diff options
Diffstat (limited to 'mastodon')
-rw-r--r-- | mastodon/Mastodon.py | 102 | ||||
-rw-r--r-- | mastodon/streaming.py | 50 |
2 files changed, 118 insertions, 34 deletions
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index ab9ac1f..3851fbf 100644 --- a/mastodon/Mastodon.py +++ b/mastodon/Mastodon.py | |||
@@ -120,10 +120,27 @@ class AttribAccessDict(dict): | |||
120 | raise AttributeError("Attribute-style access is read only") | 120 | raise AttributeError("Attribute-style access is read only") |
121 | super(AttribAccessDict, self).__setattr__(attr, val) | 121 | super(AttribAccessDict, self).__setattr__(attr, val) |
122 | 122 | ||
123 | |||
123 | ### | 124 | ### |
124 | # The actual Mastodon class | 125 | # List helper class. |
126 | # Defined at top level so it can be pickled. | ||
125 | ### | 127 | ### |
128 | class AttribAccessList(list): | ||
129 | def __getattr__(self, attr): | ||
130 | if attr in self: | ||
131 | return self[attr] | ||
132 | else: | ||
133 | raise AttributeError("Attribute not found: " + str(attr)) | ||
126 | 134 | ||
135 | def __setattr__(self, attr, val): | ||
136 | if attr in self: | ||
137 | raise AttributeError("Attribute-style access is read only") | ||
138 | super(AttribAccessList, self).__setattr__(attr, val) | ||
139 | |||
140 | |||
141 | ### | ||
142 | # The actual Mastodon class | ||
143 | ### | ||
127 | class Mastodon: | 144 | class Mastodon: |
128 | """ | 145 | """ |
129 | Thorough and easy to use Mastodon | 146 | Thorough and easy to use Mastodon |
@@ -1636,13 +1653,23 @@ class Mastodon: | |||
1636 | # Reading data: Bookmarks | 1653 | # Reading data: Bookmarks |
1637 | ### | 1654 | ### |
1638 | @api_version("3.1.0", "3.1.0", __DICT_VERSION_STATUS) | 1655 | @api_version("3.1.0", "3.1.0", __DICT_VERSION_STATUS) |
1639 | def bookmarks(self): | 1656 | def bookmarks(self, max_id=None, min_id=None, since_id=None, limit=None): |
1640 | """ | 1657 | """ |
1641 | Get a list of statuses bookmarked by the logged-in user. | 1658 | Get a list of statuses bookmarked by the logged-in user. |
1642 | 1659 | ||
1643 | Returns a list of `toot dicts`_. | 1660 | Returns a list of `toot dicts`_. |
1644 | """ | 1661 | """ |
1645 | return self.__api_request('GET', '/api/v1/bookmarks') | 1662 | if max_id != None: |
1663 | max_id = self.__unpack_id(max_id) | ||
1664 | |||
1665 | if min_id != None: | ||
1666 | min_id = self.__unpack_id(min_id) | ||
1667 | |||
1668 | if since_id != None: | ||
1669 | since_id = self.__unpack_id(since_id) | ||
1670 | |||
1671 | params = self.__generate_params(locals()) | ||
1672 | return self.__api_request('GET', '/api/v1/bookmarks', params) | ||
1646 | 1673 | ||
1647 | ### | 1674 | ### |
1648 | # Writing data: Statuses | 1675 | # Writing data: Statuses |
@@ -3043,8 +3070,8 @@ class Mastodon: | |||
3043 | Returns the next page or None if no further data is available. | 3070 | Returns the next page or None if no further data is available. |
3044 | """ | 3071 | """ |
3045 | if isinstance(previous_page, list) and len(previous_page) != 0: | 3072 | if isinstance(previous_page, list) and len(previous_page) != 0: |
3046 | if hasattr(previous_page[-1], '_pagination_next'): | 3073 | if hasattr(previous_page, '_pagination_next'): |
3047 | params = copy.deepcopy(previous_page[-1]._pagination_next) | 3074 | params = copy.deepcopy(previous_page._pagination_next) |
3048 | else: | 3075 | else: |
3049 | return None | 3076 | return None |
3050 | else: | 3077 | else: |
@@ -3067,8 +3094,8 @@ class Mastodon: | |||
3067 | Returns the previous page or None if no further data is available. | 3094 | Returns the previous page or None if no further data is available. |
3068 | """ | 3095 | """ |
3069 | if isinstance(next_page, list) and len(next_page) != 0: | 3096 | if isinstance(next_page, list) and len(next_page) != 0: |
3070 | if hasattr(next_page[0], '_pagination_prev'): | 3097 | if hasattr(next_page, '_pagination_prev'): |
3071 | params = copy.deepcopy(next_page[0]._pagination_prev) | 3098 | params = copy.deepcopy(next_page._pagination_prev) |
3072 | else: | 3099 | else: |
3073 | return None | 3100 | return None |
3074 | else: | 3101 | else: |
@@ -3233,7 +3260,7 @@ class Mastodon: | |||
3233 | if (key in json_object and isinstance(json_object[key], six.text_type)): | 3260 | if (key in json_object and isinstance(json_object[key], six.text_type)): |
3234 | if json_object[key].lower() == 'true': | 3261 | if json_object[key].lower() == 'true': |
3235 | json_object[key] = True | 3262 | json_object[key] = True |
3236 | if json_object[key].lower() == 'False': | 3263 | if json_object[key].lower() == 'false': |
3237 | json_object[key] = False | 3264 | json_object[key] = False |
3238 | return json_object | 3265 | return json_object |
3239 | 3266 | ||
@@ -3446,6 +3473,7 @@ class Mastodon: | |||
3446 | if isinstance(response, list) and \ | 3473 | if isinstance(response, list) and \ |
3447 | 'Link' in response_object.headers and \ | 3474 | 'Link' in response_object.headers and \ |
3448 | response_object.headers['Link'] != "": | 3475 | response_object.headers['Link'] != "": |
3476 | response = AttribAccessList(response) | ||
3449 | tmp_urls = requests.utils.parse_header_links( | 3477 | tmp_urls = requests.utils.parse_header_links( |
3450 | response_object.headers['Link'].rstrip('>').replace('>,<', ',<')) | 3478 | response_object.headers['Link'].rstrip('>').replace('>,<', ',<')) |
3451 | for url in tmp_urls: | 3479 | for url in tmp_urls: |
@@ -3470,7 +3498,12 @@ class Mastodon: | |||
3470 | del next_params['since_id'] | 3498 | del next_params['since_id'] |
3471 | if "min_id" in next_params: | 3499 | if "min_id" in next_params: |
3472 | del next_params['min_id'] | 3500 | del next_params['min_id'] |
3473 | response[-1]._pagination_next = next_params | 3501 | response._pagination_next = next_params |
3502 | |||
3503 | # Maybe other API users rely on the pagination info in the last item | ||
3504 | # Will be removed in future | ||
3505 | if isinstance(response[-1], AttribAccessDict): | ||
3506 | response[-1]._pagination_next = next_params | ||
3474 | 3507 | ||
3475 | if url['rel'] == 'prev': | 3508 | if url['rel'] == 'prev': |
3476 | # Be paranoid and extract since_id or min_id specifically | 3509 | # Be paranoid and extract since_id or min_id specifically |
@@ -3489,8 +3522,13 @@ class Mastodon: | |||
3489 | prev_params['since_id'] = since_id | 3522 | prev_params['since_id'] = since_id |
3490 | if "max_id" in prev_params: | 3523 | if "max_id" in prev_params: |
3491 | del prev_params['max_id'] | 3524 | del prev_params['max_id'] |
3492 | response[0]._pagination_prev = prev_params | 3525 | response._pagination_prev = prev_params |
3493 | 3526 | ||
3527 | # Maybe other API users rely on the pagination info in the first item | ||
3528 | # Will be removed in future | ||
3529 | if isinstance(response[0], AttribAccessDict): | ||
3530 | response[0]._pagination_prev = prev_params | ||
3531 | |||
3494 | # New and fantastico (post-2.6.0): min_id pagination | 3532 | # New and fantastico (post-2.6.0): min_id pagination |
3495 | matchgroups = re.search(r"[?&]min_id=([^&]+)", prev_url) | 3533 | matchgroups = re.search(r"[?&]min_id=([^&]+)", prev_url) |
3496 | if matchgroups: | 3534 | if matchgroups: |
@@ -3504,7 +3542,12 @@ class Mastodon: | |||
3504 | prev_params['min_id'] = min_id | 3542 | prev_params['min_id'] = min_id |
3505 | if "max_id" in prev_params: | 3543 | if "max_id" in prev_params: |
3506 | del prev_params['max_id'] | 3544 | del prev_params['max_id'] |
3507 | response[0]._pagination_prev = prev_params | 3545 | response._pagination_prev = prev_params |
3546 | |||
3547 | # Maybe other API users rely on the pagination info in the first item | ||
3548 | # Will be removed in future | ||
3549 | if isinstance(response[0], AttribAccessDict): | ||
3550 | response[0]._pagination_prev = prev_params | ||
3508 | 3551 | ||
3509 | return response | 3552 | return response |
3510 | 3553 | ||
@@ -3570,7 +3613,8 @@ class Mastodon: | |||
3570 | 3613 | ||
3571 | def close(self): | 3614 | def close(self): |
3572 | self.closed = True | 3615 | self.closed = True |
3573 | self.connection.close() | 3616 | if not self.connection is None: |
3617 | self.connection.close() | ||
3574 | 3618 | ||
3575 | def is_alive(self): | 3619 | def is_alive(self): |
3576 | return self._thread.is_alive() | 3620 | return self._thread.is_alive() |
@@ -3581,6 +3625,14 @@ class Mastodon: | |||
3581 | else: | 3625 | else: |
3582 | return True | 3626 | return True |
3583 | 3627 | ||
3628 | def _sleep_attentive(self): | ||
3629 | if self._thread != threading.current_thread(): | ||
3630 | raise RuntimeError ("Illegal call from outside the stream_handle thread") | ||
3631 | time_remaining = self.reconnect_async_wait_sec | ||
3632 | while time_remaining>0 and not self.closed: | ||
3633 | time.sleep(0.5) | ||
3634 | time_remaining -= 0.5 | ||
3635 | |||
3584 | def _threadproc(self): | 3636 | def _threadproc(self): |
3585 | self._thread = threading.current_thread() | 3637 | self._thread = threading.current_thread() |
3586 | 3638 | ||
@@ -3602,16 +3654,26 @@ class Mastodon: | |||
3602 | self.reconnecting = True | 3654 | self.reconnecting = True |
3603 | connect_success = False | 3655 | connect_success = False |
3604 | while not connect_success: | 3656 | while not connect_success: |
3605 | connect_success = True | 3657 | if self.closed: |
3658 | # Someone from outside stopped the streaming | ||
3659 | self.running = False | ||
3660 | break | ||
3606 | try: | 3661 | try: |
3607 | self.connection = self.connect_func() | 3662 | the_connection = self.connect_func() |
3608 | if self.connection.status_code != 200: | 3663 | if the_connection.status_code != 200: |
3609 | time.sleep(self.reconnect_async_wait_sec) | 3664 | exception = MastodonNetworkError(f"Could not connect to server. " |
3610 | connect_success = False | 3665 | f"HTTP status: {the_connection.status_code}") |
3611 | exception = MastodonNetworkError("Could not connect to server.") | ||
3612 | listener.on_abort(exception) | 3666 | listener.on_abort(exception) |
3667 | self._sleep_attentive() | ||
3668 | if self.closed: | ||
3669 | # Here we have maybe a rare race condition. Exactly on connect, someone | ||
3670 | # stopped the streaming before. We close the previous established connection: | ||
3671 | the_connection.close() | ||
3672 | else: | ||
3673 | self.connection = the_connection | ||
3674 | connect_success = True | ||
3613 | except: | 3675 | except: |
3614 | time.sleep(self.reconnect_async_wait_sec) | 3676 | self._sleep_attentive() |
3615 | connect_success = False | 3677 | connect_success = False |
3616 | self.reconnecting = False | 3678 | self.reconnecting = False |
3617 | else: | 3679 | else: |
diff --git a/mastodon/streaming.py b/mastodon/streaming.py index 214ed1c..ceb61ea 100644 --- a/mastodon/streaming.py +++ b/mastodon/streaming.py | |||
@@ -45,6 +45,16 @@ class StreamListener(object): | |||
45 | contains the resulting conversation dict.""" | 45 | contains the resulting conversation dict.""" |
46 | pass | 46 | pass |
47 | 47 | ||
48 | def on_unknown_event(self, name, unknown_event = None): | ||
49 | """An unknown mastodon API event has been received. The name contains the event-name and unknown_event | ||
50 | contains the content of the unknown event. | ||
51 | |||
52 | This function must be implemented, if unknown events should be handled without an error. | ||
53 | """ | ||
54 | exception = MastodonMalformedEventError('Bad event type', name) | ||
55 | self.on_abort(exception) | ||
56 | raise exception | ||
57 | |||
48 | def handle_heartbeat(self): | 58 | def handle_heartbeat(self): |
49 | """The server has sent us a keep-alive message. This callback may be | 59 | """The server has sent us a keep-alive message. This callback may be |
50 | useful to carry out periodic housekeeping tasks, or just to confirm | 60 | useful to carry out periodic housekeeping tasks, or just to confirm |
@@ -56,6 +66,11 @@ class StreamListener(object): | |||
56 | Handles a stream of events from the Mastodon server. When each event | 66 | Handles a stream of events from the Mastodon server. When each event |
57 | is received, the corresponding .on_[name]() method is called. | 67 | is received, the corresponding .on_[name]() method is called. |
58 | 68 | ||
69 | When the Mastodon API changes, the on_unknown_event(name, content) | ||
70 | function is called. | ||
71 | The default behavior is to throw an error. Define a callback handler | ||
72 | to intercept unknown events if needed (and avoid errors) | ||
73 | |||
59 | response; a requests response object with the open stream for reading. | 74 | response; a requests response object with the open stream for reading. |
60 | """ | 75 | """ |
61 | event = {} | 76 | event = {} |
@@ -137,33 +152,32 @@ class StreamListener(object): | |||
137 | exception, | 152 | exception, |
138 | err | 153 | err |
139 | ) | 154 | ) |
140 | 155 | # New mastodon API also supports event names with dots: | |
141 | handler_name = 'on_' + name | 156 | handler_name = 'on_' + name.replace('.', '_') |
142 | try: | 157 | # A generic way to handle unknown events to make legacy code more stable for future changes |
143 | handler = getattr(self, handler_name) | 158 | handler = getattr(self, handler_name, self.on_unknown_event) |
144 | except AttributeError as err: | 159 | if handler != self.on_unknown_event: |
145 | exception = MastodonMalformedEventError('Bad event type', name) | ||
146 | self.on_abort(exception) | ||
147 | six.raise_from( | ||
148 | exception, | ||
149 | err | ||
150 | ) | ||
151 | else: | ||
152 | handler(payload) | 160 | handler(payload) |
161 | else: | ||
162 | handler(name, payload) | ||
163 | |||
153 | 164 | ||
154 | class CallbackStreamListener(StreamListener): | 165 | class CallbackStreamListener(StreamListener): |
155 | """ | 166 | """ |
156 | Simple callback stream handler class. | 167 | Simple callback stream handler class. |
157 | Can optionally additionally send local update events to a separate handler. | 168 | Can optionally additionally send local update events to a separate handler. |
169 | Define an unknown_event_handler for new Mastodon API events. If not, the | ||
170 | listener will raise an error on new, not handled, events from the API. | ||
158 | """ | 171 | """ |
159 | def __init__(self, update_handler = None, local_update_handler = None, delete_handler = None, notification_handler = None, conversation_handler = None): | 172 | def __init__(self, update_handler = None, local_update_handler = None, delete_handler = None, notification_handler = None, conversation_handler = None, unknown_event_handler = None): |
160 | super(CallbackStreamListener, self).__init__() | 173 | super(CallbackStreamListener, self).__init__() |
161 | self.update_handler = update_handler | 174 | self.update_handler = update_handler |
162 | self.local_update_handler = local_update_handler | 175 | self.local_update_handler = local_update_handler |
163 | self.delete_handler = delete_handler | 176 | self.delete_handler = delete_handler |
164 | self.notification_handler = notification_handler | 177 | self.notification_handler = notification_handler |
165 | self.conversation_handler = conversation_handler | 178 | self.conversation_handler = conversation_handler |
166 | 179 | self.unknown_event_handler = unknown_event_handler | |
180 | |||
167 | def on_update(self, status): | 181 | def on_update(self, status): |
168 | if self.update_handler != None: | 182 | if self.update_handler != None: |
169 | self.update_handler(status) | 183 | self.update_handler(status) |
@@ -188,3 +202,11 @@ class CallbackStreamListener(StreamListener): | |||
188 | def on_conversation(self, conversation): | 202 | def on_conversation(self, conversation): |
189 | if self.conversation_handler != None: | 203 | if self.conversation_handler != None: |
190 | self.conversation_handler(conversation) | 204 | self.conversation_handler(conversation) |
205 | |||
206 | def on_unknown_event(self, name, unknown_event = None): | ||
207 | if self.unknown_event_handler != None: | ||
208 | self.unknown_event_handler(name, unknown_event) | ||
209 | else: | ||
210 | exception = MastodonMalformedEventError('Bad event type', name) | ||
211 | self.on_abort(exception) | ||
212 | raise exception | ||