diff options
-rw-r--r-- | mastodon/Mastodon.py | 166 |
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 | |||
7 | import time | 7 | import time |
8 | import random | 8 | import random |
9 | import string | 9 | import string |
10 | from datetime import datetime | ||
10 | 11 | ||
11 | class Mastodon: | 12 | class 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 | ## | ||
547 | class MastodonIllegalArgumentError(ValueError): | ||
548 | pass | ||
549 | |||
550 | class MastodonNetworkError(IOError): | ||
551 | pass | ||
552 | |||
553 | class MastodonAPIError(Exception): | ||
554 | pass | ||
555 | |||
556 | class MastodonRatelimitError(Exception): | ||
557 | pass | ||
558 | |||