aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--mastodon/Mastodon.py166
1 files changed, 130 insertions, 36 deletions
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py
index eb13859..bc1c52b 100644
--- a/mastodon/Mastodon.py
+++ b/mastodon/Mastodon.py
@@ -7,6 +7,7 @@ import mimetypes
7import time 7import time
8import random 8import random
9import string 9import string
10from datetime import datetime
10 11
11class Mastodon: 12class Mastodon:
12 """ 13 """
@@ -21,6 +22,7 @@ class Mastodon:
21 """ 22 """
22 __DEFAULT_BASE_URL = 'https://mastodon.social' 23 __DEFAULT_BASE_URL = 'https://mastodon.social'
23 24
25
24 ### 26 ###
25 # Registering apps 27 # Registering apps
26 ### 28 ###
@@ -32,6 +34,9 @@ class Mastodon:
32 Specify redirect_uris if you want users to be redirected to a certain page after authenticating. 34 Specify redirect_uris if you want users to be redirected to a certain page after authenticating.
33 Specify to_file to persist your apps info to a file so you can use them in the constructor. 35 Specify to_file to persist your apps info to a file so you can use them in the constructor.
34 Specify api_base_url if you want to register an app on an instance different from the flagship one. 36 Specify api_base_url if you want to register an app on an instance different from the flagship one.
37
38 Presently, app registration is open by default, but this is not guaranteed to be the case for all
39 future mastodon instances or even the flagship instance in the future.
35 40
36 Returns client_id and client_secret. 41 Returns client_id and client_secret.
37 """ 42 """
@@ -57,13 +62,22 @@ class Mastodon:
57 ### 62 ###
58 # Authentication, including constructor 63 # Authentication, including constructor
59 ### 64 ###
60 def __init__(self, client_id, client_secret = None, access_token = None, api_base_url = __DEFAULT_BASE_URL, debug_requests = False): 65 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 = 0.9):
61 """ 66 """
62 Create a new API wrapper instance based on the given client_secret and client_id. If you 67 Create a new API wrapper instance based on the given client_secret and client_id. If you
63 give a client_id and it is not a file, you must also give a secret. 68 give a client_id and it is not a file, you must also give a secret.
64 69
65 You can also directly specify an access_token, directly or as a file. 70 You can also directly specify an access_token, directly or as a file.
66 71
72 Mastodon.py can try to respect rate limits in several ways, controlled by ratelimit_method.
73 "throw" makes functions throw a MastodonRatelimitError when the rate
74 limit is hit. "wait" mode will, once the limit is hit, wait and retry the request as soon
75 as the rate limit resets, until it succeeds. "pace" works like throw, but tries to wait in
76 between calls so that the limit is generally not hit (How hard it tries to not hit the rate
77 limit can be controlled by ratelimit_pacefactor). The default setting is "wait". Note that
78 even in "wait" and "pace" mode, requests can still fail due to network or other problems! Also
79 note that "pace" and "wait" are NOT thread safe.
80
67 Specify api_base_url if you wish to talk to an instance other than the flagship one. 81 Specify api_base_url if you wish to talk to an instance other than the flagship one.
68 If a file is given as client_id, read client ID and secret from that file 82 If a file is given as client_id, read client ID and secret from that file
69 """ 83 """
@@ -72,6 +86,13 @@ class Mastodon:
72 self.client_secret = client_secret 86 self.client_secret = client_secret
73 self.access_token = access_token 87 self.access_token = access_token
74 self.debug_requests = debug_requests 88 self.debug_requests = debug_requests
89 self.ratelimit_method = ratelimit_method
90
91 self.ratelimit_limit = 150
92 self.ratelimit_reset = time.time()
93 self.ratelimit_remaining = 150
94 self.ratelimit_lastcall = time.time()
95 self.ratelimit_pacefactor = 0.9
75 96
76 if os.path.isfile(self.client_id): 97 if os.path.isfile(self.client_id):
77 with open(self.client_id, 'r') as secret_file: 98 with open(self.client_id, 'r') as secret_file:
@@ -79,7 +100,7 @@ class Mastodon:
79 self.client_secret = secret_file.readline().rstrip() 100 self.client_secret = secret_file.readline().rstrip()
80 else: 101 else:
81 if self.client_secret == None: 102 if self.client_secret == None:
82 raise ValueError('Specified client id directly, but did not supply secret') 103 raise MastodonIllegalArgumentError('Specified client id directly, but did not supply secret')
83 104
84 if self.access_token != None and os.path.isfile(self.access_token): 105 if self.access_token != None and os.path.isfile(self.access_token):
85 with open(self.access_token, 'r') as token_file: 106 with open(self.access_token, 'r') as token_file:
@@ -87,13 +108,15 @@ class Mastodon:
87 108
88 def log_in(self, username, password, scopes = ['read', 'write', 'follow'], to_file = None): 109 def log_in(self, username, password, scopes = ['read', 'write', 'follow'], to_file = None):
89 """ 110 """
90 Log in and set access_token to what was returned. 111 Log in and sets access_token to what was returned. Note that your
91 Can persist access token to file. 112 username is the e-mail you use to log in into mastodon.
92 113
93 Will throw an exception if username / password are wrong, scopes are not 114 Can persist access token to file, to be used in the constructor.
94 valid or granted scopes differ from requested.
95 115
96 Returns the access_token, as well. 116 Will throw a MastodonIllegalArgumentError if username / password
117 are wrong, scopes are not valid or granted scopes differ from requested.
118
119 Returns the access_token.
97 """ 120 """
98 params = self.__generate_params(locals()) 121 params = self.__generate_params(locals())
99 params['client_id'] = self.client_id 122 params['client_id'] = self.client_id
@@ -102,16 +125,16 @@ class Mastodon:
102 params['scope'] = " ".join(scopes) 125 params['scope'] = " ".join(scopes)
103 126
104 try: 127 try:
105 response = self.__api_request('POST', '/oauth/token', params) 128 response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting = False)
106 self.access_token = response['access_token'] 129 self.access_token = response['access_token']
107 except: 130 except:
108 raise ValueError('Invalid user name, password or scopes.') 131 raise MastodonIllegalArgumentError('Invalid user name, password or scopes.')
109 132
110 requested_scopes = " ".join(sorted(scopes)) 133 requested_scopes = " ".join(sorted(scopes))
111 received_scopes = " ".join(sorted(response["scope"].split(" "))) 134 received_scopes = " ".join(sorted(response["scope"].split(" ")))
112 135
113 if requested_scopes != received_scopes: 136 if requested_scopes != received_scopes:
114 raise ValueError('Granted scopes "' + received_scopes + '" differ from requested scopes "' + requested_scopes + '".') 137 raise MastodonAPIError('Granted scopes "' + received_scopes + '" differ from requested scopes "' + requested_scopes + '".')
115 138
116 if to_file != None: 139 if to_file != None:
117 with open(to_file, 'w') as token_file: 140 with open(to_file, 'w') as token_file:
@@ -381,19 +404,18 @@ class Mastodon:
381 type has to be specified manually, otherwise, it is 404 type has to be specified manually, otherwise, it is
382 determined from the file name. 405 determined from the file name.
383 406
384 Throws a ValueError if the mime type of the passed data or file can 407 Throws a MastodonIllegalArgumentError if the mime type of the
385 not be determined properly. 408 passed data or file can not be determined properly.
386 409
387 Returns a media dict. This contains the id that can be used in 410 Returns a media dict. This contains the id that can be used in
388 status_post to attach the media file to a toot. 411 status_post to attach the media file to a toot.
389 """ 412 """
390
391 if os.path.isfile(media_file) and mime_type == None: 413 if os.path.isfile(media_file) and mime_type == None:
392 mime_type = mimetypes.guess_type(media_file)[0] 414 mime_type = mimetypes.guess_type(media_file)[0]
393 media_file = open(media_file, 'rb') 415 media_file = open(media_file, 'rb')
394 416
395 if mime_type == None: 417 if mime_type == None:
396 raise ValueError('Could not determine mime type or data passed directly without mime type.') 418 raise MastodonIllegalArgumentError('Could not determine mime type or data passed directly without mime type.')
397 419
398 random_suffix = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10)) 420 random_suffix = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10))
399 file_name = "mastodonpyupload_" + str(time.time()) + "_" + str(random_suffix) + mimetypes.guess_extension(mime_type) 421 file_name = "mastodonpyupload_" + str(time.time()) + "_" + str(random_suffix) + mimetypes.guess_extension(mime_type)
@@ -404,14 +426,35 @@ class Mastodon:
404 ### 426 ###
405 # Internal helpers, dragons probably 427 # Internal helpers, dragons probably
406 ### 428 ###
407 def __api_request(self, method, endpoint, params = {}, files = {}): 429 def __api_request(self, method, endpoint, params = {}, files = {}, do_ratelimiting = True):
408 """ 430 """
409 Internal API request helper. 431 Internal API request helper.
432
433 TODO FIXME: time.time() does not match server time neccesarily. Using the time from the request
434 would be correct.
435
436 TODO FIXME: Date parsing can fail. Should probably use a proper "date parsing" module rather than
437 rely on the server to return the right thing.
410 """ 438 """
411 response = None 439 response = None
412 headers = None 440 headers = None
413 441
414 442 # "pace" mode ratelimiting: Assume constant rate of requests, sleep a little less long than it
443 # would take to not hit the rate limit at that request rate.
444 if do_ratelimiting and self.ratelimit_method == "pace":
445 if self.ratelimit_remaining == 0:
446 to_next = self.ratelimit_reset - time.time()
447 if to_next > 0:
448 time.sleep(to_next)
449 else:
450 time_waited = time.time() - self.ratelimit_lastcall
451 time_wait = float(self.ratelimit_reset - time.time()) / float(self.ratelimit_remaining)
452 remaining_wait = time_wait - time_waited
453
454 if remaining_wait > 0:
455 time.sleep(remaining_wait * self.ratelimit_pacefactor)
456
457 # Generate request headers
415 if self.access_token != None: 458 if self.access_token != None:
416 headers = {'Authorization': 'Bearer ' + self.access_token} 459 headers = {'Authorization': 'Bearer ' + self.access_token}
417 460
@@ -421,26 +464,61 @@ class Mastodon:
421 print('Headers: ' + str(headers)) 464 print('Headers: ' + str(headers))
422 print('Files: ' + str(files)) 465 print('Files: ' + str(files))
423 466
424 if method == 'GET': 467 # Make request
425 response = requests.get(self.api_base_url + endpoint, data = params, headers = headers, files = files) 468 request_complete = False
426 469 while not request_complete:
427 if method == 'POST': 470 request_complete = True
428 response = requests.post(self.api_base_url + endpoint, data = params, headers = headers, files = files)
429 471
430 if method == 'DELETE': 472 response_object = None
431 response = requests.delete(self.api_base_url + endpoint, data = params, headers = headers, files = files) 473 try:
432 474 if method == 'GET':
433 if response.status_code == 404: 475 response_object = requests.get(self.api_base_url + endpoint, data = params, headers = headers, files = files)
434 raise IOError('Endpoint not found.') 476
435 477 if method == 'POST':
436 if response.status_code == 500: 478 response_object = requests.post(self.api_base_url + endpoint, data = params, headers = headers, files = files)
437 raise IOError('General API problem.') 479
438 480 if method == 'DELETE':
439 try: 481 response_object = requests.delete(self.api_base_url + endpoint, data = params, headers = headers, files = files)
440 response = response.json() 482 except:
441 except: 483 raise MastodonNetworkError("Could not complete request.")
442 raise ValueError("Could not parse response as JSON, respose code was " + str(response.status_code)) 484
443 485 if response_object == None:
486 raise MastodonIllegalArgumentError("Illegal request.")
487
488 # Handle response
489 if self.debug_requests == True:
490 print('Mastodon: Response received with code ' + str(response_object.status_code) + '.')
491 print('Respose headers: ' + str(response_object.headers))
492 print('Response text content: ' + str(response_object.text))
493
494 if response_object.status_code == 404:
495 raise MastodonAPIError('Endpoint not found.')
496
497 if response_object.status_code == 500:
498 raise MastodonAPIError('General API problem.')
499
500 try:
501 response = response_object.json()
502 except:
503 raise MastodonAPIError("Could not parse response as JSON, respose code was " + str(response_object.status_code))
504
505 # Handle rate limiting
506 if 'X-RateLimit-Remaining' in response_object.headers and do_ratelimiting:
507 self.ratelimit_remaining = int(response_object.headers['X-RateLimit-Remaining'])
508 self.ratelimit_limit = int(response_object.headers['X-RateLimit-Limit'])
509 self.ratelimit_reset = (datetime.strptime(response_object.headers['X-RateLimit-Reset'], "%Y-%m-%dT%H:%M:%S.%fZ") - datetime(1970, 1, 1)).total_seconds()
510 self.ratelimit_lastcall = time.time()
511
512 if "error" in response and response["error"] == "Throttled":
513 if self.ratelimit_method == "throw":
514 raise MastodonRatelimitError("Hit rate limit.")
515
516 if self.ratelimit_method == "wait" or self.ratelimit_method == "pace":
517 to_next = self.ratelimit_reset - time.time()
518 if to_next > 0:
519 time.sleep(to_next)
520 request_complete = False
521
444 return response 522 return response
445 523
446 def __generate_params(self, params, exclude = []): 524 def __generate_params(self, params, exclude = []):
@@ -462,3 +540,19 @@ class Mastodon:
462 del params[key] 540 del params[key]
463 541
464 return params 542 return params
543
544##
545# Exceptions
546##
547class MastodonIllegalArgumentError(ValueError):
548 pass
549
550class MastodonNetworkError(IOError):
551 pass
552
553class MastodonAPIError(Exception):
554 pass
555
556class MastodonRatelimitError(Exception):
557 pass
558
Powered by cgit v1.2.3 (git 2.41.0)