aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/conf.py4
-rw-r--r--mastodon/Mastodon.py374
-rw-r--r--mastodon/streaming.py29
-rw-r--r--setup.py7
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/test_streaming.py17
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
30import sys 30import sys
31sys.path.insert(0, os.path.abspath('../')) 31sys.path.insert(0, os.path.abspath('../'))
32autodoc_member_order = 'by_source' 32autodoc_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
6import time 6import time
7import random 7import random
8import string 8import string
9import pytz
10import datetime 9import datetime
11from contextlib import closing 10from contextlib import closing
12import pytz 11import pytz
@@ -18,6 +17,7 @@ import re
18import copy 17import copy
19import threading 18import threading
20 19
20
21class Mastodon: 21class 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##
1134class MastodonIllegalArgumentError(ValueError): 1195class MastodonIllegalArgumentError(ValueError):
1135 pass 1196 pass
1136 1197
1198
1137class MastodonFileNotFoundError(IOError): 1199class MastodonFileNotFoundError(IOError):
1138 pass 1200 pass
1139 1201
1202
1140class MastodonNetworkError(IOError): 1203class MastodonNetworkError(IOError):
1141 pass 1204 pass
1142 1205
1206
1143class MastodonAPIError(Exception): 1207class MastodonAPIError(Exception):
1144 pass 1208 pass
1145 1209
1210
1146class MastodonRatelimitError(Exception): 1211class 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"""
2Handlers for the Streaming API: 2Handlers for the Streaming API:
3https://github.com/tootsuite/mastodon/blob/master/docs/Using-the-API/Streaming-API.md 3https://github.com/tootsuite/mastodon/blob/master/docs/Using-the-API/Streaming-API.md
4''' 4"""
5 5
6import json 6import json
7import logging 7import logging
@@ -12,43 +12,43 @@ log = logging.getLogger(__name__)
12 12
13 13
14class MalformedEventError(Exception): 14class 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
19class StreamListener(object): 19class 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.''' 27describing 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
diff --git a/setup.py b/setup.py
index 81dafe6..0ebf236 100644
--- a/setup.py
+++ b/setup.py
@@ -1,4 +1,4 @@
1from setuptools import setup, find_packages 1from setuptools import setup
2 2
3setup(name='Mastodon.py', 3setup(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
30def test_heartbeat(): 31def 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
87def test_unknown_event(): 88def 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
139def test_extra_keys_ignored(): 140def 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
157def test_valid_utf8(): 158def 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
168def test_invalid_utf8(): 169def 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
179def test_multiline_payload(): 180def 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',
Powered by cgit v1.2.3 (git 2.41.0)