diff options
-rw-r--r-- | docs/conf.py | 4 | ||||
-rw-r--r-- | mastodon/Mastodon.py | 374 | ||||
-rw-r--r-- | mastodon/streaming.py | 29 | ||||
-rw-r--r-- | setup.py | 7 | ||||
-rw-r--r-- | tests/__init__.py | 0 | ||||
-rw-r--r-- | tests/test_streaming.py | 17 |
6 files changed, 247 insertions, 184 deletions
diff --git a/docs/conf.py b/docs/conf.py index 9c4a292..4c5352a 100644 --- a/docs/conf.py +++ b/docs/conf.py | |||
@@ -30,7 +30,7 @@ import os | |||
30 | import sys | 30 | import sys |
31 | sys.path.insert(0, os.path.abspath('../')) | 31 | sys.path.insert(0, os.path.abspath('../')) |
32 | autodoc_member_order = 'by_source' | 32 | autodoc_member_order = 'by_source' |
33 | #print(sys.path) | 33 | # print(sys.path) |
34 | 34 | ||
35 | # Add any Sphinx extension module names here, as strings. They can be | 35 | # Add any Sphinx extension module names here, as strings. They can be |
36 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom | 36 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom |
@@ -154,7 +154,7 @@ todo_include_todos = False | |||
154 | # html_logo = None | 154 | # html_logo = None |
155 | 155 | ||
156 | # The name of an image file (relative to this directory) to use as a favicon of | 156 | # The name of an image file (relative to this directory) to use as a favicon of |
157 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 | 157 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 |
158 | # pixels large. | 158 | # pixels large. |
159 | # | 159 | # |
160 | # html_favicon = None | 160 | # html_favicon = None |
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index 620b303..a74f59a 100644 --- a/mastodon/Mastodon.py +++ b/mastodon/Mastodon.py | |||
@@ -6,7 +6,6 @@ import mimetypes | |||
6 | import time | 6 | import time |
7 | import random | 7 | import random |
8 | import string | 8 | import string |
9 | import pytz | ||
10 | import datetime | 9 | import datetime |
11 | from contextlib import closing | 10 | from contextlib import closing |
12 | import pytz | 11 | import pytz |
@@ -18,6 +17,7 @@ import re | |||
18 | import copy | 17 | import copy |
19 | import threading | 18 | import threading |
20 | 19 | ||
20 | |||
21 | class Mastodon: | 21 | class Mastodon: |
22 | """ | 22 | """ |
23 | Super basic but thorough and easy to use Mastodon | 23 | Super basic but thorough and easy to use Mastodon |
@@ -29,12 +29,12 @@ class Mastodon: | |||
29 | __DEFAULT_BASE_URL = 'https://mastodon.social' | 29 | __DEFAULT_BASE_URL = 'https://mastodon.social' |
30 | __DEFAULT_TIMEOUT = 300 | 30 | __DEFAULT_TIMEOUT = 300 |
31 | 31 | ||
32 | |||
33 | ### | 32 | ### |
34 | # Registering apps | 33 | # Registering apps |
35 | ### | 34 | ### |
36 | @staticmethod | 35 | @staticmethod |
37 | def create_app(client_name, scopes = ['read', 'write', 'follow'], redirect_uris = None, website = None, to_file = None, api_base_url = __DEFAULT_BASE_URL, request_timeout = __DEFAULT_TIMEOUT): | 36 | def create_app(client_name, scopes=['read', 'write', 'follow'], redirect_uris=None, website=None, to_file=None, |
37 | api_base_url=__DEFAULT_BASE_URL, request_timeout=__DEFAULT_TIMEOUT): | ||
38 | """ | 38 | """ |
39 | Create a new app with given client_name and scopes (read, write, follow) | 39 | Create a new app with given client_name and scopes (read, write, follow) |
40 | 40 | ||
@@ -48,7 +48,7 @@ class Mastodon: | |||
48 | Returns client_id and client_secret. | 48 | Returns client_id and client_secret. |
49 | """ | 49 | """ |
50 | api_base_url = Mastodon.__protocolize(api_base_url) | 50 | api_base_url = Mastodon.__protocolize(api_base_url) |
51 | 51 | ||
52 | request_data = { | 52 | request_data = { |
53 | 'client_name': client_name, | 53 | 'client_name': client_name, |
54 | 'scopes': " ".join(scopes) | 54 | 'scopes': " ".join(scopes) |
@@ -56,18 +56,18 @@ class Mastodon: | |||
56 | 56 | ||
57 | try: | 57 | try: |
58 | if redirect_uris is not None: | 58 | if redirect_uris is not None: |
59 | request_data['redirect_uris'] = redirect_uris; | 59 | request_data['redirect_uris'] = redirect_uris |
60 | else: | 60 | else: |
61 | request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'; | 61 | request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob' |
62 | if website is not None: | 62 | if website is not None: |
63 | request_data['website'] = website | 63 | request_data['website'] = website |
64 | 64 | ||
65 | response = requests.post(api_base_url + '/api/v1/apps', data = request_data, timeout = request_timeout) | 65 | response = requests.post(api_base_url + '/api/v1/apps', data=request_data, timeout=request_timeout) |
66 | response = response.json() | 66 | response = response.json() |
67 | except Exception as e: | 67 | except Exception as e: |
68 | raise MastodonNetworkError("Could not complete request: %s" % e) | 68 | raise MastodonNetworkError("Could not complete request: %s" % e) |
69 | 69 | ||
70 | if to_file != None: | 70 | if to_file is not None: |
71 | with open(to_file, 'w') as secret_file: | 71 | with open(to_file, 'w') as secret_file: |
72 | secret_file.write(response['client_id'] + '\n') | 72 | secret_file.write(response['client_id'] + '\n') |
73 | secret_file.write(response['client_secret'] + '\n') | 73 | secret_file.write(response['client_secret'] + '\n') |
@@ -77,7 +77,10 @@ class Mastodon: | |||
77 | ### | 77 | ### |
78 | # Authentication, including constructor | 78 | # Authentication, including constructor |
79 | ### | 79 | ### |
80 | def __init__(self, client_id, client_secret = None, access_token = None, api_base_url = __DEFAULT_BASE_URL, debug_requests = False, ratelimit_method = "wait", ratelimit_pacefactor = 1.1, request_timeout = __DEFAULT_TIMEOUT): | 80 | def __init__(self, client_id, client_secret=None, access_token=None, |
81 | api_base_url=__DEFAULT_BASE_URL, debug_requests=False, | ||
82 | ratelimit_method="wait", ratelimit_pacefactor=1.1, | ||
83 | request_timeout=__DEFAULT_TIMEOUT): | ||
81 | """ | 84 | """ |
82 | Create a new API wrapper instance based on the given client_secret and client_id. If you | 85 | Create a new API wrapper instance based on the given client_secret and client_id. If you |
83 | give a client_id and it is not a file, you must also give a secret. | 86 | give a client_id and it is not a file, you must also give a secret. |
@@ -116,7 +119,7 @@ class Mastodon: | |||
116 | 119 | ||
117 | self.request_timeout = request_timeout | 120 | self.request_timeout = request_timeout |
118 | 121 | ||
119 | if not ratelimit_method in ["throw", "wait", "pace"]: | 122 | if ratelimit_method not in ["throw", "wait", "pace"]: |
120 | raise MastodonIllegalArgumentError("Invalid ratelimit method.") | 123 | raise MastodonIllegalArgumentError("Invalid ratelimit method.") |
121 | 124 | ||
122 | if os.path.isfile(self.client_id): | 125 | if os.path.isfile(self.client_id): |
@@ -124,15 +127,15 @@ class Mastodon: | |||
124 | self.client_id = secret_file.readline().rstrip() | 127 | self.client_id = secret_file.readline().rstrip() |
125 | self.client_secret = secret_file.readline().rstrip() | 128 | self.client_secret = secret_file.readline().rstrip() |
126 | else: | 129 | else: |
127 | if self.client_secret == None: | 130 | if self.client_secret is None: |
128 | raise MastodonIllegalArgumentError('Specified client id directly, but did not supply secret') | 131 | raise MastodonIllegalArgumentError('Specified client id directly, but did not supply secret') |
129 | 132 | ||
130 | if self.access_token != None and os.path.isfile(self.access_token): | 133 | if self.access_token is not None and os.path.isfile(self.access_token): |
131 | with open(self.access_token, 'r') as token_file: | 134 | with open(self.access_token, 'r') as token_file: |
132 | self.access_token = token_file.readline().rstrip() | 135 | self.access_token = token_file.readline().rstrip() |
133 | |||
134 | 136 | ||
135 | def auth_request_url(self, client_id = None, redirect_uris = "urn:ietf:wg:oauth:2.0:oob", scopes = ['read', 'write', 'follow']): | 137 | def auth_request_url(self, client_id=None, redirect_uris="urn:ietf:wg:oauth:2.0:oob", |
138 | scopes=['read', 'write', 'follow']): | ||
136 | """Returns the url that a client needs to request the grant from the server. | 139 | """Returns the url that a client needs to request the grant from the server. |
137 | """ | 140 | """ |
138 | if client_id is None: | 141 | if client_id is None: |
@@ -141,8 +144,8 @@ class Mastodon: | |||
141 | if os.path.isfile(client_id): | 144 | if os.path.isfile(client_id): |
142 | with open(client_id, 'r') as secret_file: | 145 | with open(client_id, 'r') as secret_file: |
143 | client_id = secret_file.readline().rstrip() | 146 | client_id = secret_file.readline().rstrip() |
144 | 147 | ||
145 | params = {} | 148 | params = dict() |
146 | params['client_id'] = client_id | 149 | params['client_id'] = client_id |
147 | params['response_type'] = "code" | 150 | params['response_type'] = "code" |
148 | params['redirect_uri'] = redirect_uris | 151 | params['redirect_uri'] = redirect_uris |
@@ -150,22 +153,23 @@ class Mastodon: | |||
150 | formatted_params = urlencode(params) | 153 | formatted_params = urlencode(params) |
151 | return "".join([self.api_base_url, "/oauth/authorize?", formatted_params]) | 154 | return "".join([self.api_base_url, "/oauth/authorize?", formatted_params]) |
152 | 155 | ||
153 | def log_in(self, username = None, password = None,\ | 156 | def log_in(self, username=None, password=None, |
154 | code = None, redirect_uri = "urn:ietf:wg:oauth:2.0:oob", refresh_token = None,\ | 157 | code=None, redirect_uri="urn:ietf:wg:oauth:2.0:oob", refresh_token=None, |
155 | scopes = ['read', 'write', 'follow'], to_file = None): | 158 | scopes=['read', 'write', 'follow'], to_file=None): |
156 | """ | 159 | """ |
157 | Your username is the e-mail you use to log in into mastodon. | 160 | Your username is the e-mail you use to log in into mastodon. |
158 | 161 | ||
159 | Can persist access token to file, to be used in the constructor. | 162 | Can persist access token to file, to be used in the constructor. |
160 | 163 | ||
161 | Supports refresh_token but Mastodon.social doesn't implement it at the moment. | 164 | Supports refresh_token but Mastodon.social doesn't implement it at the moment. |
162 | 165 | ||
163 | Handles password, authorization_code, and refresh_token authentication. | 166 | Handles password, authorization_code, and refresh_token authentication. |
164 | 167 | ||
165 | Will throw a MastodonIllegalArgumentError if username / password | 168 | Will throw a MastodonIllegalArgumentError if username / password |
166 | are wrong, scopes are not valid or granted scopes differ from requested. | 169 | are wrong, scopes are not valid or granted scopes differ from requested. |
167 | 170 | ||
168 | For OAuth2 documentation, compare https://github.com/doorkeeper-gem/doorkeeper/wiki/Interacting-as-an-OAuth-client-with-Doorkeeper | 171 | For OAuth2 documentation, compare |
172 | https://github.com/doorkeeper-gem/doorkeeper/wiki/Interacting-as-an-OAuth-client-with-Doorkeeper | ||
169 | 173 | ||
170 | Returns the access token. | 174 | Returns the access token. |
171 | """ | 175 | """ |
@@ -180,13 +184,13 @@ class Mastodon: | |||
180 | params['grant_type'] = 'refresh_token' | 184 | params['grant_type'] = 'refresh_token' |
181 | else: | 185 | else: |
182 | raise MastodonIllegalArgumentError('Invalid arguments given. username and password or code are required.') | 186 | raise MastodonIllegalArgumentError('Invalid arguments given. username and password or code are required.') |
183 | 187 | ||
184 | params['client_id'] = self.client_id | 188 | params['client_id'] = self.client_id |
185 | params['client_secret'] = self.client_secret | 189 | params['client_secret'] = self.client_secret |
186 | params['scope'] = " ".join(scopes) | 190 | params['scope'] = " ".join(scopes) |
187 | 191 | ||
188 | try: | 192 | try: |
189 | response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting = False) | 193 | response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting=False) |
190 | self.access_token = response['access_token'] | 194 | self.access_token = response['access_token'] |
191 | self.__set_refresh_token(response.get('refresh_token')) | 195 | self.__set_refresh_token(response.get('refresh_token')) |
192 | self.__set_token_expired(int(response.get('expires_in', 0))) | 196 | self.__set_token_expired(int(response.get('expires_in', 0))) |
@@ -202,9 +206,10 @@ class Mastodon: | |||
202 | received_scopes = " ".join(sorted(response["scope"].split(" "))) | 206 | received_scopes = " ".join(sorted(response["scope"].split(" "))) |
203 | 207 | ||
204 | if requested_scopes != received_scopes: | 208 | if requested_scopes != received_scopes: |
205 | raise MastodonAPIError('Granted scopes "' + received_scopes + '" differ from requested scopes "' + requested_scopes + '".') | 209 | raise MastodonAPIError( |
210 | 'Granted scopes "' + received_scopes + '" differ from requested scopes "' + requested_scopes + '".') | ||
206 | 211 | ||
207 | if to_file != None: | 212 | if to_file is not None: |
208 | with open(to_file, 'w') as token_file: | 213 | with open(to_file, 'w') as token_file: |
209 | token_file.write(response['access_token'] + '\n') | 214 | token_file.write(response['access_token'] + '\n') |
210 | 215 | ||
@@ -224,7 +229,7 @@ class Mastodon: | |||
224 | ### | 229 | ### |
225 | # Reading data: Timelines | 230 | # Reading data: Timelines |
226 | ## | 231 | ## |
227 | def timeline(self, timeline = "home", max_id = None, since_id = None, limit = None): | 232 | def timeline(self, timeline="home", max_id=None, since_id=None, limit=None): |
228 | """ | 233 | """ |
229 | Fetch statuses, most recent ones first. Timeline can be home, local, public, | 234 | Fetch statuses, most recent ones first. Timeline can be home, local, public, |
230 | or tag/hashtag. See the following functions documentation for what those do. | 235 | or tag/hashtag. See the following functions documentation for what those do. |
@@ -240,39 +245,44 @@ class Mastodon: | |||
240 | params_initial['local'] = True | 245 | params_initial['local'] = True |
241 | 246 | ||
242 | params = self.__generate_params(params_initial, ['timeline']) | 247 | params = self.__generate_params(params_initial, ['timeline']) |
243 | return self.__api_request('GET', '/api/v1/timelines/' + timeline, params) | 248 | url = '/api/v1/timelines/{0}'.format(timeline) |
249 | return self.__api_request('GET', url, params) | ||
244 | 250 | ||
245 | def timeline_home(self, max_id = None, since_id = None, limit = None): | 251 | def timeline_home(self, max_id=None, since_id=None, limit=None): |
246 | """ | 252 | """ |
247 | Fetch the authenticated users home timeline (i.e. followed users and self). | 253 | Fetch the authenticated users home timeline (i.e. followed users and self). |
248 | 254 | ||
249 | Returns a list of toot dicts. | 255 | Returns a list of toot dicts. |
250 | """ | 256 | """ |
251 | return self.timeline('home', max_id = max_id, since_id = since_id, limit = limit) | 257 | return self.timeline('home', max_id=max_id, since_id=since_id, |
258 | limit=limit) | ||
252 | 259 | ||
253 | def timeline_local(self, max_id = None, since_id = None, limit = None): | 260 | def timeline_local(self, max_id=None, since_id=None, limit=None): |
254 | """ | 261 | """ |
255 | Fetches the local / instance-wide timeline, not including replies. | 262 | Fetches the local / instance-wide timeline, not including replies. |
256 | 263 | ||
257 | Returns a list of toot dicts. | 264 | Returns a list of toot dicts. |
258 | """ | 265 | """ |
259 | return self.timeline('local', max_id = max_id, since_id = since_id, limit = limit) | 266 | return self.timeline('local', max_id=max_id, since_id=since_id, |
267 | limit=limit) | ||
260 | 268 | ||
261 | def timeline_public(self, max_id = None, since_id = None, limit = None): | 269 | def timeline_public(self, max_id=None, since_id=None, limit=None): |
262 | """ | 270 | """ |
263 | Fetches the public / visible-network timeline, not including replies. | 271 | Fetches the public / visible-network timeline, not including replies. |
264 | 272 | ||
265 | Returns a list of toot dicts. | 273 | Returns a list of toot dicts. |
266 | """ | 274 | """ |
267 | return self.timeline('public', max_id = max_id, since_id = since_id, limit = limit) | 275 | return self.timeline('public', max_id=max_id, since_id=since_id, |
276 | limit=limit) | ||
268 | 277 | ||
269 | def timeline_hashtag(self, hashtag, max_id = None, since_id = None, limit = None): | 278 | def timeline_hashtag(self, hashtag, max_id=None, since_id=None, limit=None): |
270 | """ | 279 | """ |
271 | Fetch a timeline of toots with a given hashtag. | 280 | Fetch a timeline of toots with a given hashtag. |
272 | 281 | ||
273 | Returns a list of toot dicts. | 282 | Returns a list of toot dicts. |
274 | """ | 283 | """ |
275 | return self.timeline('tag/' + str(hashtag), max_id = max_id, since_id = since_id, limit = limit) | 284 | url = 'tag/{0}'.format(str(hashtag)) |
285 | return self.timeline(url, max_id=max_id, since_id=since_id, limit=limit) | ||
276 | 286 | ||
277 | ### | 287 | ### |
278 | # Reading data: Statuses | 288 | # Reading data: Statuses |
@@ -283,7 +293,8 @@ class Mastodon: | |||
283 | 293 | ||
284 | Returns a toot dict. | 294 | Returns a toot dict. |
285 | """ | 295 | """ |
286 | return self.__api_request('GET', '/api/v1/statuses/' + str(id)) | 296 | url = '/api/v1/statuses/{0}'.format(str(id)) |
297 | return self.__api_request('GET', url) | ||
287 | 298 | ||
288 | def status_card(self, id): | 299 | def status_card(self, id): |
289 | """ | 300 | """ |
@@ -292,7 +303,8 @@ class Mastodon: | |||
292 | 303 | ||
293 | Returns a card dict. | 304 | Returns a card dict. |
294 | """ | 305 | """ |
295 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/card') | 306 | url = '/api/v1/statuses/{0}/card'.format(str(id)) |
307 | return self.__api_request('GET', url) | ||
296 | 308 | ||
297 | def status_context(self, id): | 309 | def status_context(self, id): |
298 | """ | 310 | """ |
@@ -300,7 +312,8 @@ class Mastodon: | |||
300 | 312 | ||
301 | Returns a context dict. | 313 | Returns a context dict. |
302 | """ | 314 | """ |
303 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/context') | 315 | url = '/api/v1/statuses/{0}/context'.format(str(id)) |
316 | return self.__api_request('GET', url) | ||
304 | 317 | ||
305 | def status_reblogged_by(self, id): | 318 | def status_reblogged_by(self, id): |
306 | """ | 319 | """ |
@@ -308,7 +321,8 @@ class Mastodon: | |||
308 | 321 | ||
309 | Returns a list of user dicts. | 322 | Returns a list of user dicts. |
310 | """ | 323 | """ |
311 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/reblogged_by') | 324 | url = '/api/v1/statuses/{0}/reblogged_by'.format(str(id)) |
325 | return self.__api_request('GET', url) | ||
312 | 326 | ||
313 | def status_favourited_by(self, id): | 327 | def status_favourited_by(self, id): |
314 | """ | 328 | """ |
@@ -316,12 +330,13 @@ class Mastodon: | |||
316 | 330 | ||
317 | Returns a list of user dicts. | 331 | Returns a list of user dicts. |
318 | """ | 332 | """ |
319 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/favourited_by') | 333 | url = '/api/v1/statuses/{0}/favourited_by'.format(str(id)) |
334 | return self.__api_request('GET', url) | ||
320 | 335 | ||
321 | ### | 336 | ### |
322 | # Reading data: Notifications | 337 | # Reading data: Notifications |
323 | ### | 338 | ### |
324 | def notifications(self, id = None, max_id = None, since_id = None, limit = None): | 339 | def notifications(self, id=None, max_id=None, since_id=None, limit=None): |
325 | """ | 340 | """ |
326 | Fetch notifications (mentions, favourites, reblogs, follows) for the authenticated | 341 | Fetch notifications (mentions, favourites, reblogs, follows) for the authenticated |
327 | user. | 342 | user. |
@@ -330,11 +345,12 @@ class Mastodon: | |||
330 | 345 | ||
331 | Returns a list of notification dicts. | 346 | Returns a list of notification dicts. |
332 | """ | 347 | """ |
333 | if id == None: | 348 | if id is None: |
334 | params = self.__generate_params(locals(), ['id']) | 349 | params = self.__generate_params(locals(), ['id']) |
335 | return self.__api_request('GET', '/api/v1/notifications', params) | 350 | return self.__api_request('GET', '/api/v1/notifications', params) |
336 | else: | 351 | else: |
337 | return self.__api_request('GET', '/api/v1/notifications/' + str(id)) | 352 | url = '/api/v1/notifications/{0}'.format(str(id)) |
353 | return self.__api_request('GET', url) | ||
338 | 354 | ||
339 | ### | 355 | ### |
340 | # Reading data: Accounts | 356 | # Reading data: Accounts |
@@ -345,7 +361,8 @@ class Mastodon: | |||
345 | 361 | ||
346 | Returns a user dict. | 362 | Returns a user dict. |
347 | """ | 363 | """ |
348 | return self.__api_request('GET', '/api/v1/accounts/' + str(id)) | 364 | url = '/api/v1/accounts/{0}'.format(str(id)) |
365 | return self.__api_request('GET', url) | ||
349 | 366 | ||
350 | def account_verify_credentials(self): | 367 | def account_verify_credentials(self): |
351 | """ | 368 | """ |
@@ -355,32 +372,35 @@ class Mastodon: | |||
355 | """ | 372 | """ |
356 | return self.__api_request('GET', '/api/v1/accounts/verify_credentials') | 373 | return self.__api_request('GET', '/api/v1/accounts/verify_credentials') |
357 | 374 | ||
358 | def account_statuses(self, id, max_id = None, since_id = None, limit = None): | 375 | def account_statuses(self, id, max_id=None, since_id=None, limit=None): |
359 | """ | 376 | """ |
360 | Fetch statuses by user id. Same options as timeline are permitted. | 377 | Fetch statuses by user id. Same options as timeline are permitted. |
361 | 378 | ||
362 | Returns a list of toot dicts. | 379 | Returns a list of toot dicts. |
363 | """ | 380 | """ |
364 | params = self.__generate_params(locals(), ['id']) | 381 | params = self.__generate_params(locals(), ['id']) |
365 | return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/statuses', params) | 382 | url = '/api/v1/accounts/{0}/statuses'.format(str(id)) |
383 | return self.__api_request('GET', url, params) | ||
366 | 384 | ||
367 | def account_following(self, id, max_id = None, since_id = None, limit = None): | 385 | def account_following(self, id, max_id=None, since_id=None, limit=None): |
368 | """ | 386 | """ |
369 | Fetch users the given user is following. | 387 | Fetch users the given user is following. |
370 | 388 | ||
371 | Returns a list of user dicts. | 389 | Returns a list of user dicts. |
372 | """ | 390 | """ |
373 | params = self.__generate_params(locals(), ['id']) | 391 | params = self.__generate_params(locals(), ['id']) |
374 | return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/following', params) | 392 | url = '/api/v1/accounts/{0}/following'.format(str(id)) |
393 | return self.__api_request('GET', url, params) | ||
375 | 394 | ||
376 | def account_followers(self, id, max_id = None, since_id = None, limit = None): | 395 | def account_followers(self, id, max_id=None, since_id=None, limit=None): |
377 | """ | 396 | """ |
378 | Fetch users the given user is followed by. | 397 | Fetch users the given user is followed by. |
379 | 398 | ||
380 | Returns a list of user dicts. | 399 | Returns a list of user dicts. |
381 | """ | 400 | """ |
382 | params = self.__generate_params(locals(), ['id']) | 401 | params = self.__generate_params(locals(), ['id']) |
383 | return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/followers', params) | 402 | url = '/api/v1/accounts/{0}/followers'.format(str(id)) |
403 | return self.__api_request('GET', url, params) | ||
384 | 404 | ||
385 | def account_relationships(self, id): | 405 | def account_relationships(self, id): |
386 | """ | 406 | """ |
@@ -390,9 +410,10 @@ class Mastodon: | |||
390 | Returns a list of relationship dicts. | 410 | Returns a list of relationship dicts. |
391 | """ | 411 | """ |
392 | params = self.__generate_params(locals()) | 412 | params = self.__generate_params(locals()) |
393 | return self.__api_request('GET', '/api/v1/accounts/relationships', params) | 413 | return self.__api_request('GET', '/api/v1/accounts/relationships', |
414 | params) | ||
394 | 415 | ||
395 | def account_search(self, q, limit = None): | 416 | def account_search(self, q, limit=None): |
396 | """ | 417 | """ |
397 | Fetch matching accounts. Will lookup an account remotely if the search term is | 418 | Fetch matching accounts. Will lookup an account remotely if the search term is |
398 | in the username@domain format and not yet in the database. | 419 | in the username@domain format and not yet in the database. |
@@ -405,7 +426,7 @@ class Mastodon: | |||
405 | ### | 426 | ### |
406 | # Reading data: Searching | 427 | # Reading data: Searching |
407 | ### | 428 | ### |
408 | def search(self, q, resolve = False): | 429 | def search(self, q, resolve=False): |
409 | """ | 430 | """ |
410 | Fetch matching hashtags, accounts and statuses. Will search federated | 431 | Fetch matching hashtags, accounts and statuses. Will search federated |
411 | instances if resolve is True. | 432 | instances if resolve is True. |
@@ -418,7 +439,7 @@ class Mastodon: | |||
418 | ### | 439 | ### |
419 | # Reading data: Mutes and Blocks | 440 | # Reading data: Mutes and Blocks |
420 | ### | 441 | ### |
421 | def mutes(self, max_id = None, since_id = None, limit = None): | 442 | def mutes(self, max_id=None, since_id=None, limit=None): |
422 | """ | 443 | """ |
423 | Fetch a list of users muted by the authenticated user. | 444 | Fetch a list of users muted by the authenticated user. |
424 | 445 | ||
@@ -427,7 +448,7 @@ class Mastodon: | |||
427 | params = self.__generate_params(locals()) | 448 | params = self.__generate_params(locals()) |
428 | return self.__api_request('GET', '/api/v1/mutes', params) | 449 | return self.__api_request('GET', '/api/v1/mutes', params) |
429 | 450 | ||
430 | def blocks(self, max_id = None, since_id = None, limit = None): | 451 | def blocks(self, max_id=None, since_id=None, limit=None): |
431 | """ | 452 | """ |
432 | Fetch a list of users blocked by the authenticated user. | 453 | Fetch a list of users blocked by the authenticated user. |
433 | 454 | ||
@@ -450,7 +471,7 @@ class Mastodon: | |||
450 | ### | 471 | ### |
451 | # Reading data: Favourites | 472 | # Reading data: Favourites |
452 | ### | 473 | ### |
453 | def favourites(self, max_id = None, since_id = None, limit = None): | 474 | def favourites(self, max_id=None, since_id=None, limit=None): |
454 | """ | 475 | """ |
455 | Fetch the authenticated user's favourited statuses. | 476 | Fetch the authenticated user's favourited statuses. |
456 | 477 | ||
@@ -462,7 +483,7 @@ class Mastodon: | |||
462 | ### | 483 | ### |
463 | # Reading data: Follow requests | 484 | # Reading data: Follow requests |
464 | ### | 485 | ### |
465 | def follow_requests(self, max_id = None, since_id = None, limit = None): | 486 | def follow_requests(self, max_id=None, since_id=None, limit=None): |
466 | """ | 487 | """ |
467 | Fetch the authenticated user's incoming follow requests. | 488 | Fetch the authenticated user's incoming follow requests. |
468 | 489 | ||
@@ -474,7 +495,7 @@ class Mastodon: | |||
474 | ### | 495 | ### |
475 | # Reading data: Domain blocks | 496 | # Reading data: Domain blocks |
476 | ### | 497 | ### |
477 | def domain_blocks(self, max_id = None, since_id = None, limit = None): | 498 | def domain_blocks(self, max_id=None, since_id=None, limit=None): |
478 | """ | 499 | """ |
479 | Fetch the authenticated user's blocked domains. | 500 | Fetch the authenticated user's blocked domains. |
480 | 501 | ||
@@ -482,11 +503,12 @@ class Mastodon: | |||
482 | """ | 503 | """ |
483 | params = self.__generate_params(locals()) | 504 | params = self.__generate_params(locals()) |
484 | return self.__api_request('GET', '/api/v1/domain_blocks', params) | 505 | return self.__api_request('GET', '/api/v1/domain_blocks', params) |
485 | 506 | ||
486 | ### | 507 | ### |
487 | # Writing data: Statuses | 508 | # Writing data: Statuses |
488 | ### | 509 | ### |
489 | def status_post(self, status, in_reply_to_id = None, media_ids = None, sensitive = False, visibility = '', spoiler_text = None): | 510 | def status_post(self, status, in_reply_to_id=None, media_ids=None, |
511 | sensitive=False, visibility='', spoiler_text=None): | ||
490 | """ | 512 | """ |
491 | Post a status. Can optionally be in reply to another status and contain | 513 | Post a status. Can optionally be in reply to another status and contain |
492 | up to four pieces of media (Uploaded via media_post()). media_ids can | 514 | up to four pieces of media (Uploaded via media_post()). media_ids can |
@@ -505,7 +527,9 @@ class Mastodon: | |||
505 | 'public' - post will be public | 527 | 'public' - post will be public |
506 | 528 | ||
507 | If not passed in, visibility defaults to match the current account's | 529 | If not passed in, visibility defaults to match the current account's |
508 | privacy setting (private if the account is locked, public otherwise). | 530 | locked setting (private if the account is locked, public otherwise). |
531 | Note that the "privacy" setting is not currently used in determining | ||
532 | visibility when not specified. | ||
509 | 533 | ||
510 | The spoiler_text parameter is a string to be shown as a warning before | 534 | The spoiler_text parameter is a string to be shown as a warning before |
511 | the text of the status. If no text is passed in, no warning will be | 535 | the text of the status. If no text is passed in, no warning will be |
@@ -518,12 +542,13 @@ class Mastodon: | |||
518 | # Validate visibility parameter | 542 | # Validate visibility parameter |
519 | valid_visibilities = ['private', 'public', 'unlisted', 'direct', ''] | 543 | valid_visibilities = ['private', 'public', 'unlisted', 'direct', ''] |
520 | if params_initial['visibility'].lower() not in valid_visibilities: | 544 | if params_initial['visibility'].lower() not in valid_visibilities: |
521 | raise ValueError('Invalid visibility value! Acceptable values are %s' % valid_visibilities) | 545 | raise ValueError('Invalid visibility value! Acceptable ' |
546 | 'values are %s' % valid_visibilities) | ||
522 | 547 | ||
523 | if params_initial['sensitive'] == False: | 548 | if params_initial['sensitive'] is False: |
524 | del[params_initial['sensitive']] | 549 | del [params_initial['sensitive']] |
525 | 550 | ||
526 | if media_ids != None: | 551 | if media_ids is not None: |
527 | try: | 552 | try: |
528 | media_ids_proper = [] | 553 | media_ids_proper = [] |
529 | for media_id in media_ids: | 554 | for media_id in media_ids: |
@@ -532,7 +557,8 @@ class Mastodon: | |||
532 | else: | 557 | else: |
533 | media_ids_proper.append(media_id) | 558 | media_ids_proper.append(media_id) |
534 | except Exception as e: | 559 | except Exception as e: |
535 | raise MastodonIllegalArgumentError("Invalid media dict: %s" % e) | 560 | raise MastodonIllegalArgumentError("Invalid media " |
561 | "dict: %s" % e) | ||
536 | 562 | ||
537 | params_initial["media_ids"] = media_ids_proper | 563 | params_initial["media_ids"] = media_ids_proper |
538 | 564 | ||
@@ -542,6 +568,8 @@ class Mastodon: | |||
542 | def toot(self, status): | 568 | def toot(self, status): |
543 | """ | 569 | """ |
544 | Synonym for status_post that only takes the status text as input. | 570 | Synonym for status_post that only takes the status text as input. |
571 | |||
572 | Usage in production code is not recommended. | ||
545 | 573 | ||
546 | Returns a toot dict with the new status. | 574 | Returns a toot dict with the new status. |
547 | """ | 575 | """ |
@@ -553,14 +581,16 @@ class Mastodon: | |||
553 | 581 | ||
554 | Returns an empty dict for good measure. | 582 | Returns an empty dict for good measure. |
555 | """ | 583 | """ |
556 | return self.__api_request('DELETE', '/api/v1/statuses/' + str(id)) | 584 | url = '/api/v1/statuses/{0}'.format(str(id)) |
585 | return self.__api_request('DELETE', url) | ||
557 | 586 | ||
558 | def status_reblog(self, id): | 587 | def status_reblog(self, id): |
559 | """Reblog a status. | 588 | """Reblog a status. |
560 | 589 | ||
561 | Returns a toot with with a new status that wraps around the reblogged one. | 590 | Returns a toot with with a new status that wraps around the reblogged one. |
562 | """ | 591 | """ |
563 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/reblog") | 592 | url = '/api/v1/statuses/{0}/reblog'.format(str(id)) |
593 | return self.__api_request('POST', url) | ||
564 | 594 | ||
565 | def status_unreblog(self, id): | 595 | def status_unreblog(self, id): |
566 | """ | 596 | """ |
@@ -568,7 +598,8 @@ class Mastodon: | |||
568 | 598 | ||
569 | Returns a toot dict with the status that used to be reblogged. | 599 | Returns a toot dict with the status that used to be reblogged. |
570 | """ | 600 | """ |
571 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unreblog") | 601 | url = '/api/v1/statuses/{0}/unreblog'.format(str(id)) |
602 | return self.__api_request('POST', url) | ||
572 | 603 | ||
573 | def status_favourite(self, id): | 604 | def status_favourite(self, id): |
574 | """ | 605 | """ |
@@ -576,7 +607,8 @@ class Mastodon: | |||
576 | 607 | ||
577 | Returns a toot dict with the favourited status. | 608 | Returns a toot dict with the favourited status. |
578 | """ | 609 | """ |
579 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/favourite") | 610 | url = '/api/v1/statuses/{0}/favourite'.format(str(id)) |
611 | return self.__api_request('POST', url) | ||
580 | 612 | ||
581 | def status_unfavourite(self, id): | 613 | def status_unfavourite(self, id): |
582 | """ | 614 | """ |
@@ -584,7 +616,8 @@ class Mastodon: | |||
584 | 616 | ||
585 | Returns a toot dict with the un-favourited status. | 617 | Returns a toot dict with the un-favourited status. |
586 | """ | 618 | """ |
587 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unfavourite") | 619 | url = '/api/v1/statuses/{0}/unfavourite'.format(str(id)) |
620 | return self.__api_request('POST', url) | ||
588 | 621 | ||
589 | ### | 622 | ### |
590 | # Writing data: Notifications | 623 | # Writing data: Notifications |
@@ -604,7 +637,8 @@ class Mastodon: | |||
604 | 637 | ||
605 | Returns a relationship dict containing the updated relationship to the user. | 638 | Returns a relationship dict containing the updated relationship to the user. |
606 | """ | 639 | """ |
607 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/follow") | 640 | url = '/api/v1/accounts/{0}/follow'.format(str(id)) |
641 | return self.__api_request('POST', url) | ||
608 | 642 | ||
609 | def follows(self, uri): | 643 | def follows(self, uri): |
610 | """ | 644 | """ |
@@ -621,7 +655,8 @@ class Mastodon: | |||
621 | 655 | ||
622 | Returns a relationship dict containing the updated relationship to the user. | 656 | Returns a relationship dict containing the updated relationship to the user. |
623 | """ | 657 | """ |
624 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unfollow") | 658 | url = '/api/v1/accounts/{0}/unfollow'.format(str(id)) |
659 | return self.__api_request('POST', url) | ||
625 | 660 | ||
626 | def account_block(self, id): | 661 | def account_block(self, id): |
627 | """ | 662 | """ |
@@ -629,7 +664,8 @@ class Mastodon: | |||
629 | 664 | ||
630 | Returns a relationship dict containing the updated relationship to the user. | 665 | Returns a relationship dict containing the updated relationship to the user. |
631 | """ | 666 | """ |
632 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/block") | 667 | url = '/api/v1/accounts/{0}/block'.format(str(id)) |
668 | return self.__api_request('POST', url) | ||
633 | 669 | ||
634 | def account_unblock(self, id): | 670 | def account_unblock(self, id): |
635 | """ | 671 | """ |
@@ -637,7 +673,8 @@ class Mastodon: | |||
637 | 673 | ||
638 | Returns a relationship dict containing the updated relationship to the user. | 674 | Returns a relationship dict containing the updated relationship to the user. |
639 | """ | 675 | """ |
640 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unblock") | 676 | url = '/api/v1/accounts/{0}/unblock'.format(str(id)) |
677 | return self.__api_request('POST', url) | ||
641 | 678 | ||
642 | def account_mute(self, id): | 679 | def account_mute(self, id): |
643 | """ | 680 | """ |
@@ -645,7 +682,8 @@ class Mastodon: | |||
645 | 682 | ||
646 | Returns a relationship dict containing the updated relationship to the user. | 683 | Returns a relationship dict containing the updated relationship to the user. |
647 | """ | 684 | """ |
648 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/mute") | 685 | url = '/api/v1/accounts/{0}/mute'.format(str(id)) |
686 | return self.__api_request('POST', url) | ||
649 | 687 | ||
650 | def account_unmute(self, id): | 688 | def account_unmute(self, id): |
651 | """ | 689 | """ |
@@ -653,9 +691,11 @@ class Mastodon: | |||
653 | 691 | ||
654 | Returns a relationship dict containing the updated relationship to the user. | 692 | Returns a relationship dict containing the updated relationship to the user. |
655 | """ | 693 | """ |
656 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unmute") | 694 | url = '/api/v1/accounts/{0}/unmute'.format(str(id)) |
695 | return self.__api_request('POST', url) | ||
657 | 696 | ||
658 | def account_update_credentials(self, display_name = None, note = None, avatar = None, header = None): | 697 | def account_update_credentials(self, display_name=None, note=None, |
698 | avatar=None, header=None): | ||
659 | """ | 699 | """ |
660 | Update the profile for the currently authenticated user. | 700 | Update the profile for the currently authenticated user. |
661 | 701 | ||
@@ -690,7 +730,8 @@ class Mastodon: | |||
690 | 730 | ||
691 | Returns an empty dict. | 731 | Returns an empty dict. |
692 | """ | 732 | """ |
693 | return self.__api_request('POST', '/api/v1/follow_requests/' + str(id) + "/authorize") | 733 | url = '/api/v1/follow_requests/{0}/authorize'.format(str(id)) |
734 | return self.__api_request('POST', url) | ||
694 | 735 | ||
695 | def follow_request_reject(self, id): | 736 | def follow_request_reject(self, id): |
696 | """ | 737 | """ |
@@ -698,12 +739,13 @@ class Mastodon: | |||
698 | 739 | ||
699 | Returns an empty dict. | 740 | Returns an empty dict. |
700 | """ | 741 | """ |
701 | return self.__api_request('POST', '/api/v1/follow_requests/' + str(id) + "/reject") | 742 | url = '/api/v1/follow_requests/{0}/reject'.format(str(id)) |
743 | return self.__api_request('POST', url) | ||
702 | 744 | ||
703 | ### | 745 | ### |
704 | # Writing data: Media | 746 | # Writing data: Media |
705 | ### | 747 | ### |
706 | def media_post(self, media_file, mime_type = None): | 748 | def media_post(self, media_file, mime_type=None): |
707 | """ | 749 | """ |
708 | Post an image. media_file can either be image data or | 750 | Post an image. media_file can either be image data or |
709 | a file name. If image data is passed directly, the mime | 751 | a file name. If image data is passed directly, the mime |
@@ -716,106 +758,110 @@ class Mastodon: | |||
716 | Returns a media dict. This contains the id that can be used in | 758 | Returns a media dict. This contains the id that can be used in |
717 | status_post to attach the media file to a toot. | 759 | status_post to attach the media file to a toot. |
718 | """ | 760 | """ |
719 | if mime_type == None and os.path.isfile(media_file): | 761 | if mime_type is None and os.path.isfile(media_file): |
720 | mime_type = mimetypes.guess_type(media_file)[0] | 762 | mime_type = mimetypes.guess_type(media_file)[0] |
721 | media_file = open(media_file, 'rb') | 763 | media_file = open(media_file, 'rb') |
722 | 764 | ||
723 | if mime_type == None: | 765 | if mime_type is None: |
724 | raise MastodonIllegalArgumentError('Could not determine mime type or data passed directly without mime type.') | 766 | raise MastodonIllegalArgumentError('Could not determine mime type' |
767 | ' or data passed directly ' | ||
768 | 'without mime type.') | ||
725 | 769 | ||
726 | random_suffix = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10)) | 770 | random_suffix = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10)) |
727 | file_name = "mastodonpyupload_" + str(time.time()) + "_" + str(random_suffix) + mimetypes.guess_extension(mime_type) | 771 | file_name = "mastodonpyupload_" + str(time.time()) + "_" + str(random_suffix) + mimetypes.guess_extension( |
772 | mime_type) | ||
728 | 773 | ||
729 | media_file_description = (file_name, media_file, mime_type) | 774 | media_file_description = (file_name, media_file, mime_type) |
730 | return self.__api_request('POST', '/api/v1/media', files = {'file': media_file_description}) | 775 | return self.__api_request('POST', '/api/v1/media', |
776 | files={'file': media_file_description}) | ||
731 | 777 | ||
732 | ### | 778 | ### |
733 | # Writing data: Domain blocks | 779 | # Writing data: Domain blocks |
734 | ### | 780 | ### |
735 | def domain_block(self, domain = None): | 781 | def domain_block(self, domain=None): |
736 | """ | 782 | """ |
737 | Add a block for all statuses originating from the specified domain for the logged-in user. | 783 | Add a block for all statuses originating from the specified domain for the logged-in user. |
738 | """ | 784 | """ |
739 | params = self.__generate_params(locals()) | 785 | params = self.__generate_params(locals()) |
740 | return self.__api_request('POST', '/api/v1/domain_blocks', params) | 786 | return self.__api_request('POST', '/api/v1/domain_blocks', params) |
741 | 787 | ||
742 | def domain_unblock(self, domain = None): | 788 | def domain_unblock(self, domain=None): |
743 | """ | 789 | """ |
744 | Remove a domain block for the logged-in user. | 790 | Remove a domain block for the logged-in user. |
745 | """ | 791 | """ |
746 | params = self.__generate_params(locals()) | 792 | params = self.__generate_params(locals()) |
747 | return self.__api_request('DELETE', '/api/v1/domain_blocks', params) | 793 | return self.__api_request('DELETE', '/api/v1/domain_blocks', params) |
748 | 794 | ||
749 | ### | 795 | ### |
750 | # Pagination | 796 | # Pagination |
751 | ### | 797 | ### |
752 | def fetch_next(self, previous_page): | 798 | def fetch_next(self, previous_page): |
753 | """ | 799 | """ |
754 | Fetches the next page of results of a paginated request. Pass in the | 800 | Fetches the next page of results of a paginated request. Pass in the |
755 | previous page in its entirety, or the pagination information dict | 801 | previous page in its entirety, or the pagination information dict |
756 | returned as a part of that pages last status ('_pagination_next'). | 802 | returned as a part of that pages last status ('_pagination_next'). |
757 | 803 | ||
758 | Returns the next page or None if no further data is available. | 804 | Returns the next page or None if no further data is available. |
759 | """ | 805 | """ |
760 | if isinstance(previous_page, list): | 806 | if isinstance(previous_page, list): |
761 | if '_pagination_next' in previous_page[-1]: | 807 | if '_pagination_next' in previous_page[-1]: |
762 | params = previous_page[-1]['_pagination_next'] | 808 | params = copy.deepcopy(previous_page[-1]['_pagination_next']) |
763 | else: | 809 | else: |
764 | return None | 810 | return None |
765 | else: | 811 | else: |
766 | params = previous_page | 812 | params = copy.deepcopy(previous_page) |
767 | 813 | ||
768 | method = params['_pagination_method'] | 814 | method = params['_pagination_method'] |
769 | del params['_pagination_method'] | 815 | del params['_pagination_method'] |
770 | 816 | ||
771 | endpoint = params['_pagination_endpoint'] | 817 | endpoint = params['_pagination_endpoint'] |
772 | del params['_pagination_endpoint'] | 818 | del params['_pagination_endpoint'] |
773 | 819 | ||
774 | return self.__api_request(method, endpoint, params) | 820 | return self.__api_request(method, endpoint, params) |
775 | 821 | ||
776 | def fetch_previous(self, next_page): | 822 | def fetch_previous(self, next_page): |
777 | """ | 823 | """ |
778 | Fetches the previous page of results of a paginated request. Pass in the | 824 | Fetches the previous page of results of a paginated request. Pass in the |
779 | previous page in its entirety, or the pagination information dict | 825 | previous page in its entirety, or the pagination information dict |
780 | returned as a part of that pages first status ('_pagination_prev'). | 826 | returned as a part of that pages first status ('_pagination_prev'). |
781 | 827 | ||
782 | Returns the previous page or None if no further data is available. | 828 | Returns the previous page or None if no further data is available. |
783 | """ | 829 | """ |
784 | if isinstance(next_page, list): | 830 | if isinstance(next_page, list): |
785 | if '_pagination_prev' in next_page[-1]: | 831 | if '_pagination_prev' in next_page[0]: |
786 | params = next_page[-1]['_pagination_prev'] | 832 | params = copy.deepcopy(next_page[0]['_pagination_prev']) |
787 | else: | 833 | else: |
788 | return None | 834 | return None |
789 | else: | 835 | else: |
790 | params = next_page | 836 | params = copy.deepcopy(next_page) |
791 | 837 | ||
792 | method = params['_pagination_method'] | 838 | method = params['_pagination_method'] |
793 | del params['_pagination_method'] | 839 | del params['_pagination_method'] |
794 | 840 | ||
795 | endpoint = params['_pagination_endpoint'] | 841 | endpoint = params['_pagination_endpoint'] |
796 | del params['_pagination_endpoint'] | 842 | del params['_pagination_endpoint'] |
797 | 843 | ||
798 | return self.__api_request(method, endpoint, params) | 844 | return self.__api_request(method, endpoint, params) |
799 | 845 | ||
800 | def fetch_remaining(self, first_page): | 846 | def fetch_remaining(self, first_page): |
801 | """ | 847 | """ |
802 | Fetches all the remaining pages of a paginated request starting from a | 848 | Fetches all the remaining pages of a paginated request starting from a |
803 | first page and returns the entire set of results (including the first page | 849 | first page and returns the entire set of results (including the first page |
804 | that was passed in) as a big list. | 850 | that was passed in) as a big list. |
805 | 851 | ||
806 | Be careful, as this might generate a lot of requests, depending on what you are | 852 | Be careful, as this might generate a lot of requests, depending on what you are |
807 | fetching, and might cause you to run into rate limits very quickly. | 853 | fetching, and might cause you to run into rate limits very quickly. |
808 | """ | 854 | """ |
809 | first_page = copy.deepcopy(first_page) | 855 | first_page = copy.deepcopy(first_page) |
810 | 856 | ||
811 | all_pages = [] | 857 | all_pages = [] |
812 | current_page = first_page | 858 | current_page = first_page |
813 | while current_page != None: | 859 | while current_page is not None and len(current_page) > 0: |
814 | all_pages.extend(current_page) | 860 | all_pages.extend(current_page) |
815 | current_page = self.fetch_next(current_page) | 861 | current_page = self.fetch_next(current_page) |
816 | 862 | ||
817 | return all_pages | 863 | return all_pages |
818 | 864 | ||
819 | ### | 865 | ### |
820 | # Streaming | 866 | # Streaming |
821 | ### | 867 | ### |
@@ -871,8 +917,7 @@ class Mastodon: | |||
871 | will return a handle corresponding to the open connection. The | 917 | will return a handle corresponding to the open connection. The |
872 | connection may be closed at any time by calling its close() method. | 918 | connection may be closed at any time by calling its close() method. |
873 | """ | 919 | """ |
874 | return self.__stream('/api/v1/streaming/hashtag', listener, params={'tag': tag}, async=async) | 920 | return self.__stream("/api/v1/streaming/hashtag?tag={}".format(tag), listener) |
875 | |||
876 | ### | 921 | ### |
877 | # Internal helpers, dragons probably | 922 | # Internal helpers, dragons probably |
878 | ### | 923 | ### |
@@ -884,22 +929,22 @@ class Mastodon: | |||
884 | Assumes UTC if timezone is not given. | 929 | Assumes UTC if timezone is not given. |
885 | """ | 930 | """ |
886 | date_time_utc = None | 931 | date_time_utc = None |
887 | if date_time.tzinfo == None: | 932 | if date_time.tzinfo is None: |
888 | date_time_utc = date_time.replace(tzinfo = pytz.utc) | 933 | date_time_utc = date_time.replace(tzinfo=pytz.utc) |
889 | else: | 934 | else: |
890 | date_time_utc = date_time.astimezone(pytz.utc) | 935 | date_time_utc = date_time.astimezone(pytz.utc) |
891 | 936 | ||
892 | epoch_utc = datetime.datetime.utcfromtimestamp(0).replace(tzinfo = pytz.utc) | 937 | epoch_utc = datetime.datetime.utcfromtimestamp(0).replace(tzinfo=pytz.utc) |
893 | 938 | ||
894 | return (date_time_utc - epoch_utc).total_seconds() | 939 | return (date_time_utc - epoch_utc).total_seconds() |
895 | 940 | ||
896 | def __api_request(self, method, endpoint, params = {}, files = {}, do_ratelimiting = True): | 941 | def __api_request(self, method, endpoint, params={}, files={}, do_ratelimiting=True): |
897 | """ | 942 | """ |
898 | Internal API request helper. | 943 | Internal API request helper. |
899 | """ | 944 | """ |
900 | response = None | 945 | response = None |
901 | headers = None | 946 | headers = None |
902 | 947 | remaining_wait = 0 | |
903 | # "pace" mode ratelimiting: Assume constant rate of requests, sleep a little less long than it | 948 | # "pace" mode ratelimiting: Assume constant rate of requests, sleep a little less long than it |
904 | # would take to not hit the rate limit at that request rate. | 949 | # would take to not hit the rate limit at that request rate. |
905 | if do_ratelimiting and self.ratelimit_method == "pace": | 950 | if do_ratelimiting and self.ratelimit_method == "pace": |
@@ -920,10 +965,10 @@ class Mastodon: | |||
920 | time.sleep(to_next) | 965 | time.sleep(to_next) |
921 | 966 | ||
922 | # Generate request headers | 967 | # Generate request headers |
923 | if self.access_token != None: | 968 | if self.access_token is not None: |
924 | headers = {'Authorization': 'Bearer ' + self.access_token} | 969 | headers = {'Authorization': 'Bearer ' + self.access_token} |
925 | 970 | ||
926 | if self.debug_requests == True: | 971 | if self.debug_requests: |
927 | print('Mastodon: Request to endpoint "' + endpoint + '" using method "' + method + '".') | 972 | print('Mastodon: Request to endpoint "' + endpoint + '" using method "' + method + '".') |
928 | print('Parameters: ' + str(params)) | 973 | print('Parameters: ' + str(params)) |
929 | print('Headers: ' + str(headers)) | 974 | print('Headers: ' + str(headers)) |
@@ -937,24 +982,28 @@ class Mastodon: | |||
937 | response_object = None | 982 | response_object = None |
938 | try: | 983 | try: |
939 | if method == 'GET': | 984 | if method == 'GET': |
940 | response_object = requests.get(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout) | 985 | response_object = requests.get(self.api_base_url + endpoint, params=params, |
941 | 986 | headers=headers, files=files, | |
987 | timeout=self.request_timeout) | ||
942 | if method == 'POST': | 988 | if method == 'POST': |
943 | response_object = requests.post(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout) | 989 | response_object = requests.post(self.api_base_url + endpoint, data=params, headers=headers, |
990 | files=files, timeout=self.request_timeout) | ||
944 | 991 | ||
945 | if method == 'PATCH': | 992 | if method == 'PATCH': |
946 | response_object = requests.patch(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout) | 993 | response_object = requests.patch(self.api_base_url + endpoint, data=params, headers=headers, |
994 | files=files, timeout=self.request_timeout) | ||
947 | 995 | ||
948 | if method == 'DELETE': | 996 | if method == 'DELETE': |
949 | response_object = requests.delete(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout) | 997 | response_object = requests.delete(self.api_base_url + endpoint, data=params, headers=headers, |
998 | files=files, timeout=self.request_timeout) | ||
950 | except Exception as e: | 999 | except Exception as e: |
951 | raise MastodonNetworkError("Could not complete request: %s" % e) | 1000 | raise MastodonNetworkError("Could not complete request: %s" % e) |
952 | 1001 | ||
953 | if response_object == None: | 1002 | if response_object is None: |
954 | raise MastodonIllegalArgumentError("Illegal request.") | 1003 | raise MastodonIllegalArgumentError("Illegal request.") |
955 | 1004 | ||
956 | # Handle response | 1005 | # Handle response |
957 | if self.debug_requests == True: | 1006 | if self.debug_requests: |
958 | print('Mastodon: Response received with code ' + str(response_object.status_code) + '.') | 1007 | print('Mastodon: Response received with code ' + str(response_object.status_code) + '.') |
959 | print('response headers: ' + str(response_object.headers)) | 1008 | print('response headers: ' + str(response_object.headers)) |
960 | print('Response text content: ' + str(response_object.text)) | 1009 | print('Response text content: ' + str(response_object.text)) |
@@ -968,36 +1017,49 @@ class Mastodon: | |||
968 | try: | 1017 | try: |
969 | response = response_object.json() | 1018 | response = response_object.json() |
970 | except: | 1019 | except: |
971 | raise MastodonAPIError("Could not parse response as JSON, response code was %s, bad json content was '%s'" % (response_object.status_code, response_object.content)) | 1020 | raise MastodonAPIError( |
1021 | "Could not parse response as JSON, response code was %s, " | ||
1022 | "bad json content was '%s'" % (response_object.status_code, | ||
1023 | response_object.content)) | ||
972 | 1024 | ||
973 | # Parse link headers | 1025 | # Parse link headers |
974 | if isinstance(response, list) and 'Link' in response_object.headers and response_object.headers['Link'] != "": | 1026 | if isinstance(response, list) and \ |
975 | tmp_urls = requests.utils.parse_header_links(response_object.headers['Link'].rstrip('>').replace('>,<', ',<')) | 1027 | 'Link' in response_object.headers and \ |
1028 | response_object.headers['Link'] != "": | ||
1029 | tmp_urls = requests.utils.parse_header_links( | ||
1030 | response_object.headers['Link'].rstrip('>').replace('>,<', ',<')) | ||
976 | for url in tmp_urls: | 1031 | for url in tmp_urls: |
1032 | if 'rel' not in url: | ||
1033 | continue | ||
1034 | |||
977 | if url['rel'] == 'next': | 1035 | if url['rel'] == 'next': |
978 | # Be paranoid and extract max_id specifically | 1036 | # Be paranoid and extract max_id specifically |
979 | next_url = url['url'] | 1037 | next_url = url['url'] |
980 | matchgroups = re.search(r"max_id=([0-9]*)", next_url) | 1038 | matchgroups = re.search(r"max_id=([0-9]*)", next_url) |
981 | 1039 | ||
982 | if matchgroups: | 1040 | if matchgroups: |
983 | next_params = copy.deepcopy(params) | 1041 | next_params = copy.deepcopy(params) |
984 | next_params['_pagination_method'] = method | 1042 | next_params['_pagination_method'] = method |
985 | next_params['_pagination_endpoint'] = endpoint | 1043 | next_params['_pagination_endpoint'] = endpoint |
986 | next_params['max_id'] = int(matchgroups.group(1)) | 1044 | next_params['max_id'] = int(matchgroups.group(1)) |
1045 | if "since_id" in next_params: | ||
1046 | del next_params['since_id'] | ||
987 | response[-1]['_pagination_next'] = next_params | 1047 | response[-1]['_pagination_next'] = next_params |
988 | 1048 | ||
989 | if url['rel'] == 'prev': | 1049 | if url['rel'] == 'prev': |
990 | # Be paranoid and extract since_id specifically | 1050 | # Be paranoid and extract since_id specifically |
991 | prev_url = url['url'] | 1051 | prev_url = url['url'] |
992 | matchgroups = re.search(r"since_id=([0-9]*)", prev_url) | 1052 | matchgroups = re.search(r"since_id=([0-9]*)", prev_url) |
993 | 1053 | ||
994 | if matchgroups: | 1054 | if matchgroups: |
995 | prev_params = copy.deepcopy(params) | 1055 | prev_params = copy.deepcopy(params) |
996 | prev_params['_pagination_method'] = method | 1056 | prev_params['_pagination_method'] = method |
997 | prev_params['_pagination_endpoint'] = endpoint | 1057 | prev_params['_pagination_endpoint'] = endpoint |
998 | prev_params['max_id'] = int(matchgroups.group(1)) | 1058 | prev_params['since_id'] = int(matchgroups.group(1)) |
1059 | if "max_id" in prev_params: | ||
1060 | del prev_params['max_id'] | ||
999 | response[0]['_pagination_prev'] = prev_params | 1061 | response[0]['_pagination_prev'] = prev_params |
1000 | 1062 | ||
1001 | # Handle rate limiting | 1063 | # Handle rate limiting |
1002 | if 'X-RateLimit-Remaining' in response_object.headers and do_ratelimiting: | 1064 | if 'X-RateLimit-Remaining' in response_object.headers and do_ratelimiting: |
1003 | self.ratelimit_remaining = int(response_object.headers['X-RateLimit-Remaining']) | 1065 | self.ratelimit_remaining = int(response_object.headers['X-RateLimit-Remaining']) |
@@ -1031,8 +1093,7 @@ class Mastodon: | |||
1031 | 1093 | ||
1032 | return response | 1094 | return response |
1033 | 1095 | ||
1034 | 1096 | def __stream(self, endpoint, listener, params={}, async=False): | |
1035 | def __stream(self, endpoint, listener, params = {}, async=False): | ||
1036 | """ | 1097 | """ |
1037 | Internal streaming API helper. | 1098 | Internal streaming API helper. |
1038 | 1099 | ||
@@ -1041,7 +1102,7 @@ class Mastodon: | |||
1041 | """ | 1102 | """ |
1042 | 1103 | ||
1043 | headers = {} | 1104 | headers = {} |
1044 | if self.access_token != None: | 1105 | if self.access_token is not None: |
1045 | headers = {'Authorization': 'Bearer ' + self.access_token} | 1106 | headers = {'Authorization': 'Bearer ' + self.access_token} |
1046 | url = self.api_base_url + endpoint | 1107 | url = self.api_base_url + endpoint |
1047 | 1108 | ||
@@ -1074,7 +1135,7 @@ class Mastodon: | |||
1074 | with closing(connection) as r: | 1135 | with closing(connection) as r: |
1075 | listener.handle_stream(r.iter_lines()) | 1136 | listener.handle_stream(r.iter_lines()) |
1076 | 1137 | ||
1077 | def __generate_params(self, params, exclude = []): | 1138 | def __generate_params(self, params, exclude=[]): |
1078 | """ | 1139 | """ |
1079 | Internal named-parameters-to-dict helper. | 1140 | Internal named-parameters-to-dict helper. |
1080 | 1141 | ||
@@ -1088,7 +1149,7 @@ class Mastodon: | |||
1088 | del params['self'] | 1149 | del params['self'] |
1089 | param_keys = list(params.keys()) | 1150 | param_keys = list(params.keys()) |
1090 | for key in param_keys: | 1151 | for key in param_keys: |
1091 | if params[key] == None or key in exclude: | 1152 | if params[key] is None or key in exclude: |
1092 | del params[key] | 1153 | del params[key] |
1093 | 1154 | ||
1094 | param_keys = list(params.keys()) | 1155 | param_keys = list(params.keys()) |
@@ -1099,50 +1160,53 @@ class Mastodon: | |||
1099 | 1160 | ||
1100 | return params | 1161 | return params |
1101 | 1162 | ||
1102 | |||
1103 | def __get_token_expired(self): | 1163 | def __get_token_expired(self): |
1104 | """Internal helper for oauth code""" | 1164 | """Internal helper for oauth code""" |
1105 | if self._token_expired < datetime.datetime.now(): | 1165 | return self._token_expired < datetime.datetime.now() |
1106 | return True | ||
1107 | else: | ||
1108 | return False | ||
1109 | 1166 | ||
1110 | def __set_token_expired(self, value): | 1167 | def __set_token_expired(self, value): |
1111 | """Internal helper for oauth code""" | 1168 | """Internal helper for oauth code""" |
1112 | self._token_expired = datetime.datetime.now() + datetime.timedelta(seconds=value) | 1169 | self._token_expired = datetime.datetime.now() + datetime.timedelta(seconds=value) |
1113 | return | 1170 | return |
1114 | 1171 | ||
1115 | def __get_refresh_token(self): | 1172 | def __get_refresh_token(self): |
1116 | """Internal helper for oauth code""" | 1173 | """Internal helper for oauth code""" |
1117 | return self._refresh_token | 1174 | return self._refresh_token |
1118 | 1175 | ||
1119 | def __set_refresh_token(self, value): | 1176 | def __set_refresh_token(self, value): |
1120 | """Internal helper for oauth code""" | 1177 | """Internal helper for oauth code""" |
1121 | self._refresh_token = value | 1178 | self._refresh_token = value |
1122 | return | 1179 | return |
1123 | 1180 | ||
1124 | @staticmethod | 1181 | @staticmethod |
1125 | def __protocolize(base_url): | 1182 | def __protocolize(base_url): |
1126 | """Internal add-protocol-to-url helper""" | 1183 | """Internal add-protocol-to-url helper""" |
1127 | if not base_url.startswith("http://") and not base_url.startswith("https://"): | 1184 | if not base_url.startswith("http://") and not base_url.startswith("https://"): |
1128 | base_url = "https://" + base_url | 1185 | base_url = "https://" + base_url |
1186 | |||
1187 | # Some API endpoints can't handle extra /'s in path requests | ||
1188 | base_url = base_url.rstrip("/") | ||
1129 | return base_url | 1189 | return base_url |
1130 | 1190 | ||
1191 | |||
1131 | ## | 1192 | ## |
1132 | # Exceptions | 1193 | # Exceptions |
1133 | ## | 1194 | ## |
1134 | class MastodonIllegalArgumentError(ValueError): | 1195 | class MastodonIllegalArgumentError(ValueError): |
1135 | pass | 1196 | pass |
1136 | 1197 | ||
1198 | |||
1137 | class MastodonFileNotFoundError(IOError): | 1199 | class MastodonFileNotFoundError(IOError): |
1138 | pass | 1200 | pass |
1139 | 1201 | ||
1202 | |||
1140 | class MastodonNetworkError(IOError): | 1203 | class MastodonNetworkError(IOError): |
1141 | pass | 1204 | pass |
1142 | 1205 | ||
1206 | |||
1143 | class MastodonAPIError(Exception): | 1207 | class MastodonAPIError(Exception): |
1144 | pass | 1208 | pass |
1145 | 1209 | ||
1210 | |||
1146 | class MastodonRatelimitError(Exception): | 1211 | class MastodonRatelimitError(Exception): |
1147 | pass | 1212 | pass |
1148 | |||
diff --git a/mastodon/streaming.py b/mastodon/streaming.py index 3212848..290ed44 100644 --- a/mastodon/streaming.py +++ b/mastodon/streaming.py | |||
@@ -1,7 +1,7 @@ | |||
1 | ''' | 1 | """ |
2 | Handlers for the Streaming API: | 2 | Handlers for the Streaming API: |
3 | https://github.com/tootsuite/mastodon/blob/master/docs/Using-the-API/Streaming-API.md | 3 | https://github.com/tootsuite/mastodon/blob/master/docs/Using-the-API/Streaming-API.md |
4 | ''' | 4 | """ |
5 | 5 | ||
6 | import json | 6 | import json |
7 | import logging | 7 | import logging |
@@ -12,43 +12,43 @@ log = logging.getLogger(__name__) | |||
12 | 12 | ||
13 | 13 | ||
14 | class MalformedEventError(Exception): | 14 | class MalformedEventError(Exception): |
15 | '''Raised when the server-sent event stream is malformed.''' | 15 | """Raised when the server-sent event stream is malformed.""" |
16 | pass | 16 | pass |
17 | 17 | ||
18 | 18 | ||
19 | class StreamListener(object): | 19 | class StreamListener(object): |
20 | '''Callbacks for the streaming API. Create a subclass, override the on_xxx | 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 | 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 | 22 | of your subclass to Mastodon.user_stream(), Mastodon.public_stream(), or |
23 | Mastodon.hashtag_stream().''' | 23 | Mastodon.hashtag_stream().""" |
24 | 24 | ||
25 | def on_update(self, status): | 25 | def on_update(self, status): |
26 | '''A new status has appeared! 'status' is the parsed JSON dictionary | 26 | """A new status has appeared! 'status' is the parsed JSON dictionary |
27 | describing the status.''' | 27 | describing the status.""" |
28 | pass | 28 | pass |
29 | 29 | ||
30 | def on_notification(self, notification): | 30 | def on_notification(self, notification): |
31 | '''A new notification. 'notification' is the parsed JSON dictionary | 31 | """A new notification. 'notification' is the parsed JSON dictionary |
32 | describing the notification.''' | 32 | describing the notification.""" |
33 | pass | 33 | pass |
34 | 34 | ||
35 | def on_delete(self, status_id): | 35 | def on_delete(self, status_id): |
36 | '''A status has been deleted. status_id is the status' integer ID.''' | 36 | """A status has been deleted. status_id is the status' integer ID.""" |
37 | pass | 37 | pass |
38 | 38 | ||
39 | def handle_heartbeat(self): | 39 | def handle_heartbeat(self): |
40 | '''The server has sent us a keep-alive message. This callback may be | 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 | 41 | useful to carry out periodic housekeeping tasks, or just to confirm |
42 | that the connection is still open.''' | 42 | that the connection is still open.""" |
43 | 43 | ||
44 | def handle_stream(self, lines): | 44 | def handle_stream(self, lines): |
45 | ''' | 45 | """ |
46 | Handles a stream of events from the Mastodon server. When each event | 46 | Handles a stream of events from the Mastodon server. When each event |
47 | is received, the corresponding .on_[name]() method is called. | 47 | is received, the corresponding .on_[name]() method is called. |
48 | 48 | ||
49 | lines: an iterable of lines of bytes sent by the Mastodon server, as | 49 | lines: an iterable of lines of bytes sent by the Mastodon server, as |
50 | returned by requests.Response.iter_lines(). | 50 | returned by requests.Response.iter_lines(). |
51 | ''' | 51 | """ |
52 | event = {} | 52 | event = {} |
53 | for raw_line in lines: | 53 | for raw_line in lines: |
54 | try: | 54 | try: |
@@ -104,4 +104,3 @@ class StreamListener(object): | |||
104 | else: | 104 | else: |
105 | # TODO: allow handlers to return/raise to stop streaming cleanly | 105 | # TODO: allow handlers to return/raise to stop streaming cleanly |
106 | handler(payload) | 106 | handler(payload) |
107 | |||
@@ -1,4 +1,4 @@ | |||
1 | from setuptools import setup, find_packages | 1 | from setuptools import setup |
2 | 2 | ||
3 | setup(name='Mastodon.py', | 3 | setup(name='Mastodon.py', |
4 | version='1.0.8', | 4 | version='1.0.8', |
@@ -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', 'dateutil', 'six'], | 9 | install_requires=['requests', 'python-dateutil', 'six', 'pytz'], |
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]', |
@@ -19,5 +19,4 @@ setup(name='Mastodon.py', | |||
19 | 'License :: OSI Approved :: MIT License', | 19 | 'License :: OSI Approved :: MIT License', |
20 | 'Programming Language :: Python :: 2', | 20 | 'Programming Language :: Python :: 2', |
21 | 'Programming Language :: Python :: 3', | 21 | 'Programming Language :: Python :: 3', |
22 | ] | 22 | ]) |
23 | ) | ||
diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py | |||
diff --git a/tests/test_streaming.py b/tests/test_streaming.py index c79a8e9..1f8d75f 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py | |||
@@ -24,9 +24,10 @@ class Listener(StreamListener): | |||
24 | self.heartbeats += 1 | 24 | self.heartbeats += 1 |
25 | 25 | ||
26 | def handle_stream_(self, lines): | 26 | def handle_stream_(self, lines): |
27 | '''Test helper to avoid littering all tests with six.b().''' | 27 | """Test helper to avoid littering all tests with six.b().""" |
28 | return self.handle_stream(map(six.b, lines)) | 28 | return self.handle_stream(map(six.b, lines)) |
29 | 29 | ||
30 | |||
30 | def test_heartbeat(): | 31 | def test_heartbeat(): |
31 | listener = Listener() | 32 | listener = Listener() |
32 | listener.handle_stream_([':one', ':two']) | 33 | listener.handle_stream_([':one', ':two']) |
@@ -85,7 +86,7 @@ def test_many(events): | |||
85 | 86 | ||
86 | 87 | ||
87 | def test_unknown_event(): | 88 | def test_unknown_event(): |
88 | '''Be tolerant of new event types''' | 89 | """Be tolerant of new event types""" |
89 | listener = Listener() | 90 | listener = Listener() |
90 | listener.handle_stream_([ | 91 | listener.handle_stream_([ |
91 | 'event: blahblah', | 92 | 'event: blahblah', |
@@ -137,11 +138,11 @@ def test_sse_order_doesnt_matter(): | |||
137 | 138 | ||
138 | 139 | ||
139 | def test_extra_keys_ignored(): | 140 | def test_extra_keys_ignored(): |
140 | ''' | 141 | """ |
141 | https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format | 142 | 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 | defines 'id' and 'retry' keys which the Mastodon streaming API doesn't use, |
143 | and alleges that "All other field names are ignored". | 144 | and alleges that "All other field names are ignored". |
144 | ''' | 145 | """ |
145 | listener = Listener() | 146 | listener = Listener() |
146 | listener.handle_stream_([ | 147 | listener.handle_stream_([ |
147 | 'event: update', | 148 | 'event: update', |
@@ -155,7 +156,7 @@ def test_extra_keys_ignored(): | |||
155 | 156 | ||
156 | 157 | ||
157 | def test_valid_utf8(): | 158 | def test_valid_utf8(): |
158 | '''Snowman Cat Face With Tears Of Joy''' | 159 | """Snowman Cat Face With Tears Of Joy""" |
159 | listener = Listener() | 160 | listener = Listener() |
160 | listener.handle_stream_([ | 161 | listener.handle_stream_([ |
161 | 'event: update', | 162 | 'event: update', |
@@ -166,7 +167,7 @@ def test_valid_utf8(): | |||
166 | 167 | ||
167 | 168 | ||
168 | def test_invalid_utf8(): | 169 | def test_invalid_utf8(): |
169 | '''Cat Face With Tears O''' | 170 | """Cat Face With Tears O""" |
170 | listener = Listener() | 171 | listener = Listener() |
171 | with pytest.raises(MalformedEventError): | 172 | with pytest.raises(MalformedEventError): |
172 | listener.handle_stream_([ | 173 | listener.handle_stream_([ |
@@ -177,13 +178,13 @@ def test_invalid_utf8(): | |||
177 | 178 | ||
178 | 179 | ||
179 | def test_multiline_payload(): | 180 | def test_multiline_payload(): |
180 | ''' | 181 | """ |
181 | https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Data-only_messages | 182 | 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 | 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 | twice! This would be really pathological for Mastodon because the payload |
184 | is JSON, but technically literal newlines are permissible (outside strings) | 185 | is JSON, but technically literal newlines are permissible (outside strings) |
185 | so let's handle this case. | 186 | so let's handle this case. |
186 | ''' | 187 | """ |
187 | listener = Listener() | 188 | listener = Listener() |
188 | listener.handle_stream_([ | 189 | listener.handle_stream_([ |
189 | 'event: update', | 190 | 'event: update', |