diff options
-rw-r--r-- | mastodon/Mastodon.py | 48 | ||||
-rw-r--r-- | mastodon/__init__.py | 4 | ||||
-rw-r--r-- | mastodon/streaming.py | 107 | ||||
-rw-r--r-- | setup.py | 2 | ||||
-rw-r--r-- | tests/test_streaming.py | 195 |
5 files changed, 354 insertions, 2 deletions
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index 9967bdb..493fb40 100644 --- a/mastodon/Mastodon.py +++ b/mastodon/Mastodon.py | |||
@@ -12,6 +12,9 @@ import datetime | |||
12 | import dateutil | 12 | import dateutil |
13 | import dateutil.parser | 13 | import dateutil.parser |
14 | 14 | ||
15 | from contextlib import closing | ||
16 | |||
17 | |||
15 | class Mastodon: | 18 | class Mastodon: |
16 | """ | 19 | """ |
17 | Super basic but thorough and easy to use mastodon.social | 20 | Super basic but thorough and easy to use mastodon.social |
@@ -578,6 +581,37 @@ class Mastodon: | |||
578 | media_file_description = (file_name, media_file, mime_type) | 581 | media_file_description = (file_name, media_file, mime_type) |
579 | return self.__api_request('POST', '/api/v1/media', files = {'file': media_file_description}) | 582 | return self.__api_request('POST', '/api/v1/media', files = {'file': media_file_description}) |
580 | 583 | ||
584 | def user_stream(self, listener): | ||
585 | """ | ||
586 | Streams events that are relevant to the authorized user, i.e. home | ||
587 | timeline and notifications. 'listener' should be a subclass of | ||
588 | StreamListener. | ||
589 | |||
590 | This method blocks forever, calling callbacks on 'listener' for | ||
591 | incoming events. | ||
592 | """ | ||
593 | return self.__stream('/api/v1/streaming/user', listener) | ||
594 | |||
595 | def public_stream(self, listener): | ||
596 | """ | ||
597 | Streams public events. 'listener' should be a subclass of | ||
598 | StreamListener. | ||
599 | |||
600 | This method blocks forever, calling callbacks on 'listener' for | ||
601 | incoming events. | ||
602 | """ | ||
603 | return self.__stream('/api/v1/streaming/public', listener) | ||
604 | |||
605 | def hashtag_stream(self, tag, listener): | ||
606 | """ | ||
607 | Returns all public statuses for the hashtag 'tag'. 'listener' should be | ||
608 | a subclass of StreamListener. | ||
609 | |||
610 | This method blocks forever, calling callbacks on 'listener' for | ||
611 | incoming events. | ||
612 | """ | ||
613 | return self.__stream('/api/v1/streaming/hashtag', listener, params={'tag': tag}) | ||
614 | |||
581 | ### | 615 | ### |
582 | # Internal helpers, dragons probably | 616 | # Internal helpers, dragons probably |
583 | ### | 617 | ### |
@@ -710,6 +744,20 @@ class Mastodon: | |||
710 | 744 | ||
711 | return response | 745 | return response |
712 | 746 | ||
747 | def __stream(self, endpoint, listener, params = {}): | ||
748 | """ | ||
749 | Internal streaming API helper. | ||
750 | """ | ||
751 | |||
752 | headers = {} | ||
753 | if self.access_token != None: | ||
754 | headers = {'Authorization': 'Bearer ' + self.access_token} | ||
755 | |||
756 | url = self.api_base_url + endpoint | ||
757 | with closing(requests.get(url, headers = headers, data = params, stream = True)) as r: | ||
758 | listener.handle_stream(r.iter_lines()) | ||
759 | |||
760 | |||
713 | def __generate_params(self, params, exclude = []): | 761 | def __generate_params(self, params, exclude = []): |
714 | """ | 762 | """ |
715 | Internal named-parameters-to-dict helper. | 763 | Internal named-parameters-to-dict helper. |
diff --git a/mastodon/__init__.py b/mastodon/__init__.py index 17f63e6..9c8e39b 100644 --- a/mastodon/__init__.py +++ b/mastodon/__init__.py | |||
@@ -1,2 +1,4 @@ | |||
1 | from mastodon.Mastodon import Mastodon | 1 | from mastodon.Mastodon import Mastodon |
2 | __all__ = ['Mastodon'] | 2 | from mastodon.streaming import StreamListener, MalformedEventError |
3 | |||
4 | __all__ = ['Mastodon', 'StreamListener', 'MalformedEventError'] | ||
diff --git a/mastodon/streaming.py b/mastodon/streaming.py new file mode 100644 index 0000000..3212848 --- /dev/null +++ b/mastodon/streaming.py | |||
@@ -0,0 +1,107 @@ | |||
1 | ''' | ||
2 | Handlers for the Streaming API: | ||
3 | https://github.com/tootsuite/mastodon/blob/master/docs/Using-the-API/Streaming-API.md | ||
4 | ''' | ||
5 | |||
6 | import json | ||
7 | import logging | ||
8 | import six | ||
9 | |||
10 | |||
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 | |||
19 | class StreamListener(object): | ||
20 | '''Callbacks for the streaming API. Create a subclass, override the on_xxx | ||
21 | methods for the kinds of events you're interested in, then pass an instance | ||
22 | of your subclass to Mastodon.user_stream(), Mastodon.public_stream(), or | ||
23 | Mastodon.hashtag_stream().''' | ||
24 | |||
25 | def on_update(self, status): | ||
26 | '''A new status has appeared! 'status' is the parsed JSON dictionary | ||
27 | describing the status.''' | ||
28 | pass | ||
29 | |||
30 | def on_notification(self, notification): | ||
31 | '''A new notification. 'notification' is the parsed JSON dictionary | ||
32 | describing the notification.''' | ||
33 | pass | ||
34 | |||
35 | def on_delete(self, status_id): | ||
36 | '''A status has been deleted. status_id is the status' integer ID.''' | ||
37 | pass | ||
38 | |||
39 | def handle_heartbeat(self): | ||
40 | '''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 | ||
42 | that the connection is still open.''' | ||
43 | |||
44 | def handle_stream(self, lines): | ||
45 | ''' | ||
46 | Handles a stream of events from the Mastodon server. When each event | ||
47 | is received, the corresponding .on_[name]() method is called. | ||
48 | |||
49 | lines: an iterable of lines of bytes sent by the Mastodon server, as | ||
50 | returned by requests.Response.iter_lines(). | ||
51 | ''' | ||
52 | event = {} | ||
53 | for raw_line in lines: | ||
54 | try: | ||
55 | line = raw_line.decode('utf-8') | ||
56 | except UnicodeDecodeError as err: | ||
57 | six.raise_from( | ||
58 | MalformedEventError("Malformed UTF-8", line), | ||
59 | err | ||
60 | ) | ||
61 | |||
62 | if line.startswith(':'): | ||
63 | self.handle_heartbeat() | ||
64 | elif line == '': | ||
65 | # end of event | ||
66 | self._despatch(event) | ||
67 | event = {} | ||
68 | else: | ||
69 | key, value = line.split(': ', 1) | ||
70 | # According to the MDN spec, repeating the 'data' key | ||
71 | # represents a newline(!) | ||
72 | if key in event: | ||
73 | event[key] += '\n' + value | ||
74 | else: | ||
75 | event[key] = value | ||
76 | |||
77 | # end of stream | ||
78 | if event: | ||
79 | log.warn("outstanding partial event at end of stream: %s", event) | ||
80 | |||
81 | def _despatch(self, event): | ||
82 | try: | ||
83 | name = event['event'] | ||
84 | data = event['data'] | ||
85 | payload = json.loads(data) | ||
86 | except KeyError as err: | ||
87 | six.raise_from( | ||
88 | MalformedEventError('Missing field', err.args[0], event), | ||
89 | err | ||
90 | ) | ||
91 | except ValueError as err: | ||
92 | # py2: plain ValueError | ||
93 | # py3: json.JSONDecodeError, a subclass of ValueError | ||
94 | six.raise_from( | ||
95 | MalformedEventError('Bad JSON', data), | ||
96 | err | ||
97 | ) | ||
98 | |||
99 | handler_name = 'on_' + name | ||
100 | try: | ||
101 | handler = getattr(self, handler_name) | ||
102 | except AttributeError: | ||
103 | log.warn("Unhandled event '%s'", name) | ||
104 | else: | ||
105 | # TODO: allow handlers to return/raise to stop streaming cleanly | ||
106 | handler(payload) | ||
107 | |||
@@ -6,7 +6,7 @@ setup(name='Mastodon.py', | |||
6 | packages=['mastodon'], | 6 | packages=['mastodon'], |
7 | setup_requires=['pytest-runner'], | 7 | setup_requires=['pytest-runner'], |
8 | tests_require=['pytest'], | 8 | tests_require=['pytest'], |
9 | install_requires=['requests', 'dateutils'], | 9 | install_requires=['requests', 'dateutils', 'six'], |
10 | url='https://github.com/halcy/Mastodon.py', | 10 | url='https://github.com/halcy/Mastodon.py', |
11 | author='Lorenz Diener', | 11 | author='Lorenz Diener', |
12 | author_email='[email protected]', | 12 | author_email='[email protected]', |
diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..c79a8e9 --- /dev/null +++ b/tests/test_streaming.py | |||
@@ -0,0 +1,195 @@ | |||
1 | import six | ||
2 | import pytest | ||
3 | import itertools | ||
4 | from mastodon.streaming import StreamListener, MalformedEventError | ||
5 | |||
6 | |||
7 | class Listener(StreamListener): | ||
8 | def __init__(self): | ||
9 | self.updates = [] | ||
10 | self.notifications = [] | ||
11 | self.deletes = [] | ||
12 | self.heartbeats = 0 | ||
13 | |||
14 | def on_update(self, status): | ||
15 | self.updates.append(status) | ||
16 | |||
17 | def on_notification(self, notification): | ||
18 | self.notifications.append(notification) | ||
19 | |||
20 | def on_delete(self, status_id): | ||
21 | self.deletes.append(status_id) | ||
22 | |||
23 | def handle_heartbeat(self): | ||
24 | self.heartbeats += 1 | ||
25 | |||
26 | def handle_stream_(self, lines): | ||
27 | '''Test helper to avoid littering all tests with six.b().''' | ||
28 | return self.handle_stream(map(six.b, lines)) | ||
29 | |||
30 | def test_heartbeat(): | ||
31 | listener = Listener() | ||
32 | listener.handle_stream_([':one', ':two']) | ||
33 | assert listener.heartbeats == 2 | ||
34 | |||
35 | |||
36 | def test_status(): | ||
37 | listener = Listener() | ||
38 | listener.handle_stream_([ | ||
39 | 'event: update', | ||
40 | 'data: {"foo": "bar"}', | ||
41 | '', | ||
42 | ]) | ||
43 | assert listener.updates == [{"foo": "bar"}] | ||
44 | |||
45 | |||
46 | def test_notification(): | ||
47 | listener = Listener() | ||
48 | listener.handle_stream_([ | ||
49 | 'event: notification', | ||
50 | 'data: {"foo": "bar"}', | ||
51 | '', | ||
52 | ]) | ||
53 | assert listener.notifications == [{"foo": "bar"}] | ||
54 | |||
55 | |||
56 | def test_delete(): | ||
57 | listener = Listener() | ||
58 | listener.handle_stream_([ | ||
59 | 'event: delete', | ||
60 | 'data: 123', | ||
61 | '', | ||
62 | ]) | ||
63 | assert listener.deletes == [123] | ||
64 | |||
65 | |||
66 | @pytest.mark.parametrize('events', itertools.permutations([ | ||
67 | ['event: update', 'data: {"foo": "bar"}', ''], | ||
68 | ['event: notification', 'data: {"foo": "bar"}', ''], | ||
69 | ['event: delete', 'data: 123', ''], | ||
70 | [':toot toot'], | ||
71 | [':beep beep'], | ||
72 | ])) | ||
73 | def test_many(events): | ||
74 | listener = Listener() | ||
75 | stream = [ | ||
76 | line | ||
77 | for event in events | ||
78 | for line in event | ||
79 | ] | ||
80 | listener.handle_stream_(stream) | ||
81 | assert listener.updates == [{"foo": "bar"}] | ||
82 | assert listener.notifications == [{"foo": "bar"}] | ||
83 | assert listener.deletes == [123] | ||
84 | assert listener.heartbeats == 2 | ||
85 | |||
86 | |||
87 | def test_unknown_event(): | ||
88 | '''Be tolerant of new event types''' | ||
89 | listener = Listener() | ||
90 | listener.handle_stream_([ | ||
91 | 'event: blahblah', | ||
92 | 'data: {}', | ||
93 | '', | ||
94 | ]) | ||
95 | assert listener.updates == [] | ||
96 | assert listener.notifications == [] | ||
97 | assert listener.deletes == [] | ||
98 | assert listener.heartbeats == 0 | ||
99 | |||
100 | |||
101 | def test_missing_event_name(): | ||
102 | listener = Listener() | ||
103 | with pytest.raises(MalformedEventError): | ||
104 | listener.handle_stream_([ | ||
105 | 'data: {}', | ||
106 | '', | ||
107 | ]) | ||
108 | |||
109 | assert listener.updates == [] | ||
110 | assert listener.notifications == [] | ||
111 | assert listener.deletes == [] | ||
112 | assert listener.heartbeats == 0 | ||
113 | |||
114 | |||
115 | def test_missing_data(): | ||
116 | listener = Listener() | ||
117 | with pytest.raises(MalformedEventError): | ||
118 | listener.handle_stream_([ | ||
119 | 'event: update', | ||
120 | '', | ||
121 | ]) | ||
122 | |||
123 | assert listener.updates == [] | ||
124 | assert listener.notifications == [] | ||
125 | assert listener.deletes == [] | ||
126 | assert listener.heartbeats == 0 | ||
127 | |||
128 | |||
129 | def test_sse_order_doesnt_matter(): | ||
130 | listener = Listener() | ||
131 | listener.handle_stream_([ | ||
132 | 'data: {"foo": "bar"}', | ||
133 | 'event: update', | ||
134 | '', | ||
135 | ]) | ||
136 | assert listener.updates == [{"foo": "bar"}] | ||
137 | |||
138 | |||
139 | def test_extra_keys_ignored(): | ||
140 | ''' | ||
141 | https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format | ||
142 | defines 'id' and 'retry' keys which the Mastodon streaming API doesn't use, | ||
143 | and alleges that "All other field names are ignored". | ||
144 | ''' | ||
145 | listener = Listener() | ||
146 | listener.handle_stream_([ | ||
147 | 'event: update', | ||
148 | 'data: {"foo": "bar"}', | ||
149 | 'id: 123', | ||
150 | 'retry: 456', | ||
151 | 'ignoreme: blah blah blah', | ||
152 | '', | ||
153 | ]) | ||
154 | assert listener.updates == [{"foo": "bar"}] | ||
155 | |||
156 | |||
157 | def test_valid_utf8(): | ||
158 | '''Snowman Cat Face With Tears Of Joy''' | ||
159 | listener = Listener() | ||
160 | listener.handle_stream_([ | ||
161 | 'event: update', | ||
162 | 'data: {"foo": "\xE2\x98\x83\xF0\x9F\x98\xB9"}', | ||
163 | '', | ||
164 | ]) | ||
165 | assert listener.updates == [{"foo": u"\u2603\U0001F639"}] | ||
166 | |||
167 | |||
168 | def test_invalid_utf8(): | ||
169 | '''Cat Face With Tears O''' | ||
170 | listener = Listener() | ||
171 | with pytest.raises(MalformedEventError): | ||
172 | listener.handle_stream_([ | ||
173 | 'event: update', | ||
174 | 'data: {"foo": "\xF0\x9F\x98"}', | ||
175 | '', | ||
176 | ]) | ||
177 | |||
178 | |||
179 | def test_multiline_payload(): | ||
180 | ''' | ||
181 | https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Data-only_messages | ||
182 | says that newlines in the 'data' field can be encoded by sending the field | ||
183 | twice! This would be really pathological for Mastodon because the payload | ||
184 | is JSON, but technically literal newlines are permissible (outside strings) | ||
185 | so let's handle this case. | ||
186 | ''' | ||
187 | listener = Listener() | ||
188 | listener.handle_stream_([ | ||
189 | 'event: update', | ||
190 | 'data: {"foo":', | ||
191 | 'data: "bar"', | ||
192 | 'data: }', | ||
193 | '', | ||
194 | ]) | ||
195 | assert listener.updates == [{"foo": "bar"}] | ||