aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/index.rst47
-rw-r--r--mastodon/Mastodon.py59
-rw-r--r--mastodon/__init__.py4
-rw-r--r--mastodon/streaming.py91
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
41network node. It has an API that allows you to interact with its 41network node. It has an API that allows you to interact with its
42every aspect. This is a simple python wrapper for that api, provided 42every aspect. This is a simple python wrapper for that api, provided
43as a single python module. By default, it talks to the 43as a single python module. By default, it talks to the
@@ -112,23 +112,23 @@ Error handling
112When Mastodon.py encounters an error, it will raise an exception, generally with 112When Mastodon.py encounters an error, it will raise an exception, generally with
113some text included to tell you what went wrong. 113some text included to tell you what went wrong.
114 114
115The base class that all mastodon exceptions inherit from is the MastodonError 115The base class that all mastodon exceptions inherit from is `MastodonError`.
116class. If you are only interested in the fact an error was raised somewhere in 116If you are only interested in the fact an error was raised somewhere in
117Mastodon.py, and not the details, this is the exception you can catch. 117Mastodon.py, and not the details, this is the exception you can catch.
118 118
119MastodonIllegalArgumentError is generally a programming problem - you asked the 119`MastodonIllegalArgumentError` is generally a programming problem - you asked the
120API to do something obviously invalid (i.e. specify a privacy scope that does 120API to do something obviously invalid (i.e. specify a privacy scope that does
121not exist). 121not exist).
122 122
123MastodonFileNotFoundError and MastodonNetworkError are IO errors - could be you 123`MastodonFileNotFoundError` and `MastodonNetworkError` are IO errors - could be you
124specified a wrong URL, could be the internet is down or your hard drive is 124specified a wrong URL, could be the internet is down or your hard drive is
125dying. They inherit from MastodonIOError, for easy catching. 125dying. They inherit from MastodonIOError, for easy catching.
126 126
127MastodonAPIError is an error returned from the Mastodon instance - the server 127`MastodonAPIError` is an error returned from the Mastodon instance - the server
128has decided it can't fullfill your request (i.e. you requested info on a user that 128has decided it can't fullfill your request (i.e. you requested info on a user that
129does not exist). 129does not exist).
130 130
131MastodonRatelimitError 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
132again after a while (see the rate limiting section above). 132again after a while (see the rate limiting section above).
133 133
134Return values 134Return 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---------
532These functions allow access to the streaming API. 532These functions allow access to the streaming API.
533 533
534.. automethod:: Mastodon.user_stream 534If async is False, these methods block forever (or until an
535.. automethod:: Mastodon.public_stream 535exception is raised).
536.. automethod:: Mastodon.hashtag_stream
537 536
537If async is True, the listener will listen on another thread and these methods
538will return a handle corresponding to the open connection. The
539connection may be closed at any time by calling its close() method.
540
541The streaming functions take instances of `StreamListener` as a parameter.
542A `CallbackStreamListener` class that allows you to specify function callbacks
543directly 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
550StreamListener
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
559CallbackStreamListener
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
17import copy 17import copy
18import threading 18import threading
19import sys 19import sys
20
20try: 21try:
21 from urllib.parse import urlparse 22 from urllib.parse import urlparse
22except ImportError: 23except ImportError:
23 from urlparse import urlparse 24 from urlparse import urlparse
24 25
25 26
26class Mastodon: 27class 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
1426class MastodonIllegalArgumentError(ValueError, MastodonError): 1406class 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
1434class MastodonFileNotFoundError(MastodonIOError): 1415class MastodonFileNotFoundError(MastodonIOError):
1416 """Raised when a file requested to be loaded can not be opened"""
1435 pass 1417 pass
1436 1418
1437 1419
1438class MastodonNetworkError(MastodonIOError): 1420class MastodonNetworkError(MastodonIOError):
1421 """Raised when network communication with the server fails"""
1439 pass 1422 pass
1440 1423
1441 1424
1442class MastodonAPIError(MastodonError): 1425class MastodonAPIError(MastodonError):
1426 """Raised when the mastodon API generates a response that cannot be handled"""
1443 pass 1427 pass
1444 1428
1445 1429
1446class MastodonRatelimitError(MastodonError): 1430class MastodonRatelimitError(MastodonError):
1431 """Raised when rate limiting is set to manual mode and the rate limit is exceeded"""
1432 pass
1433
1434class 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 @@
1from mastodon.Mastodon import Mastodon 1from mastodon.Mastodon import Mastodon
2from mastodon.streaming import StreamListener, MalformedEventError 2from 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
6import json 6import json
7import logging
8import six 7import six
9 8from mastodon import Mastodon
10 9from mastodon.Mastodon import MastodonMalformedEventError
11log = logging.getLogger(__name__)
12
13
14class MalformedEventError(Exception):
15 """Raised when the server-sent event stream is malformed."""
16 pass
17
18 10
19class StreamListener(object): 11class 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
27describing 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
100class 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
Powered by cgit v1.2.3 (git 2.41.0)