diff options
-rw-r--r-- | docs/index.rst | 47 | ||||
-rw-r--r-- | mastodon/Mastodon.py | 59 | ||||
-rw-r--r-- | mastodon/__init__.py | 4 | ||||
-rw-r--r-- | mastodon/streaming.py | 91 |
4 files changed, 120 insertions, 81 deletions
diff --git a/docs/index.rst b/docs/index.rst index 14ec49f..a8181b1 100644 --- a/docs/index.rst +++ b/docs/index.rst | |||
@@ -37,7 +37,7 @@ Mastodon.py | |||
37 | ) | 37 | ) |
38 | mastodon.toot('Tooting from python using #mastodonpy !') | 38 | mastodon.toot('Tooting from python using #mastodonpy !') |
39 | 39 | ||
40 | `Mastodon`_ is an ostatus based twitter-like federated social | 40 | `Mastodon`_ is an ActivityPub and OStatus based twitter-like federated social |
41 | network node. It has an API that allows you to interact with its | 41 | network node. It has an API that allows you to interact with its |
42 | every aspect. This is a simple python wrapper for that api, provided | 42 | every aspect. This is a simple python wrapper for that api, provided |
43 | as a single python module. By default, it talks to the | 43 | as a single python module. By default, it talks to the |
@@ -112,23 +112,23 @@ Error handling | |||
112 | When Mastodon.py encounters an error, it will raise an exception, generally with | 112 | When Mastodon.py encounters an error, it will raise an exception, generally with |
113 | some text included to tell you what went wrong. | 113 | some text included to tell you what went wrong. |
114 | 114 | ||
115 | The base class that all mastodon exceptions inherit from is the MastodonError | 115 | The base class that all mastodon exceptions inherit from is `MastodonError`. |
116 | class. If you are only interested in the fact an error was raised somewhere in | 116 | If you are only interested in the fact an error was raised somewhere in |
117 | Mastodon.py, and not the details, this is the exception you can catch. | 117 | Mastodon.py, and not the details, this is the exception you can catch. |
118 | 118 | ||
119 | MastodonIllegalArgumentError is generally a programming problem - you asked the | 119 | `MastodonIllegalArgumentError` is generally a programming problem - you asked the |
120 | API to do something obviously invalid (i.e. specify a privacy scope that does | 120 | API to do something obviously invalid (i.e. specify a privacy scope that does |
121 | not exist). | 121 | not exist). |
122 | 122 | ||
123 | MastodonFileNotFoundError and MastodonNetworkError are IO errors - could be you | 123 | `MastodonFileNotFoundError` and `MastodonNetworkError` are IO errors - could be you |
124 | specified a wrong URL, could be the internet is down or your hard drive is | 124 | specified a wrong URL, could be the internet is down or your hard drive is |
125 | dying. They inherit from MastodonIOError, for easy catching. | 125 | dying. They inherit from MastodonIOError, for easy catching. |
126 | 126 | ||
127 | MastodonAPIError is an error returned from the Mastodon instance - the server | 127 | `MastodonAPIError` is an error returned from the Mastodon instance - the server |
128 | has decided it can't fullfill your request (i.e. you requested info on a user that | 128 | has decided it can't fullfill your request (i.e. you requested info on a user that |
129 | does not exist). | 129 | does not exist). |
130 | 130 | ||
131 | MastodonRatelimitError is raised when you hit an API rate limit. You should try | 131 | `MastodonRatelimitError` is raised when you hit an API rate limit. You should try |
132 | again after a while (see the rate limiting section above). | 132 | again after a while (see the rate limiting section above). |
133 | 133 | ||
134 | Return values | 134 | Return values |
@@ -172,7 +172,7 @@ Toot dicts | |||
172 | 'uri': # Descriptor for the toot | 172 | 'uri': # Descriptor for the toot |
173 | # EG 'tag:mastodon.social,2016-11-25:objectId=<id>:objectType=Status' | 173 | # EG 'tag:mastodon.social,2016-11-25:objectId=<id>:objectType=Status' |
174 | 'url': # URL of the toot | 174 | 'url': # URL of the toot |
175 | 'account': # Account dict for the account which posted the status | 175 | 'account': # User dict for the account which posted the status |
176 | 'in_reply_to_id': # Numerical id of the toot this toot is in response to | 176 | 'in_reply_to_id': # Numerical id of the toot this toot is in response to |
177 | 'in_reply_to_account_id': # Numerical id of the account this toot is in response to | 177 | 'in_reply_to_account_id': # Numerical id of the account this toot is in response to |
178 | 'reblog': # Denotes whether the toot is a reblog | 178 | 'reblog': # Denotes whether the toot is a reblog |
@@ -531,10 +531,35 @@ Streaming | |||
531 | --------- | 531 | --------- |
532 | These functions allow access to the streaming API. | 532 | These functions allow access to the streaming API. |
533 | 533 | ||
534 | .. automethod:: Mastodon.user_stream | 534 | If async is False, these methods block forever (or until an |
535 | .. automethod:: Mastodon.public_stream | 535 | exception is raised). |
536 | .. automethod:: Mastodon.hashtag_stream | ||
537 | 536 | ||
537 | If async is True, the listener will listen on another thread and these methods | ||
538 | will return a handle corresponding to the open connection. The | ||
539 | connection may be closed at any time by calling its close() method. | ||
540 | |||
541 | The streaming functions take instances of `StreamListener` as a parameter. | ||
542 | A `CallbackStreamListener` class that allows you to specify function callbacks | ||
543 | directly is included for convenience. | ||
544 | |||
545 | .. automethod:: Mastodon.stream_user | ||
546 | .. automethod:: Mastodon.stream_public | ||
547 | .. automethod:: Mastodon.stream_local | ||
548 | .. automethod:: Mastodon.stream_hashtag | ||
549 | |||
550 | StreamListener | ||
551 | ~~~~~~~~~~~~~~ | ||
552 | |||
553 | .. autoclass:: StreamListener | ||
554 | .. automethod:: StreamListener.on_update | ||
555 | .. automethod:: StreamListener.on_notification | ||
556 | .. automethod:: StreamListener.on_delete | ||
557 | .. automethod:: StreamListener.handle_heartbeat | ||
558 | |||
559 | CallbackStreamListener | ||
560 | ~~~~~~~~~~~~~~~~~~~~~~ | ||
561 | |||
562 | .. autoclass:: CallbackStreamListener | ||
538 | 563 | ||
539 | .. _Mastodon: https://github.com/tootsuite/mastodon | 564 | .. _Mastodon: https://github.com/tootsuite/mastodon |
540 | .. _Mastodon flagship instance: http://mastodon.social/ | 565 | .. _Mastodon flagship instance: http://mastodon.social/ |
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index d2d634e..88f4906 100644 --- a/mastodon/Mastodon.py +++ b/mastodon/Mastodon.py | |||
@@ -17,10 +17,11 @@ import re | |||
17 | import copy | 17 | import copy |
18 | import threading | 18 | import threading |
19 | import sys | 19 | import sys |
20 | |||
20 | try: | 21 | try: |
21 | from urllib.parse import urlparse | 22 | from urllib.parse import urlparse |
22 | except ImportError: | 23 | except ImportError: |
23 | from urlparse import urlparse | 24 | from urlparse import urlparse |
24 | 25 | ||
25 | 26 | ||
26 | class Mastodon: | 27 | class Mastodon: |
@@ -1009,57 +1010,34 @@ class Mastodon: | |||
1009 | ### | 1010 | ### |
1010 | # Streaming | 1011 | # Streaming |
1011 | ### | 1012 | ### |
1012 | def user_stream(self, listener, async=False): | 1013 | def stream_user(self, listener, async=False): |
1013 | """ | 1014 | """ |
1014 | Streams events that are relevant to the authorized user, i.e. home | 1015 | Streams events that are relevant to the authorized user, i.e. home |
1015 | timeline and notifications. 'listener' should be a subclass of | 1016 | timeline and notifications. 'listener' should be a subclass of |
1016 | StreamListener which will receive callbacks for incoming events. | 1017 | StreamListener which will receive callbacks for incoming events. |
1017 | |||
1018 | If async is False, this method blocks forever. | ||
1019 | |||
1020 | If async is True, 'listener' will listen on another thread and this method | ||
1021 | will return a handle corresponding to the open connection. The | ||
1022 | connection may be closed at any time by calling its close() method. | ||
1023 | """ | 1018 | """ |
1024 | return self.__stream('/api/v1/streaming/user', listener, async=async) | 1019 | return self.__stream('/api/v1/streaming/user', listener, async=async) |
1025 | 1020 | ||
1026 | def public_stream(self, listener, async=False): | 1021 | def stream_public(self, listener, async=False): |
1027 | """ | 1022 | """ |
1028 | Streams public events. 'listener' should be a subclass of StreamListener | 1023 | Streams public events. 'listener' should be a subclass of StreamListener |
1029 | which will receive callbacks for incoming events. | 1024 | which will receive callbacks for incoming events. |
1030 | |||
1031 | If async is False, this method blocks forever. | ||
1032 | |||
1033 | If async is True, 'listener' will listen on another thread and this method | ||
1034 | will return a handle corresponding to the open connection. The | ||
1035 | connection may be closed at any time by calling its close() method. | ||
1036 | """ | 1025 | """ |
1037 | return self.__stream('/api/v1/streaming/public', listener, async=async) | 1026 | return self.__stream('/api/v1/streaming/public', listener, async=async) |
1038 | 1027 | ||
1039 | def local_stream(self, listener, async=False): | 1028 | def stream_local(self, listener, async=False): |
1040 | """ | 1029 | """ |
1041 | Streams local events. 'listener' should be a subclass of StreamListener | 1030 | Streams local events. 'listener' should be a subclass of StreamListener |
1042 | which will receive callbacks for incoming events. | 1031 | which will receive callbacks for incoming events. |
1043 | 1032 | ||
1044 | If async is False, this method blocks forever. | ||
1045 | |||
1046 | If async is True, 'listener' will listen on another thread and this method | ||
1047 | will return a handle corresponding to the open connection. The | ||
1048 | connection may be closed at any time by calling its close() method. | ||
1049 | """ | 1033 | """ |
1050 | return self.__stream('/api/v1/streaming/public/local', listener, async=async) | 1034 | return self.__stream('/api/v1/streaming/public/local', listener, async=async) |
1051 | 1035 | ||
1052 | def hashtag_stream(self, tag, listener, async=False): | 1036 | def stream_hashtag(self, tag, listener, async=False): |
1053 | """ | 1037 | """ |
1054 | Returns all public statuses for the hashtag 'tag'. 'listener' should be | 1038 | Returns all public statuses for the hashtag 'tag'. 'listener' should be |
1055 | a subclass of StreamListener which will receive callbacks for incoming | 1039 | a subclass of StreamListener which will receive callbacks for incoming |
1056 | events. | 1040 | events. |
1057 | |||
1058 | If async is False, this method blocks forever. | ||
1059 | |||
1060 | If async is True, 'listener' will listen on another thread and this method | ||
1061 | will return a handle corresponding to the open connection. The | ||
1062 | connection may be closed at any time by calling its close() method. | ||
1063 | """ | 1041 | """ |
1064 | return self.__stream("/api/v1/streaming/hashtag?tag={}".format(tag), listener) | 1042 | return self.__stream("/api/v1/streaming/hashtag?tag={}".format(tag), listener) |
1065 | 1043 | ||
@@ -1083,8 +1061,8 @@ class Mastodon: | |||
1083 | 1061 | ||
1084 | return (date_time_utc - epoch_utc).total_seconds() | 1062 | return (date_time_utc - epoch_utc).total_seconds() |
1085 | 1063 | ||
1086 | 1064 | @staticmethod | |
1087 | def __json_date_parse(self, json_object): | 1065 | def __json_date_parse(json_object): |
1088 | """ | 1066 | """ |
1089 | Parse dates in certain known json fields, if possible. | 1067 | Parse dates in certain known json fields, if possible. |
1090 | """ | 1068 | """ |
@@ -1100,7 +1078,8 @@ class Mastodon: | |||
1100 | raise MastodonAPIError('Encountered invalid date.') | 1078 | raise MastodonAPIError('Encountered invalid date.') |
1101 | return json_object | 1079 | return json_object |
1102 | 1080 | ||
1103 | def __json_id_to_bignum(self, json_object): | 1081 | @staticmethod |
1082 | def __json_id_to_bignum(json_object): | ||
1104 | """ | 1083 | """ |
1105 | Converts json string IDs to native python bignums. | 1084 | Converts json string IDs to native python bignums. |
1106 | """ | 1085 | """ |
@@ -1117,10 +1096,11 @@ class Mastodon: | |||
1117 | pass | 1096 | pass |
1118 | 1097 | ||
1119 | return json_object | 1098 | return json_object |
1120 | 1099 | ||
1121 | def __json_hooks(self, json_object): | 1100 | @staticmethod |
1122 | json_object = self.__json_date_parse(json_object) | 1101 | def __json_hooks(json_object): |
1123 | json_object = self.__json_id_to_bignum(json_object) | 1102 | json_object = Mastodon.__json_date_parse(json_object) |
1103 | json_object = Mastodon.__json_id_to_bignum(json_object) | ||
1124 | return json_object | 1104 | return json_object |
1125 | 1105 | ||
1126 | def __api_request(self, method, endpoint, params={}, files={}, do_ratelimiting=True): | 1106 | def __api_request(self, method, endpoint, params={}, files={}, do_ratelimiting=True): |
@@ -1424,6 +1404,7 @@ class MastodonError(Exception): | |||
1424 | 1404 | ||
1425 | 1405 | ||
1426 | class MastodonIllegalArgumentError(ValueError, MastodonError): | 1406 | class MastodonIllegalArgumentError(ValueError, MastodonError): |
1407 | """Raised when an incorrect parameter is passed to a function""" | ||
1427 | pass | 1408 | pass |
1428 | 1409 | ||
1429 | 1410 | ||
@@ -1432,16 +1413,24 @@ class MastodonIOError(IOError, MastodonError): | |||
1432 | 1413 | ||
1433 | 1414 | ||
1434 | class MastodonFileNotFoundError(MastodonIOError): | 1415 | class MastodonFileNotFoundError(MastodonIOError): |
1416 | """Raised when a file requested to be loaded can not be opened""" | ||
1435 | pass | 1417 | pass |
1436 | 1418 | ||
1437 | 1419 | ||
1438 | class MastodonNetworkError(MastodonIOError): | 1420 | class MastodonNetworkError(MastodonIOError): |
1421 | """Raised when network communication with the server fails""" | ||
1439 | pass | 1422 | pass |
1440 | 1423 | ||
1441 | 1424 | ||
1442 | class MastodonAPIError(MastodonError): | 1425 | class MastodonAPIError(MastodonError): |
1426 | """Raised when the mastodon API generates a response that cannot be handled""" | ||
1443 | pass | 1427 | pass |
1444 | 1428 | ||
1445 | 1429 | ||
1446 | class MastodonRatelimitError(MastodonError): | 1430 | class MastodonRatelimitError(MastodonError): |
1431 | """Raised when rate limiting is set to manual mode and the rate limit is exceeded""" | ||
1432 | pass | ||
1433 | |||
1434 | class MastodonMalformedEventError(MastodonError): | ||
1435 | """Raised when the server-sent event stream is malformed""" | ||
1447 | pass | 1436 | pass |
diff --git a/mastodon/__init__.py b/mastodon/__init__.py index 9c8e39b..fdf776d 100644 --- a/mastodon/__init__.py +++ b/mastodon/__init__.py | |||
@@ -1,4 +1,4 @@ | |||
1 | from mastodon.Mastodon import Mastodon | 1 | from mastodon.Mastodon import Mastodon |
2 | from mastodon.streaming import StreamListener, MalformedEventError | 2 | from mastodon.streaming import StreamListener, CallbackStreamListener |
3 | 3 | ||
4 | __all__ = ['Mastodon', 'StreamListener', 'MalformedEventError'] | 4 | __all__ = ['Mastodon', 'StreamListener', 'CallbackStreamListener'] |
diff --git a/mastodon/streaming.py b/mastodon/streaming.py index 290ed44..92a02dc 100644 --- a/mastodon/streaming.py +++ b/mastodon/streaming.py | |||
@@ -4,17 +4,9 @@ https://github.com/tootsuite/mastodon/blob/master/docs/Using-the-API/Streaming-A | |||
4 | """ | 4 | """ |
5 | 5 | ||
6 | import json | 6 | import json |
7 | import logging | ||
8 | import six | 7 | import six |
9 | 8 | from mastodon import Mastodon | |
10 | 9 | from mastodon.Mastodon import MastodonMalformedEventError | |
11 | log = logging.getLogger(__name__) | ||
12 | |||
13 | |||
14 | class MalformedEventError(Exception): | ||
15 | """Raised when the server-sent event stream is malformed.""" | ||
16 | pass | ||
17 | |||
18 | 10 | ||
19 | class StreamListener(object): | 11 | class StreamListener(object): |
20 | """Callbacks for the streaming API. Create a subclass, override the on_xxx | 12 | """Callbacks for the streaming API. Create a subclass, override the on_xxx |
@@ -24,7 +16,7 @@ class StreamListener(object): | |||
24 | 16 | ||
25 | def on_update(self, status): | 17 | def on_update(self, status): |
26 | """A new status has appeared! 'status' is the parsed JSON dictionary | 18 | """A new status has appeared! 'status' is the parsed JSON dictionary |
27 | describing the status.""" | 19 | describing the status.""" |
28 | pass | 20 | pass |
29 | 21 | ||
30 | def on_notification(self, notification): | 22 | def on_notification(self, notification): |
@@ -40,7 +32,8 @@ describing the status.""" | |||
40 | """The server has sent us a keep-alive message. This callback may be | 32 | """The server has sent us a keep-alive message. This callback may be |
41 | useful to carry out periodic housekeeping tasks, or just to confirm | 33 | useful to carry out periodic housekeeping tasks, or just to confirm |
42 | that the connection is still open.""" | 34 | that the connection is still open.""" |
43 | 35 | pass | |
36 | |||
44 | def handle_stream(self, lines): | 37 | def handle_stream(self, lines): |
45 | """ | 38 | """ |
46 | Handles a stream of events from the Mastodon server. When each event | 39 | Handles a stream of events from the Mastodon server. When each event |
@@ -55,7 +48,7 @@ describing the status.""" | |||
55 | line = raw_line.decode('utf-8') | 48 | line = raw_line.decode('utf-8') |
56 | except UnicodeDecodeError as err: | 49 | except UnicodeDecodeError as err: |
57 | six.raise_from( | 50 | six.raise_from( |
58 | MalformedEventError("Malformed UTF-8", line), | 51 | MastodonMalformedEventError("Malformed UTF-8", line), |
59 | err | 52 | err |
60 | ) | 53 | ) |
61 | 54 | ||
@@ -63,7 +56,7 @@ describing the status.""" | |||
63 | self.handle_heartbeat() | 56 | self.handle_heartbeat() |
64 | elif line == '': | 57 | elif line == '': |
65 | # end of event | 58 | # end of event |
66 | self._despatch(event) | 59 | self._dispatch(event) |
67 | event = {} | 60 | event = {} |
68 | else: | 61 | else: |
69 | key, value = line.split(': ', 1) | 62 | key, value = line.split(': ', 1) |
@@ -74,33 +67,65 @@ describing the status.""" | |||
74 | else: | 67 | else: |
75 | event[key] = value | 68 | event[key] = value |
76 | 69 | ||
77 | # end of stream | 70 | def _dispatch(self, event): |
78 | if event: | ||
79 | log.warn("outstanding partial event at end of stream: %s", event) | ||
80 | |||
81 | def _despatch(self, event): | ||
82 | try: | 71 | try: |
83 | name = event['event'] | 72 | name = event['event'] |
84 | data = event['data'] | 73 | data = event['data'] |
85 | payload = json.loads(data) | 74 | payload = json.loads(data, object_hook = Mastodon._Mastodon__json_hooks) |
86 | except KeyError as err: | 75 | except KeyError as err: |
87 | six.raise_from( | 76 | six.raise_from( |
88 | MalformedEventError('Missing field', err.args[0], event), | 77 | MastodonMalformedEventError('Missing field', err.args[0], event), |
89 | err | 78 | err |
90 | ) | 79 | ) |
91 | except ValueError as err: | 80 | except ValueError as err: |
92 | # py2: plain ValueError | 81 | # py2: plain ValueError |
93 | # py3: json.JSONDecodeError, a subclass of ValueError | 82 | # py3: json.JSONDecodeError, a subclass of ValueError |
94 | six.raise_from( | 83 | six.raise_from( |
95 | MalformedEventError('Bad JSON', data), | 84 | MastodonMalformedEventError('Bad JSON', data), |
96 | err | 85 | err |
97 | ) | 86 | ) |
98 | 87 | ||
99 | handler_name = 'on_' + name | 88 | handler_name = 'on_' + name |
100 | try: | 89 | try: |
101 | handler = getattr(self, handler_name) | 90 | handler = getattr(self, handler_name) |
102 | except AttributeError: | 91 | except AttributeError as err: |
103 | log.warn("Unhandled event '%s'", name) | 92 | six.raise_from( |
93 | MastodonMalformedEventError('Bad event type', name), | ||
94 | err | ||
95 | ) | ||
104 | else: | 96 | else: |
105 | # TODO: allow handlers to return/raise to stop streaming cleanly | 97 | # TODO: allow handlers to return/raise to stop streaming cleanly |
106 | handler(payload) | 98 | handler(payload) |
99 | |||
100 | class CallbackStreamListener(StreamListener): | ||
101 | """ | ||
102 | Simple callback stream handler class. | ||
103 | Can optionally additionally send local update events to a separate handler. | ||
104 | """ | ||
105 | def __init__(self, update_handler = None, local_update_handler = None, delete_handler = None, notification_handler = None): | ||
106 | super(CallbackStreamListener, self).__init__() | ||
107 | self.update_handler = update_handler | ||
108 | self.local_update_handler = local_update_handler | ||
109 | self.delete_handler = delete_handler | ||
110 | self.notification_handler = notification_handler | ||
111 | |||
112 | def on_update(self, status): | ||
113 | if self.update_handler != None: | ||
114 | self.update_handler(status) | ||
115 | |||
116 | try: | ||
117 | if self.local_update_handler != None and not "@" in status["account"]["acct"]: | ||
118 | self.local_update_handler(status) | ||
119 | except Exception as err: | ||
120 | six.raise_from( | ||
121 | MastodonMalformedEventError('received bad update', status), | ||
122 | err | ||
123 | ) | ||
124 | |||
125 | def on_delete(self, deleted_id): | ||
126 | if self.delete_handler != None: | ||
127 | self.delete_handler(deleted_id) | ||
128 | |||
129 | def on_notification(self, notification): | ||
130 | if self.notification_handler != None: | ||
131 | self.notification_handler(notification) \ No newline at end of file | ||