aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorWill Thompson <[email protected]>2017-04-09 10:21:56 +0100
committerWill Thompson <[email protected]>2017-04-10 08:18:08 +0100
commitab60931620066f6704be3010903f779b3cb9c71a (patch)
tree86211f5f6d5ca7ffb495d48ce98c6d71c30e100b
parent280c60120beb13d00c807c418c765b93da248b19 (diff)
downloadmastodon.py-ab60931620066f6704be3010903f779b3cb9c71a.tar.gz
Initial implementation of streaming API
This is missing any error handling and rate-limiting around the stream itself, but once the stream is established, the full range of events are supported. Fixes issue #14.
-rw-r--r--mastodon/Mastodon.py48
-rw-r--r--mastodon/__init__.py4
-rw-r--r--mastodon/streaming.py107
-rw-r--r--setup.py2
-rw-r--r--tests/test_streaming.py195
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
12import dateutil 12import dateutil
13import dateutil.parser 13import dateutil.parser
14 14
15from contextlib import closing
16
17
15class Mastodon: 18class 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 @@
1from mastodon.Mastodon import Mastodon 1from mastodon.Mastodon import Mastodon
2__all__ = ['Mastodon'] 2from 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'''
2Handlers for the Streaming API:
3https://github.com/tootsuite/mastodon/blob/master/docs/Using-the-API/Streaming-API.md
4'''
5
6import json
7import logging
8import six
9
10
11log = logging.getLogger(__name__)
12
13
14class MalformedEventError(Exception):
15 '''Raised when the server-sent event stream is malformed.'''
16 pass
17
18
19class 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
diff --git a/setup.py b/setup.py
index 59d684b..7f1401b 100644
--- a/setup.py
+++ b/setup.py
@@ -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 @@
1import six
2import pytest
3import itertools
4from mastodon.streaming import StreamListener, MalformedEventError
5
6
7class 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
30def test_heartbeat():
31 listener = Listener()
32 listener.handle_stream_([':one', ':two'])
33 assert listener.heartbeats == 2
34
35
36def 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
46def 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
56def 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]))
73def 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
87def 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
101def 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
115def 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
129def 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
139def 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
157def 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
168def 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
179def 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"}]
Powered by cgit v1.2.3 (git 2.41.0)