diff options
Diffstat (limited to 'mastodon')
-rw-r--r-- | mastodon/Mastodon.py | 160 | ||||
-rw-r--r-- | mastodon/streaming.py | 50 |
2 files changed, 162 insertions, 48 deletions
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index bdf7f1b..296921e 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)) | ||
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 | |||
126 | 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 |
@@ -276,6 +293,7 @@ class Mastodon: | |||
276 | secret_file.write(response['client_id'] + "\n") | 293 | secret_file.write(response['client_id'] + "\n") |
277 | secret_file.write(response['client_secret'] + "\n") | 294 | secret_file.write(response['client_secret'] + "\n") |
278 | secret_file.write(api_base_url + "\n") | 295 | secret_file.write(api_base_url + "\n") |
296 | secret_file.write(client_name + "\n") | ||
279 | 297 | ||
280 | return (response['client_id'], response['client_secret']) | 298 | return (response['client_id'], response['client_secret']) |
281 | 299 | ||
@@ -286,7 +304,7 @@ class Mastodon: | |||
286 | api_base_url=None, debug_requests=False, | 304 | api_base_url=None, debug_requests=False, |
287 | ratelimit_method="wait", ratelimit_pacefactor=1.1, | 305 | ratelimit_method="wait", ratelimit_pacefactor=1.1, |
288 | request_timeout=__DEFAULT_TIMEOUT, mastodon_version=None, | 306 | request_timeout=__DEFAULT_TIMEOUT, mastodon_version=None, |
289 | version_check_mode = "created", session=None, feature_set="mainline"): | 307 | version_check_mode = "created", session=None, feature_set="mainline", user_agent=None): |
290 | """ | 308 | """ |
291 | Create a new API wrapper instance based on the given `client_secret` and `client_id`. If you | 309 | Create a new API wrapper instance based on the given `client_secret` and `client_id`. If you |
292 | give a `client_id` and it is not a file, you must also give a secret. If you specify an | 310 | give a `client_id` and it is not a file, you must also give a secret. If you specify an |
@@ -331,6 +349,11 @@ class Mastodon: | |||
331 | `feature_set` can be used to enable behaviour specific to non-mainline Mastodon API implementations. | 349 | `feature_set` can be used to enable behaviour specific to non-mainline Mastodon API implementations. |
332 | Details are documented in the functions that provide such functionality. Currently supported feature | 350 | Details are documented in the functions that provide such functionality. Currently supported feature |
333 | sets are `mainline`, `fedibird` and `pleroma`. | 351 | sets are `mainline`, `fedibird` and `pleroma`. |
352 | |||
353 | For some mastodon-instances a `User-Agent` header is needed. This can be set by parameter `user_agent`. From now | ||
354 | `create_app()` stores the application name into the client secret file. If `client_id` points to this file, | ||
355 | the app name will be used as `User-Agent` header as default. It's possible to modify old secret files and append | ||
356 | a client app name to use it as a `User-Agent` name. | ||
334 | """ | 357 | """ |
335 | self.api_base_url = None | 358 | self.api_base_url = None |
336 | if not api_base_url is None: | 359 | if not api_base_url is None: |
@@ -362,6 +385,9 @@ class Mastodon: | |||
362 | self.feature_set = feature_set | 385 | self.feature_set = feature_set |
363 | if not self.feature_set in ["mainline", "fedibird", "pleroma"]: | 386 | if not self.feature_set in ["mainline", "fedibird", "pleroma"]: |
364 | raise MastodonIllegalArgumentError('Requested invalid feature set') | 387 | raise MastodonIllegalArgumentError('Requested invalid feature set') |
388 | |||
389 | # General defined user-agent | ||
390 | self.user_agent = user_agent | ||
365 | 391 | ||
366 | # Token loading | 392 | # Token loading |
367 | if self.client_id is not None: | 393 | if self.client_id is not None: |
@@ -376,6 +402,11 @@ class Mastodon: | |||
376 | if not (self.api_base_url is None or try_base_url == self.api_base_url): | 402 | if not (self.api_base_url is None or try_base_url == self.api_base_url): |
377 | raise MastodonIllegalArgumentError('Mismatch in base URLs between files and/or specified') | 403 | raise MastodonIllegalArgumentError('Mismatch in base URLs between files and/or specified') |
378 | self.api_base_url = try_base_url | 404 | self.api_base_url = try_base_url |
405 | |||
406 | # With new registrations we support the 4th line to store a client_name and use it as user-agent | ||
407 | client_name = secret_file.readline() | ||
408 | if client_name and self.user_agent is None: | ||
409 | self.user_agent = client_name.rstrip() | ||
379 | else: | 410 | else: |
380 | if self.client_secret is None: | 411 | if self.client_secret is None: |
381 | raise MastodonIllegalArgumentError('Specified client id directly, but did not supply secret') | 412 | raise MastodonIllegalArgumentError('Specified client id directly, but did not supply secret') |
@@ -390,20 +421,20 @@ class Mastodon: | |||
390 | if not (self.api_base_url is None or try_base_url == self.api_base_url): | 421 | if not (self.api_base_url is None or try_base_url == self.api_base_url): |
391 | raise MastodonIllegalArgumentError('Mismatch in base URLs between files and/or specified') | 422 | raise MastodonIllegalArgumentError('Mismatch in base URLs between files and/or specified') |
392 | self.api_base_url = try_base_url | 423 | self.api_base_url = try_base_url |
424 | |||
425 | if not version_check_mode in ["created", "changed", "none"]: | ||
426 | raise MastodonIllegalArgumentError("Invalid version check method.") | ||
427 | self.version_check_mode = version_check_mode | ||
393 | 428 | ||
394 | # Versioning | 429 | # Versioning |
395 | if mastodon_version == None: | 430 | if mastodon_version == None and self.version_check_mode != 'none': |
396 | self.retrieve_mastodon_version() | 431 | self.retrieve_mastodon_version() |
397 | else: | 432 | elif self.version_check_mode != 'none': |
398 | try: | 433 | try: |
399 | self.mastodon_major, self.mastodon_minor, self.mastodon_patch = parse_version_string(mastodon_version) | 434 | self.mastodon_major, self.mastodon_minor, self.mastodon_patch = parse_version_string(mastodon_version) |
400 | except: | 435 | except: |
401 | raise MastodonVersionError("Bad version specified") | 436 | raise MastodonVersionError("Bad version specified") |
402 | 437 | ||
403 | if not version_check_mode in ["created", "changed", "none"]: | ||
404 | raise MastodonIllegalArgumentError("Invalid version check method.") | ||
405 | self.version_check_mode = version_check_mode | ||
406 | |||
407 | # Ratelimiting parameter check | 438 | # Ratelimiting parameter check |
408 | if ratelimit_method not in ["throw", "wait", "pace"]: | 439 | if ratelimit_method not in ["throw", "wait", "pace"]: |
409 | raise MastodonIllegalArgumentError("Invalid ratelimit method.") | 440 | raise MastodonIllegalArgumentError("Invalid ratelimit method.") |
@@ -965,10 +996,13 @@ class Mastodon: | |||
965 | # Reading data: Notifications | 996 | # Reading data: Notifications |
966 | ### | 997 | ### |
967 | @api_version("1.0.0", "2.9.0", __DICT_VERSION_NOTIFICATION) | 998 | @api_version("1.0.0", "2.9.0", __DICT_VERSION_NOTIFICATION) |
968 | def notifications(self, id=None, account_id=None, max_id=None, min_id=None, since_id=None, limit=None, mentions_only=None): | 999 | def notifications(self, id=None, account_id=None, max_id=None, min_id=None, since_id=None, limit=None, exclude_types=None): |
969 | """ | 1000 | """ |
970 | Fetch notifications (mentions, favourites, reblogs, follows) for the logged-in | 1001 | Fetch notifications (mentions, favourites, reblogs, follows) for the logged-in |
971 | user. Pass `account_id` to get only notifications originating from the given account. | 1002 | user. Pass `account_id` to get only notifications originating from the given account. |
1003 | |||
1004 | Parameter `exclude_types` is an array of the following `follow`, `favourite`, `reblog`, | ||
1005 | `mention`, `poll`, `follow_request` | ||
972 | 1006 | ||
973 | Can be passed an `id` to fetch a single notification. | 1007 | Can be passed an `id` to fetch a single notification. |
974 | 1008 | ||
@@ -1028,8 +1062,8 @@ class Mastodon: | |||
1028 | """ | 1062 | """ |
1029 | return self.account_verify_credentials() | 1063 | return self.account_verify_credentials() |
1030 | 1064 | ||
1031 | @api_version("1.0.0", "2.7.0", __DICT_VERSION_STATUS) | 1065 | @api_version("1.0.0", "2.8.0", __DICT_VERSION_STATUS) |
1032 | def account_statuses(self, id, only_media=False, pinned=False, exclude_replies=False, max_id=None, min_id=None, since_id=None, limit=None): | 1066 | def account_statuses(self, id, only_media=False, pinned=False, exclude_replies=False, exclude_reblogs=False, tagged=None, max_id=None, min_id=None, since_id=None, limit=None): |
1033 | """ | 1067 | """ |
1034 | Fetch statuses by user `id`. Same options as `timeline()`_ are permitted. | 1068 | Fetch statuses by user `id`. Same options as `timeline()`_ are permitted. |
1035 | Returned toots are from the perspective of the logged-in user, i.e. | 1069 | Returned toots are from the perspective of the logged-in user, i.e. |
@@ -1040,6 +1074,8 @@ class Mastodon: | |||
1040 | If `pinned` is set, return only statuses that have been pinned. Note that | 1074 | If `pinned` is set, return only statuses that have been pinned. Note that |
1041 | as of Mastodon 2.1.0, this only works properly for instance-local users. | 1075 | as of Mastodon 2.1.0, this only works properly for instance-local users. |
1042 | If `exclude_replies` is set, filter out all statuses that are replies. | 1076 | If `exclude_replies` is set, filter out all statuses that are replies. |
1077 | If `exclude_reblogs` is set, filter out all statuses that are reblogs. | ||
1078 | If `tagged` is set, return only statuses that are tagged with `tagged`. Only a single tag without a '#' is valid. | ||
1043 | 1079 | ||
1044 | Does not require authentication for Mastodon versions after 2.7.0 (returns | 1080 | Does not require authentication for Mastodon versions after 2.7.0 (returns |
1045 | publicly visible statuses in that case), for publicly visible accounts. | 1081 | publicly visible statuses in that case), for publicly visible accounts. |
@@ -1063,7 +1099,9 @@ class Mastodon: | |||
1063 | del params["only_media"] | 1099 | del params["only_media"] |
1064 | if exclude_replies == False: | 1100 | if exclude_replies == False: |
1065 | del params["exclude_replies"] | 1101 | del params["exclude_replies"] |
1066 | 1102 | if exclude_reblogs == False: | |
1103 | del params["exclude_reblogs"] | ||
1104 | |||
1067 | url = '/api/v1/accounts/{0}/statuses'.format(str(id)) | 1105 | url = '/api/v1/accounts/{0}/statuses'.format(str(id)) |
1068 | return self.__api_request('GET', url, params) | 1106 | return self.__api_request('GET', url, params) |
1069 | 1107 | ||
@@ -1633,13 +1671,23 @@ class Mastodon: | |||
1633 | # Reading data: Bookmarks | 1671 | # Reading data: Bookmarks |
1634 | ### | 1672 | ### |
1635 | @api_version("3.1.0", "3.1.0", __DICT_VERSION_STATUS) | 1673 | @api_version("3.1.0", "3.1.0", __DICT_VERSION_STATUS) |
1636 | def bookmarks(self): | 1674 | def bookmarks(self, max_id=None, min_id=None, since_id=None, limit=None): |
1637 | """ | 1675 | """ |
1638 | Get a list of statuses bookmarked by the logged-in user. | 1676 | Get a list of statuses bookmarked by the logged-in user. |
1639 | 1677 | ||
1640 | Returns a list of `toot dicts`_. | 1678 | Returns a list of `toot dicts`_. |
1641 | """ | 1679 | """ |
1642 | return self.__api_request('GET', '/api/v1/bookmarks') | 1680 | if max_id != None: |
1681 | max_id = self.__unpack_id(max_id) | ||
1682 | |||
1683 | if min_id != None: | ||
1684 | min_id = self.__unpack_id(min_id) | ||
1685 | |||
1686 | if since_id != None: | ||
1687 | since_id = self.__unpack_id(since_id) | ||
1688 | |||
1689 | params = self.__generate_params(locals()) | ||
1690 | return self.__api_request('GET', '/api/v1/bookmarks', params) | ||
1643 | 1691 | ||
1644 | ### | 1692 | ### |
1645 | # Writing data: Statuses | 1693 | # Writing data: Statuses |
@@ -2424,7 +2472,7 @@ class Mastodon: | |||
2424 | if not status_ids is None: | 2472 | if not status_ids is None: |
2425 | if not isinstance(status_ids, list): | 2473 | if not isinstance(status_ids, list): |
2426 | status_ids = [status_ids] | 2474 | status_ids = [status_ids] |
2427 | status_ids = list(map(lambda x: self.__unpack_id(x), status_ids)) | 2475 | status_ids = list(map(lambda x: self.__unpack_id(x), status_ids)) |
2428 | 2476 | ||
2429 | params_initial = locals() | 2477 | params_initial = locals() |
2430 | if forward == False: | 2478 | if forward == False: |
@@ -3041,8 +3089,8 @@ class Mastodon: | |||
3041 | Returns the next page or None if no further data is available. | 3089 | Returns the next page or None if no further data is available. |
3042 | """ | 3090 | """ |
3043 | if isinstance(previous_page, list) and len(previous_page) != 0: | 3091 | if isinstance(previous_page, list) and len(previous_page) != 0: |
3044 | if hasattr(previous_page[-1], '_pagination_next'): | 3092 | if hasattr(previous_page, '_pagination_next'): |
3045 | params = copy.deepcopy(previous_page[-1]._pagination_next) | 3093 | params = copy.deepcopy(previous_page._pagination_next) |
3046 | else: | 3094 | else: |
3047 | return None | 3095 | return None |
3048 | else: | 3096 | else: |
@@ -3065,8 +3113,8 @@ class Mastodon: | |||
3065 | Returns the previous page or None if no further data is available. | 3113 | Returns the previous page or None if no further data is available. |
3066 | """ | 3114 | """ |
3067 | if isinstance(next_page, list) and len(next_page) != 0: | 3115 | if isinstance(next_page, list) and len(next_page) != 0: |
3068 | if hasattr(next_page[0], '_pagination_prev'): | 3116 | if hasattr(next_page, '_pagination_prev'): |
3069 | params = copy.deepcopy(next_page[0]._pagination_prev) | 3117 | params = copy.deepcopy(next_page._pagination_prev) |
3070 | else: | 3118 | else: |
3071 | return None | 3119 | return None |
3072 | else: | 3120 | else: |
@@ -3231,7 +3279,7 @@ class Mastodon: | |||
3231 | if (key in json_object and isinstance(json_object[key], six.text_type)): | 3279 | if (key in json_object and isinstance(json_object[key], six.text_type)): |
3232 | if json_object[key].lower() == 'true': | 3280 | if json_object[key].lower() == 'true': |
3233 | json_object[key] = True | 3281 | json_object[key] = True |
3234 | if json_object[key].lower() == 'False': | 3282 | if json_object[key].lower() == 'false': |
3235 | json_object[key] = False | 3283 | json_object[key] = False |
3236 | return json_object | 3284 | return json_object |
3237 | 3285 | ||
@@ -3305,6 +3353,10 @@ class Mastodon: | |||
3305 | if not access_token_override is None: | 3353 | if not access_token_override is None: |
3306 | headers['Authorization'] = 'Bearer ' + access_token_override | 3354 | headers['Authorization'] = 'Bearer ' + access_token_override |
3307 | 3355 | ||
3356 | # Add user-agent | ||
3357 | if self.user_agent: | ||
3358 | headers['User-Agent'] = self.user_agent | ||
3359 | |||
3308 | # Determine base URL | 3360 | # Determine base URL |
3309 | base_url = self.api_base_url | 3361 | base_url = self.api_base_url |
3310 | if not base_url_override is None: | 3362 | if not base_url_override is None: |
@@ -3356,8 +3408,11 @@ class Mastodon: | |||
3356 | self.ratelimit_limit = int(response_object.headers['X-RateLimit-Limit']) | 3408 | self.ratelimit_limit = int(response_object.headers['X-RateLimit-Limit']) |
3357 | 3409 | ||
3358 | try: | 3410 | try: |
3359 | ratelimit_reset_datetime = dateutil.parser.parse(response_object.headers['X-RateLimit-Reset']) | 3411 | if str(int(response_object.headers['X-RateLimit-Reset'])) == response_object.headers['X-RateLimit-Reset']: |
3360 | self.ratelimit_reset = self.__datetime_to_epoch(ratelimit_reset_datetime) | 3412 | self.ratelimit_reset = int(response_object.headers['X-RateLimit-Reset']) |
3413 | else: | ||
3414 | ratelimit_reset_datetime = dateutil.parser.parse(response_object.headers['X-RateLimit-Reset']) | ||
3415 | self.ratelimit_reset = self.__datetime_to_epoch(ratelimit_reset_datetime) | ||
3361 | 3416 | ||
3362 | # Adjust server time to local clock | 3417 | # Adjust server time to local clock |
3363 | if 'Date' in response_object.headers: | 3418 | if 'Date' in response_object.headers: |
@@ -3444,6 +3499,7 @@ class Mastodon: | |||
3444 | if isinstance(response, list) and \ | 3499 | if isinstance(response, list) and \ |
3445 | 'Link' in response_object.headers and \ | 3500 | 'Link' in response_object.headers and \ |
3446 | response_object.headers['Link'] != "": | 3501 | response_object.headers['Link'] != "": |
3502 | response = AttribAccessList(response) | ||
3447 | tmp_urls = requests.utils.parse_header_links( | 3503 | tmp_urls = requests.utils.parse_header_links( |
3448 | response_object.headers['Link'].rstrip('>').replace('>,<', ',<')) | 3504 | response_object.headers['Link'].rstrip('>').replace('>,<', ',<')) |
3449 | for url in tmp_urls: | 3505 | for url in tmp_urls: |
@@ -3468,7 +3524,12 @@ class Mastodon: | |||
3468 | del next_params['since_id'] | 3524 | del next_params['since_id'] |
3469 | if "min_id" in next_params: | 3525 | if "min_id" in next_params: |
3470 | del next_params['min_id'] | 3526 | del next_params['min_id'] |
3471 | response[-1]._pagination_next = next_params | 3527 | response._pagination_next = next_params |
3528 | |||
3529 | # Maybe other API users rely on the pagination info in the last item | ||
3530 | # Will be removed in future | ||
3531 | if isinstance(response[-1], AttribAccessDict): | ||
3532 | response[-1]._pagination_next = next_params | ||
3472 | 3533 | ||
3473 | if url['rel'] == 'prev': | 3534 | if url['rel'] == 'prev': |
3474 | # Be paranoid and extract since_id or min_id specifically | 3535 | # Be paranoid and extract since_id or min_id specifically |
@@ -3487,8 +3548,13 @@ class Mastodon: | |||
3487 | prev_params['since_id'] = since_id | 3548 | prev_params['since_id'] = since_id |
3488 | if "max_id" in prev_params: | 3549 | if "max_id" in prev_params: |
3489 | del prev_params['max_id'] | 3550 | del prev_params['max_id'] |
3490 | response[0]._pagination_prev = prev_params | 3551 | response._pagination_prev = prev_params |
3491 | 3552 | ||
3553 | # Maybe other API users rely on the pagination info in the first item | ||
3554 | # Will be removed in future | ||
3555 | if isinstance(response[0], AttribAccessDict): | ||
3556 | response[0]._pagination_prev = prev_params | ||
3557 | |||
3492 | # New and fantastico (post-2.6.0): min_id pagination | 3558 | # New and fantastico (post-2.6.0): min_id pagination |
3493 | matchgroups = re.search(r"[?&]min_id=([^&]+)", prev_url) | 3559 | matchgroups = re.search(r"[?&]min_id=([^&]+)", prev_url) |
3494 | if matchgroups: | 3560 | if matchgroups: |
@@ -3502,7 +3568,12 @@ class Mastodon: | |||
3502 | prev_params['min_id'] = min_id | 3568 | prev_params['min_id'] = min_id |
3503 | if "max_id" in prev_params: | 3569 | if "max_id" in prev_params: |
3504 | del prev_params['max_id'] | 3570 | del prev_params['max_id'] |
3505 | response[0]._pagination_prev = prev_params | 3571 | response._pagination_prev = prev_params |
3572 | |||
3573 | # Maybe other API users rely on the pagination info in the first item | ||
3574 | # Will be removed in future | ||
3575 | if isinstance(response[0], AttribAccessDict): | ||
3576 | response[0]._pagination_prev = prev_params | ||
3506 | 3577 | ||
3507 | return response | 3578 | return response |
3508 | 3579 | ||
@@ -3547,6 +3618,8 @@ class Mastodon: | |||
3547 | # Connect function (called and then potentially passed to async handler) | 3618 | # Connect function (called and then potentially passed to async handler) |
3548 | def connect_func(): | 3619 | def connect_func(): |
3549 | headers = {"Authorization": "Bearer " + self.access_token} if self.access_token else {} | 3620 | headers = {"Authorization": "Bearer " + self.access_token} if self.access_token else {} |
3621 | if self.user_agent: | ||
3622 | headers['User-Agent'] = self.user_agent | ||
3550 | connection = self.session.get(url + endpoint, headers = headers, data = params, stream = True, | 3623 | connection = self.session.get(url + endpoint, headers = headers, data = params, stream = True, |
3551 | timeout=(self.request_timeout, timeout)) | 3624 | timeout=(self.request_timeout, timeout)) |
3552 | 3625 | ||
@@ -3568,7 +3641,8 @@ class Mastodon: | |||
3568 | 3641 | ||
3569 | def close(self): | 3642 | def close(self): |
3570 | self.closed = True | 3643 | self.closed = True |
3571 | self.connection.close() | 3644 | if not self.connection is None: |
3645 | self.connection.close() | ||
3572 | 3646 | ||
3573 | def is_alive(self): | 3647 | def is_alive(self): |
3574 | return self._thread.is_alive() | 3648 | return self._thread.is_alive() |
@@ -3579,6 +3653,14 @@ class Mastodon: | |||
3579 | else: | 3653 | else: |
3580 | return True | 3654 | return True |
3581 | 3655 | ||
3656 | def _sleep_attentive(self): | ||
3657 | if self._thread != threading.current_thread(): | ||
3658 | raise RuntimeError ("Illegal call from outside the stream_handle thread") | ||
3659 | time_remaining = self.reconnect_async_wait_sec | ||
3660 | while time_remaining>0 and not self.closed: | ||
3661 | time.sleep(0.5) | ||
3662 | time_remaining -= 0.5 | ||
3663 | |||
3582 | def _threadproc(self): | 3664 | def _threadproc(self): |
3583 | self._thread = threading.current_thread() | 3665 | self._thread = threading.current_thread() |
3584 | 3666 | ||
@@ -3600,16 +3682,26 @@ class Mastodon: | |||
3600 | self.reconnecting = True | 3682 | self.reconnecting = True |
3601 | connect_success = False | 3683 | connect_success = False |
3602 | while not connect_success: | 3684 | while not connect_success: |
3603 | connect_success = True | 3685 | if self.closed: |
3686 | # Someone from outside stopped the streaming | ||
3687 | self.running = False | ||
3688 | break | ||
3604 | try: | 3689 | try: |
3605 | self.connection = self.connect_func() | 3690 | the_connection = self.connect_func() |
3606 | if self.connection.status_code != 200: | 3691 | if the_connection.status_code != 200: |
3607 | time.sleep(self.reconnect_async_wait_sec) | 3692 | exception = MastodonNetworkError(f"Could not connect to server. " |
3608 | connect_success = False | 3693 | f"HTTP status: {the_connection.status_code}") |
3609 | exception = MastodonNetworkError("Could not connect to server.") | ||
3610 | listener.on_abort(exception) | 3694 | listener.on_abort(exception) |
3695 | self._sleep_attentive() | ||
3696 | if self.closed: | ||
3697 | # Here we have maybe a rare race condition. Exactly on connect, someone | ||
3698 | # stopped the streaming before. We close the previous established connection: | ||
3699 | the_connection.close() | ||
3700 | else: | ||
3701 | self.connection = the_connection | ||
3702 | connect_success = True | ||
3611 | except: | 3703 | except: |
3612 | time.sleep(self.reconnect_async_wait_sec) | 3704 | self._sleep_attentive() |
3613 | connect_success = False | 3705 | connect_success = False |
3614 | self.reconnecting = False | 3706 | self.reconnecting = False |
3615 | else: | 3707 | 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 | ||