diff options
Diffstat (limited to 'mastodon')
-rw-r--r-- | mastodon/Mastodon.py | 50 | ||||
-rw-r--r-- | mastodon/__init__.py | 4 | ||||
-rw-r--r-- | mastodon/streaming.py | 107 |
3 files changed, 157 insertions, 4 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 | |||
9 | import random | 9 | import random |
10 | import string | 10 | import string |
11 | import datetime | 11 | import datetime |
12 | import dateutil.parser | ||
13 | |||
14 | import pytz | ||
15 | import dateutil | 12 | import dateutil |
13 | import dateutil.parser | ||
14 | from contextlib import closing | ||
16 | import requests | 15 | import requests |
17 | 16 | ||
18 | class Mastodon: | 17 | class 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 @@ | |||
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 | |||