aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--mastodon/Mastodon.py50
-rw-r--r--mastodon/__init__.py4
-rw-r--r--mastodon/streaming.py107
-rw-r--r--setup.cfg3
-rw-r--r--setup.py4
-rw-r--r--tests/test_streaming.py195
6 files changed, 358 insertions, 5 deletions
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py
index f3861c2..ee11496 100644
--- a/mastodon/Mastodon.py
+++ b/mastodon/Mastodon.py
@@ -9,10 +9,9 @@ import time
9import random 9import random
10import string 10import string
11import datetime 11import datetime
12import dateutil.parser
13
14import pytz
15import dateutil 12import dateutil
13import dateutil.parser
14from contextlib import closing
16import requests 15import requests
17 16
18class Mastodon: 17class Mastodon:
@@ -658,6 +657,37 @@ class Mastodon:
658 media_file_description = (file_name, media_file, mime_type) 657 media_file_description = (file_name, media_file, mime_type)
659 return self.__api_request('POST', '/api/v1/media', files = {'file': media_file_description}) 658 return self.__api_request('POST', '/api/v1/media', files = {'file': media_file_description})
660 659
660 def user_stream(self, listener):
661 """
662 Streams events that are relevant to the authorized user, i.e. home
663 timeline and notifications. 'listener' should be a subclass of
664 StreamListener.
665
666 This method blocks forever, calling callbacks on 'listener' for
667 incoming events.
668 """
669 return self.__stream('/api/v1/streaming/user', listener)
670
671 def public_stream(self, listener):
672 """
673 Streams public events. 'listener' should be a subclass of
674 StreamListener.
675
676 This method blocks forever, calling callbacks on 'listener' for
677 incoming events.
678 """
679 return self.__stream('/api/v1/streaming/public', listener)
680
681 def hashtag_stream(self, tag, listener):
682 """
683 Returns all public statuses for the hashtag 'tag'. 'listener' should be
684 a subclass of StreamListener.
685
686 This method blocks forever, calling callbacks on 'listener' for
687 incoming events.
688 """
689 return self.__stream('/api/v1/streaming/hashtag', listener, params={'tag': tag})
690
661 ### 691 ###
662 # Internal helpers, dragons probably 692 # Internal helpers, dragons probably
663 ### 693 ###
@@ -790,6 +820,20 @@ class Mastodon:
790 820
791 return response 821 return response
792 822
823 def __stream(self, endpoint, listener, params = {}):
824 """
825 Internal streaming API helper.
826 """
827
828 headers = {}
829 if self.access_token != None:
830 headers = {'Authorization': 'Bearer ' + self.access_token}
831
832 url = self.api_base_url + endpoint
833 with closing(requests.get(url, headers = headers, data = params, stream = True)) as r:
834 listener.handle_stream(r.iter_lines())
835
836
793 def __generate_params(self, params, exclude = []): 837 def __generate_params(self, params, exclude = []):
794 """ 838 """
795 Internal named-parameters-to-dict helper. 839 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.cfg b/setup.cfg
index 14c6824..459ffba 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -5,3 +5,6 @@ universal=1
5source-dir = docs 5source-dir = docs
6build-dir = docs/build 6build-dir = docs/build
7all_files = 1 7all_files = 1
8
9[aliases]
10test=pytest
diff --git a/setup.py b/setup.py
index a9e2bfc..7f1401b 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,9 @@ setup(name='Mastodon.py',
4 version='1.0.6', 4 version='1.0.6',
5 description='Python wrapper for the Mastodon API', 5 description='Python wrapper for the Mastodon API',
6 packages=['mastodon'], 6 packages=['mastodon'],
7 install_requires=['requests', 'dateutils'], 7 setup_requires=['pytest-runner'],
8 tests_require=['pytest'],
9 install_requires=['requests', 'dateutils', 'six'],
8 url='https://github.com/halcy/Mastodon.py', 10 url='https://github.com/halcy/Mastodon.py',
9 author='Lorenz Diener', 11 author='Lorenz Diener',
10 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)