aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/conf.py4
-rw-r--r--mastodon/Mastodon.py351
-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, 229 insertions, 179 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 cd641e5..68f4914 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
@@ -17,6 +16,7 @@ import dateutil.parser
17import re 16import re
18import copy 17import copy
19 18
19
20class Mastodon: 20class Mastodon:
21 """ 21 """
22 Super basic but thorough and easy to use Mastodon 22 Super basic but thorough and easy to use Mastodon
@@ -28,12 +28,12 @@ class Mastodon:
28 __DEFAULT_BASE_URL = 'https://mastodon.social' 28 __DEFAULT_BASE_URL = 'https://mastodon.social'
29 __DEFAULT_TIMEOUT = 300 29 __DEFAULT_TIMEOUT = 300
30 30
31
32 ### 31 ###
33 # Registering apps 32 # Registering apps
34 ### 33 ###
35 @staticmethod 34 @staticmethod
36 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): 35 def create_app(client_name, scopes=['read', 'write', 'follow'], redirect_uris=None, website=None, to_file=None,
36 api_base_url=__DEFAULT_BASE_URL, request_timeout=__DEFAULT_TIMEOUT):
37 """ 37 """
38 Create a new app with given client_name and scopes (read, write, follow) 38 Create a new app with given client_name and scopes (read, write, follow)
39 39
@@ -47,7 +47,7 @@ class Mastodon:
47 Returns client_id and client_secret. 47 Returns client_id and client_secret.
48 """ 48 """
49 api_base_url = Mastodon.__protocolize(api_base_url) 49 api_base_url = Mastodon.__protocolize(api_base_url)
50 50
51 request_data = { 51 request_data = {
52 'client_name': client_name, 52 'client_name': client_name,
53 'scopes': " ".join(scopes) 53 'scopes': " ".join(scopes)
@@ -55,18 +55,18 @@ class Mastodon:
55 55
56 try: 56 try:
57 if redirect_uris is not None: 57 if redirect_uris is not None:
58 request_data['redirect_uris'] = redirect_uris; 58 request_data['redirect_uris'] = redirect_uris
59 else: 59 else:
60 request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'; 60 request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'
61 if website is not None: 61 if website is not None:
62 request_data['website'] = website 62 request_data['website'] = website
63 63
64 response = requests.post(api_base_url + '/api/v1/apps', data = request_data, timeout = request_timeout) 64 response = requests.post(api_base_url + '/api/v1/apps', data=request_data, timeout=request_timeout)
65 response = response.json() 65 response = response.json()
66 except Exception as e: 66 except Exception as e:
67 raise MastodonNetworkError("Could not complete request: %s" % e) 67 raise MastodonNetworkError("Could not complete request: %s" % e)
68 68
69 if to_file != None: 69 if to_file is not None:
70 with open(to_file, 'w') as secret_file: 70 with open(to_file, 'w') as secret_file:
71 secret_file.write(response['client_id'] + '\n') 71 secret_file.write(response['client_id'] + '\n')
72 secret_file.write(response['client_secret'] + '\n') 72 secret_file.write(response['client_secret'] + '\n')
@@ -76,7 +76,10 @@ class Mastodon:
76 ### 76 ###
77 # Authentication, including constructor 77 # Authentication, including constructor
78 ### 78 ###
79 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): 79 def __init__(self, client_id, client_secret=None, access_token=None,
80 api_base_url=__DEFAULT_BASE_URL, debug_requests=False,
81 ratelimit_method="wait", ratelimit_pacefactor=1.1,
82 request_timeout=__DEFAULT_TIMEOUT):
80 """ 83 """
81 Create a new API wrapper instance based on the given client_secret and client_id. If you 84 Create a new API wrapper instance based on the given client_secret and client_id. If you
82 give a client_id and it is not a file, you must also give a secret. 85 give a client_id and it is not a file, you must also give a secret.
@@ -115,7 +118,7 @@ class Mastodon:
115 118
116 self.request_timeout = request_timeout 119 self.request_timeout = request_timeout
117 120
118 if not ratelimit_method in ["throw", "wait", "pace"]: 121 if ratelimit_method not in ["throw", "wait", "pace"]:
119 raise MastodonIllegalArgumentError("Invalid ratelimit method.") 122 raise MastodonIllegalArgumentError("Invalid ratelimit method.")
120 123
121 if os.path.isfile(self.client_id): 124 if os.path.isfile(self.client_id):
@@ -123,15 +126,15 @@ class Mastodon:
123 self.client_id = secret_file.readline().rstrip() 126 self.client_id = secret_file.readline().rstrip()
124 self.client_secret = secret_file.readline().rstrip() 127 self.client_secret = secret_file.readline().rstrip()
125 else: 128 else:
126 if self.client_secret == None: 129 if self.client_secret is None:
127 raise MastodonIllegalArgumentError('Specified client id directly, but did not supply secret') 130 raise MastodonIllegalArgumentError('Specified client id directly, but did not supply secret')
128 131
129 if self.access_token != None and os.path.isfile(self.access_token): 132 if self.access_token is not None and os.path.isfile(self.access_token):
130 with open(self.access_token, 'r') as token_file: 133 with open(self.access_token, 'r') as token_file:
131 self.access_token = token_file.readline().rstrip() 134 self.access_token = token_file.readline().rstrip()
132
133 135
134 def auth_request_url(self, client_id = None, redirect_uris = "urn:ietf:wg:oauth:2.0:oob", scopes = ['read', 'write', 'follow']): 136 def auth_request_url(self, client_id=None, redirect_uris="urn:ietf:wg:oauth:2.0:oob",
137 scopes=['read', 'write', 'follow']):
135 """Returns the url that a client needs to request the grant from the server. 138 """Returns the url that a client needs to request the grant from the server.
136 """ 139 """
137 if client_id is None: 140 if client_id is None:
@@ -140,8 +143,8 @@ class Mastodon:
140 if os.path.isfile(client_id): 143 if os.path.isfile(client_id):
141 with open(client_id, 'r') as secret_file: 144 with open(client_id, 'r') as secret_file:
142 client_id = secret_file.readline().rstrip() 145 client_id = secret_file.readline().rstrip()
143 146
144 params = {} 147 params = dict()
145 params['client_id'] = client_id 148 params['client_id'] = client_id
146 params['response_type'] = "code" 149 params['response_type'] = "code"
147 params['redirect_uri'] = redirect_uris 150 params['redirect_uri'] = redirect_uris
@@ -149,22 +152,23 @@ class Mastodon:
149 formatted_params = urlencode(params) 152 formatted_params = urlencode(params)
150 return "".join([self.api_base_url, "/oauth/authorize?", formatted_params]) 153 return "".join([self.api_base_url, "/oauth/authorize?", formatted_params])
151 154
152 def log_in(self, username = None, password = None,\ 155 def log_in(self, username=None, password=None,
153 code = None, redirect_uri = "urn:ietf:wg:oauth:2.0:oob", refresh_token = None,\ 156 code=None, redirect_uri="urn:ietf:wg:oauth:2.0:oob", refresh_token=None,
154 scopes = ['read', 'write', 'follow'], to_file = None): 157 scopes=['read', 'write', 'follow'], to_file=None):
155 """ 158 """
156 Your username is the e-mail you use to log in into mastodon. 159 Your username is the e-mail you use to log in into mastodon.
157 160
158 Can persist access token to file, to be used in the constructor. 161 Can persist access token to file, to be used in the constructor.
159 162
160 Supports refresh_token but Mastodon.social doesn't implement it at the moment. 163 Supports refresh_token but Mastodon.social doesn't implement it at the moment.
161 164
162 Handles password, authorization_code, and refresh_token authentication. 165 Handles password, authorization_code, and refresh_token authentication.
163 166
164 Will throw a MastodonIllegalArgumentError if username / password 167 Will throw a MastodonIllegalArgumentError if username / password
165 are wrong, scopes are not valid or granted scopes differ from requested. 168 are wrong, scopes are not valid or granted scopes differ from requested.
166 169
167 For OAuth2 documentation, compare https://github.com/doorkeeper-gem/doorkeeper/wiki/Interacting-as-an-OAuth-client-with-Doorkeeper 170 For OAuth2 documentation, compare
171 https://github.com/doorkeeper-gem/doorkeeper/wiki/Interacting-as-an-OAuth-client-with-Doorkeeper
168 172
169 Returns the access token. 173 Returns the access token.
170 """ 174 """
@@ -179,13 +183,13 @@ class Mastodon:
179 params['grant_type'] = 'refresh_token' 183 params['grant_type'] = 'refresh_token'
180 else: 184 else:
181 raise MastodonIllegalArgumentError('Invalid arguments given. username and password or code are required.') 185 raise MastodonIllegalArgumentError('Invalid arguments given. username and password or code are required.')
182 186
183 params['client_id'] = self.client_id 187 params['client_id'] = self.client_id
184 params['client_secret'] = self.client_secret 188 params['client_secret'] = self.client_secret
185 params['scope'] = " ".join(scopes) 189 params['scope'] = " ".join(scopes)
186 190
187 try: 191 try:
188 response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting = False) 192 response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting=False)
189 self.access_token = response['access_token'] 193 self.access_token = response['access_token']
190 self.__set_refresh_token(response.get('refresh_token')) 194 self.__set_refresh_token(response.get('refresh_token'))
191 self.__set_token_expired(int(response.get('expires_in', 0))) 195 self.__set_token_expired(int(response.get('expires_in', 0)))
@@ -201,9 +205,10 @@ class Mastodon:
201 received_scopes = " ".join(sorted(response["scope"].split(" "))) 205 received_scopes = " ".join(sorted(response["scope"].split(" ")))
202 206
203 if requested_scopes != received_scopes: 207 if requested_scopes != received_scopes:
204 raise MastodonAPIError('Granted scopes "' + received_scopes + '" differ from requested scopes "' + requested_scopes + '".') 208 raise MastodonAPIError(
209 'Granted scopes "' + received_scopes + '" differ from requested scopes "' + requested_scopes + '".')
205 210
206 if to_file != None: 211 if to_file is not None:
207 with open(to_file, 'w') as token_file: 212 with open(to_file, 'w') as token_file:
208 token_file.write(response['access_token'] + '\n') 213 token_file.write(response['access_token'] + '\n')
209 214
@@ -223,7 +228,7 @@ class Mastodon:
223 ### 228 ###
224 # Reading data: Timelines 229 # Reading data: Timelines
225 ## 230 ##
226 def timeline(self, timeline = "home", max_id = None, since_id = None, limit = None): 231 def timeline(self, timeline="home", max_id=None, since_id=None, limit=None):
227 """ 232 """
228 Fetch statuses, most recent ones first. Timeline can be home, local, public, 233 Fetch statuses, most recent ones first. Timeline can be home, local, public,
229 or tag/hashtag. See the following functions documentation for what those do. 234 or tag/hashtag. See the following functions documentation for what those do.
@@ -239,39 +244,44 @@ class Mastodon:
239 params_initial['local'] = True 244 params_initial['local'] = True
240 245
241 params = self.__generate_params(params_initial, ['timeline']) 246 params = self.__generate_params(params_initial, ['timeline'])
242 return self.__api_request('GET', '/api/v1/timelines/' + timeline, params) 247 url = '/api/v1/timelines/{0}'.format(timeline)
248 return self.__api_request('GET', url, params)
243 249
244 def timeline_home(self, max_id = None, since_id = None, limit = None): 250 def timeline_home(self, max_id=None, since_id=None, limit=None):
245 """ 251 """
246 Fetch the authenticated users home timeline (i.e. followed users and self). 252 Fetch the authenticated users home timeline (i.e. followed users and self).
247 253
248 Returns a list of toot dicts. 254 Returns a list of toot dicts.
249 """ 255 """
250 return self.timeline('home', max_id = max_id, since_id = since_id, limit = limit) 256 return self.timeline('home', max_id=max_id, since_id=since_id,
257 limit=limit)
251 258
252 def timeline_local(self, max_id = None, since_id = None, limit = None): 259 def timeline_local(self, max_id=None, since_id=None, limit=None):
253 """ 260 """
254 Fetches the local / instance-wide timeline, not including replies. 261 Fetches the local / instance-wide timeline, not including replies.
255 262
256 Returns a list of toot dicts. 263 Returns a list of toot dicts.
257 """ 264 """
258 return self.timeline('local', max_id = max_id, since_id = since_id, limit = limit) 265 return self.timeline('local', max_id=max_id, since_id=since_id,
266 limit=limit)
259 267
260 def timeline_public(self, max_id = None, since_id = None, limit = None): 268 def timeline_public(self, max_id=None, since_id=None, limit=None):
261 """ 269 """
262 Fetches the public / visible-network timeline, not including replies. 270 Fetches the public / visible-network timeline, not including replies.
263 271
264 Returns a list of toot dicts. 272 Returns a list of toot dicts.
265 """ 273 """
266 return self.timeline('public', max_id = max_id, since_id = since_id, limit = limit) 274 return self.timeline('public', max_id=max_id, since_id=since_id,
275 limit=limit)
267 276
268 def timeline_hashtag(self, hashtag, max_id = None, since_id = None, limit = None): 277 def timeline_hashtag(self, hashtag, max_id=None, since_id=None, limit=None):
269 """ 278 """
270 Fetch a timeline of toots with a given hashtag. 279 Fetch a timeline of toots with a given hashtag.
271 280
272 Returns a list of toot dicts. 281 Returns a list of toot dicts.
273 """ 282 """
274 return self.timeline('tag/' + str(hashtag), max_id = max_id, since_id = since_id, limit = limit) 283 url = 'tag/{0}'.format(str(hashtag))
284 return self.timeline(url, max_id=max_id, since_id=since_id, limit=limit)
275 285
276 ### 286 ###
277 # Reading data: Statuses 287 # Reading data: Statuses
@@ -282,7 +292,8 @@ class Mastodon:
282 292
283 Returns a toot dict. 293 Returns a toot dict.
284 """ 294 """
285 return self.__api_request('GET', '/api/v1/statuses/' + str(id)) 295 url = '/api/v1/statuses/{0}'.format(str(id))
296 return self.__api_request('GET', url)
286 297
287 def status_card(self, id): 298 def status_card(self, id):
288 """ 299 """
@@ -291,7 +302,8 @@ class Mastodon:
291 302
292 Returns a card dict. 303 Returns a card dict.
293 """ 304 """
294 return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/card') 305 url = '/api/v1/statuses/{0}/card'.format(str(id))
306 return self.__api_request('GET', url)
295 307
296 def status_context(self, id): 308 def status_context(self, id):
297 """ 309 """
@@ -299,7 +311,8 @@ class Mastodon:
299 311
300 Returns a context dict. 312 Returns a context dict.
301 """ 313 """
302 return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/context') 314 url = '/api/v1/statuses/{0}/context'.format(str(id))
315 return self.__api_request('GET', url)
303 316
304 def status_reblogged_by(self, id): 317 def status_reblogged_by(self, id):
305 """ 318 """
@@ -307,7 +320,8 @@ class Mastodon:
307 320
308 Returns a list of user dicts. 321 Returns a list of user dicts.
309 """ 322 """
310 return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/reblogged_by') 323 url = '/api/v1/statuses/{0}/reblogged_by'.format(str(id))
324 return self.__api_request('GET', url)
311 325
312 def status_favourited_by(self, id): 326 def status_favourited_by(self, id):
313 """ 327 """
@@ -315,12 +329,13 @@ class Mastodon:
315 329
316 Returns a list of user dicts. 330 Returns a list of user dicts.
317 """ 331 """
318 return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/favourited_by') 332 url = '/api/v1/statuses/{0}/favourited_by'.format(str(id))
333 return self.__api_request('GET', url)
319 334
320 ### 335 ###
321 # Reading data: Notifications 336 # Reading data: Notifications
322 ### 337 ###
323 def notifications(self, id = None, max_id = None, since_id = None, limit = None): 338 def notifications(self, id=None, max_id=None, since_id=None, limit=None):
324 """ 339 """
325 Fetch notifications (mentions, favourites, reblogs, follows) for the authenticated 340 Fetch notifications (mentions, favourites, reblogs, follows) for the authenticated
326 user. 341 user.
@@ -329,11 +344,12 @@ class Mastodon:
329 344
330 Returns a list of notification dicts. 345 Returns a list of notification dicts.
331 """ 346 """
332 if id == None: 347 if id is None:
333 params = self.__generate_params(locals(), ['id']) 348 params = self.__generate_params(locals(), ['id'])
334 return self.__api_request('GET', '/api/v1/notifications', params) 349 return self.__api_request('GET', '/api/v1/notifications', params)
335 else: 350 else:
336 return self.__api_request('GET', '/api/v1/notifications/' + str(id)) 351 url = '/api/v1/notifications/{0}'.format(str(id))
352 return self.__api_request('GET', url)
337 353
338 ### 354 ###
339 # Reading data: Accounts 355 # Reading data: Accounts
@@ -344,7 +360,8 @@ class Mastodon:
344 360
345 Returns a user dict. 361 Returns a user dict.
346 """ 362 """
347 return self.__api_request('GET', '/api/v1/accounts/' + str(id)) 363 url = '/api/v1/accounts/{0}'.format(str(id))
364 return self.__api_request('GET', url)
348 365
349 def account_verify_credentials(self): 366 def account_verify_credentials(self):
350 """ 367 """
@@ -354,32 +371,35 @@ class Mastodon:
354 """ 371 """
355 return self.__api_request('GET', '/api/v1/accounts/verify_credentials') 372 return self.__api_request('GET', '/api/v1/accounts/verify_credentials')
356 373
357 def account_statuses(self, id, max_id = None, since_id = None, limit = None): 374 def account_statuses(self, id, max_id=None, since_id=None, limit=None):
358 """ 375 """
359 Fetch statuses by user id. Same options as timeline are permitted. 376 Fetch statuses by user id. Same options as timeline are permitted.
360 377
361 Returns a list of toot dicts. 378 Returns a list of toot dicts.
362 """ 379 """
363 params = self.__generate_params(locals(), ['id']) 380 params = self.__generate_params(locals(), ['id'])
364 return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/statuses', params) 381 url = '/api/v1/accounts/{0}/statuses'.format(str(id))
382 return self.__api_request('GET', url, params)
365 383
366 def account_following(self, id, max_id = None, since_id = None, limit = None): 384 def account_following(self, id, max_id=None, since_id=None, limit=None):
367 """ 385 """
368 Fetch users the given user is following. 386 Fetch users the given user is following.
369 387
370 Returns a list of user dicts. 388 Returns a list of user dicts.
371 """ 389 """
372 params = self.__generate_params(locals(), ['id']) 390 params = self.__generate_params(locals(), ['id'])
373 return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/following', params) 391 url = '/api/v1/accounts/{0}/following'.format(str(id))
392 return self.__api_request('GET', url, params)
374 393
375 def account_followers(self, id, max_id = None, since_id = None, limit = None): 394 def account_followers(self, id, max_id=None, since_id=None, limit=None):
376 """ 395 """
377 Fetch users the given user is followed by. 396 Fetch users the given user is followed by.
378 397
379 Returns a list of user dicts. 398 Returns a list of user dicts.
380 """ 399 """
381 params = self.__generate_params(locals(), ['id']) 400 params = self.__generate_params(locals(), ['id'])
382 return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/followers', params) 401 url = '/api/v1/accounts/{0}/followers'.format(str(id))
402 return self.__api_request('GET', url, params)
383 403
384 def account_relationships(self, id): 404 def account_relationships(self, id):
385 """ 405 """
@@ -389,9 +409,10 @@ class Mastodon:
389 Returns a list of relationship dicts. 409 Returns a list of relationship dicts.
390 """ 410 """
391 params = self.__generate_params(locals()) 411 params = self.__generate_params(locals())
392 return self.__api_request('GET', '/api/v1/accounts/relationships', params) 412 return self.__api_request('GET', '/api/v1/accounts/relationships',
413 params)
393 414
394 def account_search(self, q, limit = None): 415 def account_search(self, q, limit=None):
395 """ 416 """
396 Fetch matching accounts. Will lookup an account remotely if the search term is 417 Fetch matching accounts. Will lookup an account remotely if the search term is
397 in the username@domain format and not yet in the database. 418 in the username@domain format and not yet in the database.
@@ -404,7 +425,7 @@ class Mastodon:
404 ### 425 ###
405 # Reading data: Searching 426 # Reading data: Searching
406 ### 427 ###
407 def search(self, q, resolve = False): 428 def search(self, q, resolve=False):
408 """ 429 """
409 Fetch matching hashtags, accounts and statuses. Will search federated 430 Fetch matching hashtags, accounts and statuses. Will search federated
410 instances if resolve is True. 431 instances if resolve is True.
@@ -417,7 +438,7 @@ class Mastodon:
417 ### 438 ###
418 # Reading data: Mutes and Blocks 439 # Reading data: Mutes and Blocks
419 ### 440 ###
420 def mutes(self, max_id = None, since_id = None, limit = None): 441 def mutes(self, max_id=None, since_id=None, limit=None):
421 """ 442 """
422 Fetch a list of users muted by the authenticated user. 443 Fetch a list of users muted by the authenticated user.
423 444
@@ -426,7 +447,7 @@ class Mastodon:
426 params = self.__generate_params(locals()) 447 params = self.__generate_params(locals())
427 return self.__api_request('GET', '/api/v1/mutes', params) 448 return self.__api_request('GET', '/api/v1/mutes', params)
428 449
429 def blocks(self, max_id = None, since_id = None, limit = None): 450 def blocks(self, max_id=None, since_id=None, limit=None):
430 """ 451 """
431 Fetch a list of users blocked by the authenticated user. 452 Fetch a list of users blocked by the authenticated user.
432 453
@@ -449,7 +470,7 @@ class Mastodon:
449 ### 470 ###
450 # Reading data: Favourites 471 # Reading data: Favourites
451 ### 472 ###
452 def favourites(self, max_id = None, since_id = None, limit = None): 473 def favourites(self, max_id=None, since_id=None, limit=None):
453 """ 474 """
454 Fetch the authenticated user's favourited statuses. 475 Fetch the authenticated user's favourited statuses.
455 476
@@ -461,7 +482,7 @@ class Mastodon:
461 ### 482 ###
462 # Reading data: Follow requests 483 # Reading data: Follow requests
463 ### 484 ###
464 def follow_requests(self, max_id = None, since_id = None, limit = None): 485 def follow_requests(self, max_id=None, since_id=None, limit=None):
465 """ 486 """
466 Fetch the authenticated user's incoming follow requests. 487 Fetch the authenticated user's incoming follow requests.
467 488
@@ -473,7 +494,7 @@ class Mastodon:
473 ### 494 ###
474 # Reading data: Domain blocks 495 # Reading data: Domain blocks
475 ### 496 ###
476 def domain_blocks(self, max_id = None, since_id = None, limit = None): 497 def domain_blocks(self, max_id=None, since_id=None, limit=None):
477 """ 498 """
478 Fetch the authenticated user's blocked domains. 499 Fetch the authenticated user's blocked domains.
479 500
@@ -481,11 +502,12 @@ class Mastodon:
481 """ 502 """
482 params = self.__generate_params(locals()) 503 params = self.__generate_params(locals())
483 return self.__api_request('GET', '/api/v1/domain_blocks', params) 504 return self.__api_request('GET', '/api/v1/domain_blocks', params)
484 505
485 ### 506 ###
486 # Writing data: Statuses 507 # Writing data: Statuses
487 ### 508 ###
488 def status_post(self, status, in_reply_to_id = None, media_ids = None, sensitive = False, visibility = '', spoiler_text = None): 509 def status_post(self, status, in_reply_to_id=None, media_ids=None,
510 sensitive=False, visibility='', spoiler_text=None):
489 """ 511 """
490 Post a status. Can optionally be in reply to another status and contain 512 Post a status. Can optionally be in reply to another status and contain
491 up to four pieces of media (Uploaded via media_post()). media_ids can 513 up to four pieces of media (Uploaded via media_post()). media_ids can
@@ -517,12 +539,13 @@ class Mastodon:
517 # Validate visibility parameter 539 # Validate visibility parameter
518 valid_visibilities = ['private', 'public', 'unlisted', 'direct', ''] 540 valid_visibilities = ['private', 'public', 'unlisted', 'direct', '']
519 if params_initial['visibility'].lower() not in valid_visibilities: 541 if params_initial['visibility'].lower() not in valid_visibilities:
520 raise ValueError('Invalid visibility value! Acceptable values are %s' % valid_visibilities) 542 raise ValueError('Invalid visibility value! Acceptable '
543 'values are %s' % valid_visibilities)
521 544
522 if params_initial['sensitive'] == False: 545 if params_initial['sensitive'] is False:
523 del[params_initial['sensitive']] 546 del [params_initial['sensitive']]
524 547
525 if media_ids != None: 548 if media_ids is not None:
526 try: 549 try:
527 media_ids_proper = [] 550 media_ids_proper = []
528 for media_id in media_ids: 551 for media_id in media_ids:
@@ -531,7 +554,8 @@ class Mastodon:
531 else: 554 else:
532 media_ids_proper.append(media_id) 555 media_ids_proper.append(media_id)
533 except Exception as e: 556 except Exception as e:
534 raise MastodonIllegalArgumentError("Invalid media dict: %s" % e) 557 raise MastodonIllegalArgumentError("Invalid media "
558 "dict: %s" % e)
535 559
536 params_initial["media_ids"] = media_ids_proper 560 params_initial["media_ids"] = media_ids_proper
537 561
@@ -552,14 +576,16 @@ class Mastodon:
552 576
553 Returns an empty dict for good measure. 577 Returns an empty dict for good measure.
554 """ 578 """
555 return self.__api_request('DELETE', '/api/v1/statuses/' + str(id)) 579 url = '/api/v1/statuses/{0}'.format(str(id))
580 return self.__api_request('DELETE', url)
556 581
557 def status_reblog(self, id): 582 def status_reblog(self, id):
558 """Reblog a status. 583 """Reblog a status.
559 584
560 Returns a toot with with a new status that wraps around the reblogged one. 585 Returns a toot with with a new status that wraps around the reblogged one.
561 """ 586 """
562 return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/reblog") 587 url = '/api/v1/statuses/{0}/reblog'.format(str(id))
588 return self.__api_request('POST', url)
563 589
564 def status_unreblog(self, id): 590 def status_unreblog(self, id):
565 """ 591 """
@@ -567,7 +593,8 @@ class Mastodon:
567 593
568 Returns a toot dict with the status that used to be reblogged. 594 Returns a toot dict with the status that used to be reblogged.
569 """ 595 """
570 return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unreblog") 596 url = '/api/v1/statuses/{0}/unreblog'.format(str(id))
597 return self.__api_request('POST', url)
571 598
572 def status_favourite(self, id): 599 def status_favourite(self, id):
573 """ 600 """
@@ -575,7 +602,8 @@ class Mastodon:
575 602
576 Returns a toot dict with the favourited status. 603 Returns a toot dict with the favourited status.
577 """ 604 """
578 return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/favourite") 605 url = '/api/v1/statuses/{0}/favourite'.format(str(id))
606 return self.__api_request('POST', url)
579 607
580 def status_unfavourite(self, id): 608 def status_unfavourite(self, id):
581 """ 609 """
@@ -583,7 +611,8 @@ class Mastodon:
583 611
584 Returns a toot dict with the un-favourited status. 612 Returns a toot dict with the un-favourited status.
585 """ 613 """
586 return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unfavourite") 614 url = '/api/v1/statuses/{0}/unfavourite'.format(str(id))
615 return self.__api_request('POST', url)
587 616
588 ### 617 ###
589 # Writing data: Notifications 618 # Writing data: Notifications
@@ -603,7 +632,8 @@ class Mastodon:
603 632
604 Returns a relationship dict containing the updated relationship to the user. 633 Returns a relationship dict containing the updated relationship to the user.
605 """ 634 """
606 return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/follow") 635 url = '/api/v1/accounts/{0}/follow'.format(str(id))
636 return self.__api_request('POST', url)
607 637
608 def follows(self, uri): 638 def follows(self, uri):
609 """ 639 """
@@ -620,7 +650,8 @@ class Mastodon:
620 650
621 Returns a relationship dict containing the updated relationship to the user. 651 Returns a relationship dict containing the updated relationship to the user.
622 """ 652 """
623 return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unfollow") 653 url = '/api/v1/accounts/{0}/unfollow'.format(str(id))
654 return self.__api_request('POST', url)
624 655
625 def account_block(self, id): 656 def account_block(self, id):
626 """ 657 """
@@ -628,7 +659,8 @@ class Mastodon:
628 659
629 Returns a relationship dict containing the updated relationship to the user. 660 Returns a relationship dict containing the updated relationship to the user.
630 """ 661 """
631 return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/block") 662 url = '/api/v1/accounts/{0}/block'.format(str(id))
663 return self.__api_request('POST', url)
632 664
633 def account_unblock(self, id): 665 def account_unblock(self, id):
634 """ 666 """
@@ -636,7 +668,8 @@ class Mastodon:
636 668
637 Returns a relationship dict containing the updated relationship to the user. 669 Returns a relationship dict containing the updated relationship to the user.
638 """ 670 """
639 return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unblock") 671 url = '/api/v1/accounts/{0}/unblock'.format(str(id))
672 return self.__api_request('POST', url)
640 673
641 def account_mute(self, id): 674 def account_mute(self, id):
642 """ 675 """
@@ -644,7 +677,8 @@ class Mastodon:
644 677
645 Returns a relationship dict containing the updated relationship to the user. 678 Returns a relationship dict containing the updated relationship to the user.
646 """ 679 """
647 return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/mute") 680 url = '/api/v1/accounts/{0}/mute'.format(str(id))
681 return self.__api_request('POST', url)
648 682
649 def account_unmute(self, id): 683 def account_unmute(self, id):
650 """ 684 """
@@ -652,9 +686,11 @@ class Mastodon:
652 686
653 Returns a relationship dict containing the updated relationship to the user. 687 Returns a relationship dict containing the updated relationship to the user.
654 """ 688 """
655 return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unmute") 689 url = '/api/v1/accounts/{0}/unmute'.format(str(id))
690 return self.__api_request('POST', url)
656 691
657 def account_update_credentials(self, display_name = None, note = None, avatar = None, header = None): 692 def account_update_credentials(self, display_name=None, note=None,
693 avatar=None, header=None):
658 """ 694 """
659 Update the profile for the currently authenticated user. 695 Update the profile for the currently authenticated user.
660 696
@@ -689,7 +725,8 @@ class Mastodon:
689 725
690 Returns an empty dict. 726 Returns an empty dict.
691 """ 727 """
692 return self.__api_request('POST', '/api/v1/follow_requests/' + str(id) + "/authorize") 728 url = '/api/v1/follow_requests/{0}/authorize'.format(str(id))
729 return self.__api_request('POST', url)
693 730
694 def follow_request_reject(self, id): 731 def follow_request_reject(self, id):
695 """ 732 """
@@ -697,12 +734,13 @@ class Mastodon:
697 734
698 Returns an empty dict. 735 Returns an empty dict.
699 """ 736 """
700 return self.__api_request('POST', '/api/v1/follow_requests/' + str(id) + "/reject") 737 url = '/api/v1/follow_requests/{0}/reject'.format(str(id))
738 return self.__api_request('POST', url)
701 739
702 ### 740 ###
703 # Writing data: Media 741 # Writing data: Media
704 ### 742 ###
705 def media_post(self, media_file, mime_type = None): 743 def media_post(self, media_file, mime_type=None):
706 """ 744 """
707 Post an image. media_file can either be image data or 745 Post an image. media_file can either be image data or
708 a file name. If image data is passed directly, the mime 746 a file name. If image data is passed directly, the mime
@@ -715,45 +753,49 @@ class Mastodon:
715 Returns a media dict. This contains the id that can be used in 753 Returns a media dict. This contains the id that can be used in
716 status_post to attach the media file to a toot. 754 status_post to attach the media file to a toot.
717 """ 755 """
718 if mime_type == None and os.path.isfile(media_file): 756 if mime_type is None and os.path.isfile(media_file):
719 mime_type = mimetypes.guess_type(media_file)[0] 757 mime_type = mimetypes.guess_type(media_file)[0]
720 media_file = open(media_file, 'rb') 758 media_file = open(media_file, 'rb')
721 759
722 if mime_type == None: 760 if mime_type is None:
723 raise MastodonIllegalArgumentError('Could not determine mime type or data passed directly without mime type.') 761 raise MastodonIllegalArgumentError('Could not determine mime type'
762 ' or data passed directly '
763 'without mime type.')
724 764
725 random_suffix = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10)) 765 random_suffix = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10))
726 file_name = "mastodonpyupload_" + str(time.time()) + "_" + str(random_suffix) + mimetypes.guess_extension(mime_type) 766 file_name = "mastodonpyupload_" + str(time.time()) + "_" + str(random_suffix) + mimetypes.guess_extension(
767 mime_type)
727 768
728 media_file_description = (file_name, media_file, mime_type) 769 media_file_description = (file_name, media_file, mime_type)
729 return self.__api_request('POST', '/api/v1/media', files = {'file': media_file_description}) 770 return self.__api_request('POST', '/api/v1/media',
771 files={'file': media_file_description})
730 772
731 ### 773 ###
732 # Writing data: Domain blocks 774 # Writing data: Domain blocks
733 ### 775 ###
734 def domain_block(self, domain = None): 776 def domain_block(self, domain=None):
735 """ 777 """
736 Add a block for all statuses originating from the specified domain for the logged-in user. 778 Add a block for all statuses originating from the specified domain for the logged-in user.
737 """ 779 """
738 params = self.__generate_params(locals()) 780 params = self.__generate_params(locals())
739 return self.__api_request('POST', '/api/v1/domain_blocks', params) 781 return self.__api_request('POST', '/api/v1/domain_blocks', params)
740 782
741 def domain_unblock(self, domain = None): 783 def domain_unblock(self, domain=None):
742 """ 784 """
743 Remove a domain block for the logged-in user. 785 Remove a domain block for the logged-in user.
744 """ 786 """
745 params = self.__generate_params(locals()) 787 params = self.__generate_params(locals())
746 return self.__api_request('DELETE', '/api/v1/domain_blocks', params) 788 return self.__api_request('DELETE', '/api/v1/domain_blocks', params)
747 789
748 ### 790 ###
749 # Pagination 791 # Pagination
750 ### 792 ###
751 def fetch_next(self, previous_page): 793 def fetch_next(self, previous_page):
752 """ 794 """
753 Fetches the next page of results of a paginated request. Pass in the 795 Fetches the next page of results of a paginated request. Pass in the
754 previous page in its entirety, or the pagination information dict 796 previous page in its entirety, or the pagination information dict
755 returned as a part of that pages last status ('_pagination_next'). 797 returned as a part of that pages last status ('_pagination_next').
756 798
757 Returns the next page or None if no further data is available. 799 Returns the next page or None if no further data is available.
758 """ 800 """
759 if isinstance(previous_page, list): 801 if isinstance(previous_page, list):
@@ -763,21 +805,21 @@ class Mastodon:
763 return None 805 return None
764 else: 806 else:
765 params = copy.deepcopy(previous_page) 807 params = copy.deepcopy(previous_page)
766 808
767 method = params['_pagination_method'] 809 method = params['_pagination_method']
768 del params['_pagination_method'] 810 del params['_pagination_method']
769 811
770 endpoint = params['_pagination_endpoint'] 812 endpoint = params['_pagination_endpoint']
771 del params['_pagination_endpoint'] 813 del params['_pagination_endpoint']
772 814
773 return self.__api_request(method, endpoint, params) 815 return self.__api_request(method, endpoint, params)
774 816
775 def fetch_previous(self, next_page): 817 def fetch_previous(self, next_page):
776 """ 818 """
777 Fetches the previous page of results of a paginated request. Pass in the 819 Fetches the previous page of results of a paginated request. Pass in the
778 previous page in its entirety, or the pagination information dict 820 previous page in its entirety, or the pagination information dict
779 returned as a part of that pages first status ('_pagination_prev'). 821 returned as a part of that pages first status ('_pagination_prev').
780 822
781 Returns the previous page or None if no further data is available. 823 Returns the previous page or None if no further data is available.
782 """ 824 """
783 if isinstance(next_page, list): 825 if isinstance(next_page, list):
@@ -787,34 +829,34 @@ class Mastodon:
787 return None 829 return None
788 else: 830 else:
789 params = copy.deepcopy(next_page) 831 params = copy.deepcopy(next_page)
790 832
791 method = params['_pagination_method'] 833 method = params['_pagination_method']
792 del params['_pagination_method'] 834 del params['_pagination_method']
793 835
794 endpoint = params['_pagination_endpoint'] 836 endpoint = params['_pagination_endpoint']
795 del params['_pagination_endpoint'] 837 del params['_pagination_endpoint']
796 838
797 return self.__api_request(method, endpoint, params) 839 return self.__api_request(method, endpoint, params)
798 840
799 def fetch_remaining(self, first_page): 841 def fetch_remaining(self, first_page):
800 """ 842 """
801 Fetches all the remaining pages of a paginated request starting from a 843 Fetches all the remaining pages of a paginated request starting from a
802 first page and returns the entire set of results (including the first page 844 first page and returns the entire set of results (including the first page
803 that was passed in) as a big list. 845 that was passed in) as a big list.
804 846
805 Be careful, as this might generate a lot of requests, depending on what you are 847 Be careful, as this might generate a lot of requests, depending on what you are
806 fetching, and might cause you to run into rate limits very quickly. 848 fetching, and might cause you to run into rate limits very quickly.
807 """ 849 """
808 first_page = copy.deepcopy(first_page) 850 first_page = copy.deepcopy(first_page)
809 851
810 all_pages = [] 852 all_pages = []
811 current_page = first_page 853 current_page = first_page
812 while current_page != None and len(current_page) > 0: 854 while current_page is not None and len(current_page) > 0:
813 all_pages.extend(current_page) 855 all_pages.extend(current_page)
814 current_page = self.fetch_next(current_page) 856 current_page = self.fetch_next(current_page)
815 857
816 return all_pages 858 return all_pages
817 859
818 ### 860 ###
819 # Streaming 861 # Streaming
820 ### 862 ###
@@ -858,7 +900,7 @@ class Mastodon:
858 incoming events. 900 incoming events.
859 """ 901 """
860 return self.__stream("/api/v1/streaming/hashtag?tag={}".format(tag), listener) 902 return self.__stream("/api/v1/streaming/hashtag?tag={}".format(tag), listener)
861 903
862 ### 904 ###
863 # Internal helpers, dragons probably 905 # Internal helpers, dragons probably
864 ### 906 ###
@@ -870,22 +912,22 @@ class Mastodon:
870 Assumes UTC if timezone is not given. 912 Assumes UTC if timezone is not given.
871 """ 913 """
872 date_time_utc = None 914 date_time_utc = None
873 if date_time.tzinfo == None: 915 if date_time.tzinfo is None:
874 date_time_utc = date_time.replace(tzinfo = pytz.utc) 916 date_time_utc = date_time.replace(tzinfo=pytz.utc)
875 else: 917 else:
876 date_time_utc = date_time.astimezone(pytz.utc) 918 date_time_utc = date_time.astimezone(pytz.utc)
877 919
878 epoch_utc = datetime.datetime.utcfromtimestamp(0).replace(tzinfo = pytz.utc) 920 epoch_utc = datetime.datetime.utcfromtimestamp(0).replace(tzinfo=pytz.utc)
879 921
880 return (date_time_utc - epoch_utc).total_seconds() 922 return (date_time_utc - epoch_utc).total_seconds()
881 923
882 def __api_request(self, method, endpoint, params = {}, files = {}, do_ratelimiting = True): 924 def __api_request(self, method, endpoint, params={}, files={}, do_ratelimiting=True):
883 """ 925 """
884 Internal API request helper. 926 Internal API request helper.
885 """ 927 """
886 response = None 928 response = None
887 headers = None 929 headers = None
888 930 remaining_wait = 0
889 # "pace" mode ratelimiting: Assume constant rate of requests, sleep a little less long than it 931 # "pace" mode ratelimiting: Assume constant rate of requests, sleep a little less long than it
890 # would take to not hit the rate limit at that request rate. 932 # would take to not hit the rate limit at that request rate.
891 if do_ratelimiting and self.ratelimit_method == "pace": 933 if do_ratelimiting and self.ratelimit_method == "pace":
@@ -906,10 +948,10 @@ class Mastodon:
906 time.sleep(to_next) 948 time.sleep(to_next)
907 949
908 # Generate request headers 950 # Generate request headers
909 if self.access_token != None: 951 if self.access_token is not None:
910 headers = {'Authorization': 'Bearer ' + self.access_token} 952 headers = {'Authorization': 'Bearer ' + self.access_token}
911 953
912 if self.debug_requests == True: 954 if self.debug_requests:
913 print('Mastodon: Request to endpoint "' + endpoint + '" using method "' + method + '".') 955 print('Mastodon: Request to endpoint "' + endpoint + '" using method "' + method + '".')
914 print('Parameters: ' + str(params)) 956 print('Parameters: ' + str(params))
915 print('Headers: ' + str(headers)) 957 print('Headers: ' + str(headers))
@@ -923,24 +965,28 @@ class Mastodon:
923 response_object = None 965 response_object = None
924 try: 966 try:
925 if method == 'GET': 967 if method == 'GET':
926 response_object = requests.get(self.api_base_url + endpoint, params = params, headers = headers, files = files, timeout = self.request_timeout) 968 response_object = requests.get(self.api_base_url + endpoint, params=params,
927 969 headers=headers, files=files,
970 timeout=self.request_timeout)
928 if method == 'POST': 971 if method == 'POST':
929 response_object = requests.post(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout) 972 response_object = requests.post(self.api_base_url + endpoint, data=params, headers=headers,
973 files=files, timeout=self.request_timeout)
930 974
931 if method == 'PATCH': 975 if method == 'PATCH':
932 response_object = requests.patch(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout) 976 response_object = requests.patch(self.api_base_url + endpoint, data=params, headers=headers,
977 files=files, timeout=self.request_timeout)
933 978
934 if method == 'DELETE': 979 if method == 'DELETE':
935 response_object = requests.delete(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout) 980 response_object = requests.delete(self.api_base_url + endpoint, data=params, headers=headers,
981 files=files, timeout=self.request_timeout)
936 except Exception as e: 982 except Exception as e:
937 raise MastodonNetworkError("Could not complete request: %s" % e) 983 raise MastodonNetworkError("Could not complete request: %s" % e)
938 984
939 if response_object == None: 985 if response_object is None:
940 raise MastodonIllegalArgumentError("Illegal request.") 986 raise MastodonIllegalArgumentError("Illegal request.")
941 987
942 # Handle response 988 # Handle response
943 if self.debug_requests == True: 989 if self.debug_requests:
944 print('Mastodon: Response received with code ' + str(response_object.status_code) + '.') 990 print('Mastodon: Response received with code ' + str(response_object.status_code) + '.')
945 print('response headers: ' + str(response_object.headers)) 991 print('response headers: ' + str(response_object.headers))
946 print('Response text content: ' + str(response_object.text)) 992 print('Response text content: ' + str(response_object.text))
@@ -954,20 +1000,26 @@ class Mastodon:
954 try: 1000 try:
955 response = response_object.json() 1001 response = response_object.json()
956 except: 1002 except:
957 raise MastodonAPIError("Could not parse response as JSON, response code was %s, bad json content was '%s'" % (response_object.status_code, response_object.content)) 1003 raise MastodonAPIError(
1004 "Could not parse response as JSON, response code was %s, "
1005 "bad json content was '%s'" % (response_object.status_code,
1006 response_object.content))
958 1007
959 # Parse link headers 1008 # Parse link headers
960 if isinstance(response, list) and 'Link' in response_object.headers and response_object.headers['Link'] != "": 1009 if isinstance(response, list) and \
961 tmp_urls = requests.utils.parse_header_links(response_object.headers['Link'].rstrip('>').replace('>,<', ',<')) 1010 'Link' in response_object.headers and \
1011 response_object.headers['Link'] != "":
1012 tmp_urls = requests.utils.parse_header_links(
1013 response_object.headers['Link'].rstrip('>').replace('>,<', ',<'))
962 for url in tmp_urls: 1014 for url in tmp_urls:
963 if not 'rel' in url: 1015 if 'rel' not in url:
964 continue 1016 continue
965 1017
966 if url['rel'] == 'next': 1018 if url['rel'] == 'next':
967 # Be paranoid and extract max_id specifically 1019 # Be paranoid and extract max_id specifically
968 next_url = url['url'] 1020 next_url = url['url']
969 matchgroups = re.search(r"max_id=([0-9]*)", next_url) 1021 matchgroups = re.search(r"max_id=([0-9]*)", next_url)
970 1022
971 if matchgroups: 1023 if matchgroups:
972 next_params = copy.deepcopy(params) 1024 next_params = copy.deepcopy(params)
973 next_params['_pagination_method'] = method 1025 next_params['_pagination_method'] = method
@@ -976,12 +1028,12 @@ class Mastodon:
976 if "since_id" in next_params: 1028 if "since_id" in next_params:
977 del next_params['since_id'] 1029 del next_params['since_id']
978 response[-1]['_pagination_next'] = next_params 1030 response[-1]['_pagination_next'] = next_params
979 1031
980 if url['rel'] == 'prev': 1032 if url['rel'] == 'prev':
981 # Be paranoid and extract since_id specifically 1033 # Be paranoid and extract since_id specifically
982 prev_url = url['url'] 1034 prev_url = url['url']
983 matchgroups = re.search(r"since_id=([0-9]*)", prev_url) 1035 matchgroups = re.search(r"since_id=([0-9]*)", prev_url)
984 1036
985 if matchgroups: 1037 if matchgroups:
986 prev_params = copy.deepcopy(params) 1038 prev_params = copy.deepcopy(params)
987 prev_params['_pagination_method'] = method 1039 prev_params['_pagination_method'] = method
@@ -990,7 +1042,7 @@ class Mastodon:
990 if "max_id" in prev_params: 1042 if "max_id" in prev_params:
991 del prev_params['max_id'] 1043 del prev_params['max_id']
992 response[0]['_pagination_prev'] = prev_params 1044 response[0]['_pagination_prev'] = prev_params
993 1045
994 # Handle rate limiting 1046 # Handle rate limiting
995 if 'X-RateLimit-Remaining' in response_object.headers and do_ratelimiting: 1047 if 'X-RateLimit-Remaining' in response_object.headers and do_ratelimiting:
996 self.ratelimit_remaining = int(response_object.headers['X-RateLimit-Remaining']) 1048 self.ratelimit_remaining = int(response_object.headers['X-RateLimit-Remaining'])
@@ -1024,21 +1076,20 @@ class Mastodon:
1024 1076
1025 return response 1077 return response
1026 1078
1027 def __stream(self, endpoint, listener, params = {}): 1079 def __stream(self, endpoint, listener, params={}):
1028 """ 1080 """
1029 Internal streaming API helper. 1081 Internal streaming API helper.
1030 """ 1082 """
1031 1083
1032 headers = {} 1084 headers = {}
1033 if self.access_token != None: 1085 if self.access_token is not None:
1034 headers = {'Authorization': 'Bearer ' + self.access_token} 1086 headers = {'Authorization': 'Bearer ' + self.access_token}
1035 1087
1036 url = self.api_base_url + endpoint 1088 url = self.api_base_url + endpoint
1037 with closing(requests.get(url, headers = headers, data = params, stream = True)) as r: 1089 with closing(requests.get(url, headers=headers, data=params, stream=True)) as r:
1038 listener.handle_stream(r.iter_lines()) 1090 listener.handle_stream(r.iter_lines())
1039 1091
1040 1092 def __generate_params(self, params, exclude=[]):
1041 def __generate_params(self, params, exclude = []):
1042 """ 1093 """
1043 Internal named-parameters-to-dict helper. 1094 Internal named-parameters-to-dict helper.
1044 1095
@@ -1052,7 +1103,7 @@ class Mastodon:
1052 del params['self'] 1103 del params['self']
1053 param_keys = list(params.keys()) 1104 param_keys = list(params.keys())
1054 for key in param_keys: 1105 for key in param_keys:
1055 if params[key] == None or key in exclude: 1106 if params[key] is None or key in exclude:
1056 del params[key] 1107 del params[key]
1057 1108
1058 param_keys = list(params.keys()) 1109 param_keys = list(params.keys())
@@ -1063,28 +1114,24 @@ class Mastodon:
1063 1114
1064 return params 1115 return params
1065 1116
1066
1067 def __get_token_expired(self): 1117 def __get_token_expired(self):
1068 """Internal helper for oauth code""" 1118 """Internal helper for oauth code"""
1069 if self._token_expired < datetime.datetime.now(): 1119 return self._token_expired < datetime.datetime.now()
1070 return True
1071 else:
1072 return False
1073 1120
1074 def __set_token_expired(self, value): 1121 def __set_token_expired(self, value):
1075 """Internal helper for oauth code""" 1122 """Internal helper for oauth code"""
1076 self._token_expired = datetime.datetime.now() + datetime.timedelta(seconds=value) 1123 self._token_expired = datetime.datetime.now() + datetime.timedelta(seconds=value)
1077 return 1124 return
1078 1125
1079 def __get_refresh_token(self): 1126 def __get_refresh_token(self):
1080 """Internal helper for oauth code""" 1127 """Internal helper for oauth code"""
1081 return self._refresh_token 1128 return self._refresh_token
1082 1129
1083 def __set_refresh_token(self, value): 1130 def __set_refresh_token(self, value):
1084 """Internal helper for oauth code""" 1131 """Internal helper for oauth code"""
1085 self._refresh_token = value 1132 self._refresh_token = value
1086 return 1133 return
1087 1134
1088 @staticmethod 1135 @staticmethod
1089 def __protocolize(base_url): 1136 def __protocolize(base_url):
1090 """Internal add-protocol-to-url helper""" 1137 """Internal add-protocol-to-url helper"""
@@ -1095,21 +1142,25 @@ class Mastodon:
1095 base_url = base_url.rstrip("/") 1142 base_url = base_url.rstrip("/")
1096 return base_url 1143 return base_url
1097 1144
1145
1098## 1146##
1099# Exceptions 1147# Exceptions
1100## 1148##
1101class MastodonIllegalArgumentError(ValueError): 1149class MastodonIllegalArgumentError(ValueError):
1102 pass 1150 pass
1103 1151
1152
1104class MastodonFileNotFoundError(IOError): 1153class MastodonFileNotFoundError(IOError):
1105 pass 1154 pass
1106 1155
1156
1107class MastodonNetworkError(IOError): 1157class MastodonNetworkError(IOError):
1108 pass 1158 pass
1109 1159
1160
1110class MastodonAPIError(Exception): 1161class MastodonAPIError(Exception):
1111 pass 1162 pass
1112 1163
1164
1113class MastodonRatelimitError(Exception): 1165class MastodonRatelimitError(Exception):
1114 pass 1166 pass
1115
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 20c1bf1..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', 'python-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)