diff options
-rw-r--r-- | mastodon/Mastodon.py | 230 |
1 files changed, 124 insertions, 106 deletions
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index 7cbdcc4..a50c5ab 100644 --- a/mastodon/Mastodon.py +++ b/mastodon/Mastodon.py | |||
@@ -13,59 +13,59 @@ import dateutil | |||
13 | import dateutil.parser | 13 | import dateutil.parser |
14 | 14 | ||
15 | class Mastodon: | 15 | class Mastodon: |
16 | """ | 16 | """ |
17 | Super basic but thorough and easy to use mastodon.social | 17 | Super basic but thorough and easy to use mastodon.social |
18 | api wrapper in python. | 18 | api wrapper in python. |
19 | 19 | ||
20 | If anything is unclear, check the official API docs at | 20 | If anything is unclear, check the official API docs at |
21 | https://github.com/Gargron/mastodon/wiki/API | 21 | https://github.com/Gargron/mastodon/wiki/API |
22 | 22 | ||
23 | Presently, only username-password login is supported, somebody please | 23 | Presently, only username-password login is supported, somebody please |
24 | patch in Real Proper OAuth if desired. | 24 | patch in Real Proper OAuth if desired. |
25 | """ | 25 | """ |
26 | __DEFAULT_BASE_URL = 'https://mastodon.social' | 26 | __DEFAULT_BASE_URL = 'https://mastodon.social' |
27 | __DEFAULT_TIMEOUT = 300 | 27 | __DEFAULT_TIMEOUT = 300 |
28 | 28 | ||
29 | 29 | ||
30 | ### | 30 | ### |
31 | # Registering apps | 31 | # Registering apps |
32 | ### | 32 | ### |
33 | @staticmethod | 33 | @staticmethod |
34 | def create_app(client_name, scopes = ['read', 'write', 'follow'], redirect_uris = None, to_file = None, api_base_url = __DEFAULT_BASE_URL): | 34 | def create_app(client_name, scopes = ['read', 'write', 'follow'], redirect_uris = None, to_file = None, api_base_url = __DEFAULT_BASE_URL): |
35 | """ | 35 | """ |
36 | Create a new app with given client_name and scopes (read, write, follow) | 36 | Create a new app with given client_name and scopes (read, write, follow) |
37 | 37 | ||
38 | Specify redirect_uris if you want users to be redirected to a certain page after authenticating. | 38 | Specify redirect_uris if you want users to be redirected to a certain page after authenticating. |
39 | Specify to_file to persist your apps info to a file so you can use them in the constructor. | 39 | Specify to_file to persist your apps info to a file so you can use them in the constructor. |
40 | Specify api_base_url if you want to register an app on an instance different from the flagship one. | 40 | Specify api_base_url if you want to register an app on an instance different from the flagship one. |
41 | 41 | ||
42 | Presently, app registration is open by default, but this is not guaranteed to be the case for all | 42 | Presently, app registration is open by default, but this is not guaranteed to be the case for all |
43 | future mastodon instances or even the flagship instance in the future. | 43 | future mastodon instances or even the flagship instance in the future. |
44 | 44 | ||
45 | Returns client_id and client_secret. | 45 | Returns client_id and client_secret. |
46 | """ | 46 | """ |
47 | request_data = { | 47 | request_data = { |
48 | 'client_name': client_name, | 48 | 'client_name': client_name, |
49 | 'scopes': " ".join(scopes) | 49 | 'scopes': " ".join(scopes) |
50 | } | 50 | } |
51 | 51 | ||
52 | try: | 52 | try: |
53 | if redirect_uris != None: | 53 | if redirect_uris != None: |
54 | request_data['redirect_uris'] = redirect_uris; | 54 | request_data['redirect_uris'] = redirect_uris; |
55 | else: | 55 | else: |
56 | request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'; | 56 | request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'; |
57 | 57 | ||
58 | response = requests.post(api_base_url + '/api/v1/apps', data = request_data, timeout = self.request_timeout).json() | 58 | response = requests.post(api_base_url + '/api/v1/apps', data = request_data, timeout = self.request_timeout).json() |
59 | except: | 59 | except: |
60 | raise MastodonNetworkError("Could not complete request.") | 60 | raise MastodonNetworkError("Could not complete request.") |
61 | 61 | ||
62 | if to_file != None: | 62 | if to_file != None: |
63 | with open(to_file, 'w') as secret_file: | 63 | with open(to_file, 'w') as secret_file: |
64 | secret_file.write(response['client_id'] + '\n') | 64 | secret_file.write(response['client_id'] + '\n') |
65 | secret_file.write(response['client_secret'] + '\n') | 65 | secret_file.write(response['client_secret'] + '\n') |
66 | 66 | ||
67 | return (response['client_id'], response['client_secret']) | 67 | return (response['client_id'], response['client_secret']) |
68 | 68 | ||
69 | ### | 69 | ### |
70 | # Authentication, including constructor | 70 | # Authentication, including constructor |
71 | ### | 71 | ### |
@@ -73,42 +73,42 @@ class Mastodon: | |||
73 | """ | 73 | """ |
74 | Create a new API wrapper instance based on the given client_secret and client_id. If you | 74 | Create a new API wrapper instance based on the given client_secret and client_id. If you |
75 | give a client_id and it is not a file, you must also give a secret. | 75 | give a client_id and it is not a file, you must also give a secret. |
76 | 76 | ||
77 | You can also specify an access_token, directly or as a file (as written by log_in). | 77 | You can also specify an access_token, directly or as a file (as written by log_in). |
78 | 78 | ||
79 | Mastodon.py can try to respect rate limits in several ways, controlled by ratelimit_method. | 79 | Mastodon.py can try to respect rate limits in several ways, controlled by ratelimit_method. |
80 | "throw" makes functions throw a MastodonRatelimitError when the rate | 80 | "throw" makes functions throw a MastodonRatelimitError when the rate |
81 | limit is hit. "wait" mode will, once the limit is hit, wait and retry the request as soon | 81 | limit is hit. "wait" mode will, once the limit is hit, wait and retry the request as soon |
82 | as the rate limit resets, until it succeeds. "pace" works like throw, but tries to wait in | 82 | as the rate limit resets, until it succeeds. "pace" works like throw, but tries to wait in |
83 | between calls so that the limit is generally not hit (How hard it tries to not hit the rate | 83 | between calls so that the limit is generally not hit (How hard it tries to not hit the rate |
84 | limit can be controlled by ratelimit_pacefactor). The default setting is "wait". Note that | 84 | limit can be controlled by ratelimit_pacefactor). The default setting is "wait". Note that |
85 | even in "wait" and "pace" mode, requests can still fail due to network or other problems! Also | 85 | even in "wait" and "pace" mode, requests can still fail due to network or other problems! Also |
86 | note that "pace" and "wait" are NOT thread safe. | 86 | note that "pace" and "wait" are NOT thread safe. |
87 | 87 | ||
88 | Specify api_base_url if you wish to talk to an instance other than the flagship one. | 88 | Specify api_base_url if you wish to talk to an instance other than the flagship one. |
89 | If a file is given as client_id, read client ID and secret from that file. | 89 | If a file is given as client_id, read client ID and secret from that file. |
90 | 90 | ||
91 | By defautl, a timeout of 300 seconds is used for all requests. If you wish to change this, | 91 | By defautl, a timeout of 300 seconds is used for all requests. If you wish to change this, |
92 | pass the desired timeout (in seconds) as request_timeout. | 92 | pass the desired timeout (in seconds) as request_timeout. |
93 | """ | 93 | """ |
94 | self.api_base_url = api_base_url | 94 | self.api_base_url = api_base_url |
95 | self.client_id = client_id | 95 | self.client_id = client_id |
96 | self.client_secret = client_secret | 96 | self.client_secret = client_secret |
97 | self.access_token = access_token | 97 | self.access_token = access_token |
98 | self.debug_requests = debug_requests | 98 | self.debug_requests = debug_requests |
99 | self.ratelimit_method = ratelimit_method | 99 | self.ratelimit_method = ratelimit_method |
100 | 100 | ||
101 | self.ratelimit_limit = 150 | 101 | self.ratelimit_limit = 150 |
102 | self.ratelimit_reset = time.time() | 102 | self.ratelimit_reset = time.time() |
103 | self.ratelimit_remaining = 150 | 103 | self.ratelimit_remaining = 150 |
104 | self.ratelimit_lastcall = time.time() | 104 | self.ratelimit_lastcall = time.time() |
105 | self.ratelimit_pacefactor = ratelimit_pacefactor | 105 | self.ratelimit_pacefactor = ratelimit_pacefactor |
106 | 106 | ||
107 | self.request_timeout = request_timeout | 107 | self.request_timeout = request_timeout |
108 | 108 | ||
109 | if not ratelimit_method in ["throw", "wait", "pace"]: | 109 | if not ratelimit_method in ["throw", "wait", "pace"]: |
110 | raise MastodonIllegalArgumentError("Invalid ratelimit method.") | 110 | raise MastodonIllegalArgumentError("Invalid ratelimit method.") |
111 | 111 | ||
112 | if os.path.isfile(self.client_id): | 112 | if os.path.isfile(self.client_id): |
113 | with open(self.client_id, 'r') as secret_file: | 113 | with open(self.client_id, 'r') as secret_file: |
114 | self.client_id = secret_file.readline().rstrip() | 114 | self.client_id = secret_file.readline().rstrip() |
@@ -116,21 +116,21 @@ class Mastodon: | |||
116 | else: | 116 | else: |
117 | if self.client_secret == None: | 117 | if self.client_secret == None: |
118 | raise MastodonIllegalArgumentError('Specified client id directly, but did not supply secret') | 118 | raise MastodonIllegalArgumentError('Specified client id directly, but did not supply secret') |
119 | 119 | ||
120 | if self.access_token != None and os.path.isfile(self.access_token): | 120 | if self.access_token != None and os.path.isfile(self.access_token): |
121 | with open(self.access_token, 'r') as token_file: | 121 | with open(self.access_token, 'r') as token_file: |
122 | self.access_token = token_file.readline().rstrip() | 122 | self.access_token = token_file.readline().rstrip() |
123 | 123 | ||
124 | def log_in(self, username, password, scopes = ['read', 'write', 'follow'], to_file = None): | 124 | def log_in(self, username, password, scopes = ['read', 'write', 'follow'], to_file = None): |
125 | """ | 125 | """ |
126 | Log in and sets access_token to what was returned. Note that your | 126 | Log in and sets access_token to what was returned. Note that your |
127 | username is the e-mail you use to log in into mastodon. | 127 | username is the e-mail you use to log in into mastodon. |
128 | 128 | ||
129 | Can persist access token to file, to be used in the constructor. | 129 | Can persist access token to file, to be used in the constructor. |
130 | 130 | ||
131 | Will throw a MastodonIllegalArgumentError if username / password | 131 | Will throw a MastodonIllegalArgumentError if username / password |
132 | are wrong, scopes are not valid or granted scopes differ from requested. | 132 | are wrong, scopes are not valid or granted scopes differ from requested. |
133 | 133 | ||
134 | Returns the access_token. | 134 | Returns the access_token. |
135 | """ | 135 | """ |
136 | params = self.__generate_params(locals()) | 136 | params = self.__generate_params(locals()) |
@@ -138,25 +138,25 @@ class Mastodon: | |||
138 | params['client_secret'] = self.client_secret | 138 | params['client_secret'] = self.client_secret |
139 | params['grant_type'] = 'password' | 139 | params['grant_type'] = 'password' |
140 | params['scope'] = " ".join(scopes) | 140 | params['scope'] = " ".join(scopes) |
141 | 141 | ||
142 | try: | 142 | try: |
143 | response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting = False) | 143 | response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting = False) |
144 | self.access_token = response['access_token'] | 144 | self.access_token = response['access_token'] |
145 | except: | 145 | except: |
146 | raise MastodonIllegalArgumentError('Invalid user name, password or scopes.') | 146 | raise MastodonIllegalArgumentError('Invalid user name, password or scopes.') |
147 | 147 | ||
148 | requested_scopes = " ".join(sorted(scopes)) | 148 | requested_scopes = " ".join(sorted(scopes)) |
149 | received_scopes = " ".join(sorted(response["scope"].split(" "))) | 149 | received_scopes = " ".join(sorted(response["scope"].split(" "))) |
150 | 150 | ||
151 | if requested_scopes != received_scopes: | 151 | if requested_scopes != received_scopes: |
152 | raise MastodonAPIError('Granted scopes "' + received_scopes + '" differ from requested scopes "' + requested_scopes + '".') | 152 | raise MastodonAPIError('Granted scopes "' + received_scopes + '" differ from requested scopes "' + requested_scopes + '".') |
153 | 153 | ||
154 | if to_file != None: | 154 | if to_file != None: |
155 | with open(to_file, 'w') as token_file: | 155 | with open(to_file, 'w') as token_file: |
156 | token_file.write(response['access_token'] + '\n') | 156 | token_file.write(response['access_token'] + '\n') |
157 | 157 | ||
158 | return response['access_token'] | 158 | return response['access_token'] |
159 | 159 | ||
160 | ### | 160 | ### |
161 | # Reading data: Timelines | 161 | # Reading data: Timelines |
162 | ## | 162 | ## |
@@ -164,14 +164,14 @@ class Mastodon: | |||
164 | """ | 164 | """ |
165 | Fetch statuses, most recent ones first. Timeline can be home, mentions, public | 165 | Fetch statuses, most recent ones first. Timeline can be home, mentions, public |
166 | or tag/hashtag. See the following functions documentation for what those do. | 166 | or tag/hashtag. See the following functions documentation for what those do. |
167 | 167 | ||
168 | The default timeline is the "home" timeline. | 168 | The default timeline is the "home" timeline. |
169 | 169 | ||
170 | Returns a list of toot dicts. | 170 | Returns a list of toot dicts. |
171 | """ | 171 | """ |
172 | params = self.__generate_params(locals(), ['timeline']) | 172 | params = self.__generate_params(locals(), ['timeline']) |
173 | return self.__api_request('GET', '/api/v1/timelines/' + timeline, params) | 173 | return self.__api_request('GET', '/api/v1/timelines/' + timeline, params) |
174 | 174 | ||
175 | def timeline_home(self, max_id = None, since_id = None, limit = None): | 175 | def timeline_home(self, max_id = None, since_id = None, limit = None): |
176 | """ | 176 | """ |
177 | Fetch the authenticated users home timeline (i.e. followed users and self). | 177 | Fetch the authenticated users home timeline (i.e. followed users and self). |
@@ -179,7 +179,7 @@ class Mastodon: | |||
179 | Returns a list of toot dicts. | 179 | Returns a list of toot dicts. |
180 | """ | 180 | """ |
181 | return self.timeline('home', max_id = max_id, since_id = since_id, limit = limit) | 181 | return self.timeline('home', max_id = max_id, since_id = since_id, limit = limit) |
182 | 182 | ||
183 | def timeline_mentions(self, max_id = None, since_id = None, limit = None): | 183 | def timeline_mentions(self, max_id = None, since_id = None, limit = None): |
184 | """ | 184 | """ |
185 | Fetches the authenticated users mentions. | 185 | Fetches the authenticated users mentions. |
@@ -187,7 +187,7 @@ class Mastodon: | |||
187 | Returns a list of toot dicts. | 187 | Returns a list of toot dicts. |
188 | """ | 188 | """ |
189 | return self.timeline('mentions', max_id = max_id, since_id = since_id, limit = limit) | 189 | return self.timeline('mentions', max_id = max_id, since_id = since_id, limit = limit) |
190 | 190 | ||
191 | def timeline_public(self, max_id = None, since_id = None, limit = None): | 191 | def timeline_public(self, max_id = None, since_id = None, limit = None): |
192 | """ | 192 | """ |
193 | Fetches the public / visible-network timeline. | 193 | Fetches the public / visible-network timeline. |
@@ -195,7 +195,7 @@ class Mastodon: | |||
195 | Returns a list of toot dicts. | 195 | Returns a list of toot dicts. |
196 | """ | 196 | """ |
197 | return self.timeline('public', max_id = max_id, since_id = since_id, limit = limit) | 197 | return self.timeline('public', max_id = max_id, since_id = since_id, limit = limit) |
198 | 198 | ||
199 | def timeline_hashtag(self, hashtag, max_id = None, since_id = None, limit = None): | 199 | def timeline_hashtag(self, hashtag, max_id = None, since_id = None, limit = None): |
200 | """ | 200 | """ |
201 | Fetch a timeline of toots with a given hashtag. | 201 | Fetch a timeline of toots with a given hashtag. |
@@ -203,7 +203,7 @@ class Mastodon: | |||
203 | Returns a list of toot dicts. | 203 | Returns a list of toot dicts. |
204 | """ | 204 | """ |
205 | return self.timeline('tag/' + str(hashtag), max_id = max_id, since_id = since_id, limit = limit) | 205 | return self.timeline('tag/' + str(hashtag), max_id = max_id, since_id = since_id, limit = limit) |
206 | 206 | ||
207 | ### | 207 | ### |
208 | # Reading data: Statuses | 208 | # Reading data: Statuses |
209 | ### | 209 | ### |
@@ -222,7 +222,7 @@ class Mastodon: | |||
222 | Returns a context dict. | 222 | Returns a context dict. |
223 | """ | 223 | """ |
224 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/context') | 224 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/context') |
225 | 225 | ||
226 | def status_reblogged_by(self, id): | 226 | def status_reblogged_by(self, id): |
227 | """ | 227 | """ |
228 | Fetch a list of users that have reblogged a status. | 228 | Fetch a list of users that have reblogged a status. |
@@ -230,7 +230,7 @@ class Mastodon: | |||
230 | Returns a list of user dicts. | 230 | Returns a list of user dicts. |
231 | """ | 231 | """ |
232 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/reblogged_by') | 232 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/reblogged_by') |
233 | 233 | ||
234 | def status_favourited_by(self, id): | 234 | def status_favourited_by(self, id): |
235 | """ | 235 | """ |
236 | Fetch a list of users that have favourited a status. | 236 | Fetch a list of users that have favourited a status. |
@@ -238,7 +238,7 @@ class Mastodon: | |||
238 | Returns a list of user dicts. | 238 | Returns a list of user dicts. |
239 | """ | 239 | """ |
240 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/favourited_by') | 240 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/favourited_by') |
241 | 241 | ||
242 | ### | 242 | ### |
243 | # Reading data: Notifications | 243 | # Reading data: Notifications |
244 | ### | 244 | ### |
@@ -250,7 +250,7 @@ class Mastodon: | |||
250 | Returns a list of notification dicts. | 250 | Returns a list of notification dicts. |
251 | """ | 251 | """ |
252 | return self.__api_request('GET', '/api/v1/notifications') | 252 | return self.__api_request('GET', '/api/v1/notifications') |
253 | 253 | ||
254 | ### | 254 | ### |
255 | # Reading data: Accounts | 255 | # Reading data: Accounts |
256 | ### | 256 | ### |
@@ -269,7 +269,7 @@ class Mastodon: | |||
269 | Returns a user dict. | 269 | Returns a user dict. |
270 | """ | 270 | """ |
271 | return self.__api_request('GET', '/api/v1/accounts/verify_credentials') | 271 | return self.__api_request('GET', '/api/v1/accounts/verify_credentials') |
272 | 272 | ||
273 | def account_statuses(self, id, max_id = None, since_id = None, limit = None): | 273 | def account_statuses(self, id, max_id = None, since_id = None, limit = None): |
274 | """ | 274 | """ |
275 | Fetch statuses by user id. Same options as timeline are permitted. | 275 | Fetch statuses by user id. Same options as timeline are permitted. |
@@ -297,7 +297,7 @@ class Mastodon: | |||
297 | 297 | ||
298 | def account_relationships(self, id): | 298 | def account_relationships(self, id): |
299 | """ | 299 | """ |
300 | Fetch relationships (following, followed_by, blocking) of the logged in user to | 300 | Fetch relationships (following, followed_by, blocking) of the logged in user to |
301 | a given account. id can be a list. | 301 | a given account. id can be a list. |
302 | 302 | ||
303 | Returns a list of relationship dicts. | 303 | Returns a list of relationship dicts. |
@@ -307,28 +307,46 @@ class Mastodon: | |||
307 | 307 | ||
308 | def account_search(self, q, limit = None): | 308 | def account_search(self, q, limit = None): |
309 | """ | 309 | """ |
310 | Fetch matching accounts. Will lookup an account remotely if the search term is | 310 | Fetch matching accounts. Will lookup an account remotely if the search term is |
311 | in the username@domain format and not yet in the database. | 311 | in the username@domain format and not yet in the database. |
312 | 312 | ||
313 | Returns a list of user dicts. | 313 | Returns a list of user dicts. |
314 | """ | 314 | """ |
315 | params = self.__generate_params(locals()) | 315 | params = self.__generate_params(locals()) |
316 | return self.__api_request('GET', '/api/v1/accounts/search', params) | 316 | return self.__api_request('GET', '/api/v1/accounts/search', params) |
317 | 317 | ||
318 | ### | 318 | ### |
319 | # Writing data: Statuses | 319 | # Writing data: Statuses |
320 | ### | 320 | ### |
321 | def status_post(self, status, in_reply_to_id = None, media_ids = None): | 321 | def status_post(self, status, in_reply_to_id = None, media_ids = None, sensitive = False, visibility = ''): |
322 | """ | 322 | """ |
323 | Post a status. Can optionally be in reply to another status and contain | 323 | Post a status. Can optionally be in reply to another status and contain |
324 | up to four pieces of media (Uploaded via media_post()). media_ids can | 324 | up to four pieces of media (Uploaded via media_post()). media_ids can |
325 | also be the media dicts returned by media_post - they are unpacked | 325 | also be the media dicts returned by media_post - they are unpacked |
326 | automatically. | 326 | automatically. |
327 | 327 | ||
328 | The 'sensitive' boolean decides whether or not media attached to the post | ||
329 | should be marked as sensitive, which hides it by default on the Mastodon | ||
330 | web front-end. | ||
331 | |||
332 | The visibility parameter is a string value and matches the visibility | ||
333 | option on the /api/v1/status POST API endpoint. It accepts any of: | ||
334 | 'private' - post will be visible only to followers | ||
335 | 'unlisted' - post will be public but not appear on the public timeline | ||
336 | 'public' - post will be public | ||
337 | |||
338 | If not passed in, visibility defaults to match the current account's | ||
339 | privacy setting (private if the account is locked, public otherwise). | ||
340 | |||
328 | Returns a toot dict with the new status. | 341 | Returns a toot dict with the new status. |
329 | """ | 342 | """ |
330 | params_initial = locals() | 343 | params_initial = locals() |
331 | 344 | ||
345 | # Validate visibility parameter | ||
346 | valid_visibilities = ['private', 'public', 'unlisted', ''] | ||
347 | if params_initial['visibility'].lower() not in valid_visibilities: | ||
348 | raise ValueError('Invalid visibility value! Acceptable values are %s' % valid_visibilities) | ||
349 | |||
332 | if media_ids != None: | 350 | if media_ids != None: |
333 | try: | 351 | try: |
334 | media_ids_proper = [] | 352 | media_ids_proper = [] |
@@ -339,12 +357,12 @@ class Mastodon: | |||
339 | media_ids_proper.append(media_id) | 357 | media_ids_proper.append(media_id) |
340 | except: | 358 | except: |
341 | raise MastodonIllegalArgumentError("Invalid media dict.") | 359 | raise MastodonIllegalArgumentError("Invalid media dict.") |
342 | 360 | ||
343 | params_initial["media_ids"] = media_ids_proper | 361 | params_initial["media_ids"] = media_ids_proper |
344 | 362 | ||
345 | params = self.__generate_params(params_initial) | 363 | params = self.__generate_params(params_initial) |
346 | return self.__api_request('POST', '/api/v1/statuses', params) | 364 | return self.__api_request('POST', '/api/v1/statuses', params) |
347 | 365 | ||
348 | def toot(self, status): | 366 | def toot(self, status): |
349 | """ | 367 | """ |
350 | Synonym for status_post that only takes the status text as input. | 368 | Synonym for status_post that only takes the status text as input. |
@@ -352,7 +370,7 @@ class Mastodon: | |||
352 | Returns a toot dict with the new status. | 370 | Returns a toot dict with the new status. |
353 | """ | 371 | """ |
354 | return self.status_post(status) | 372 | return self.status_post(status) |
355 | 373 | ||
356 | def status_delete(self, id): | 374 | def status_delete(self, id): |
357 | """ | 375 | """ |
358 | Delete a status | 376 | Delete a status |
@@ -363,7 +381,7 @@ class Mastodon: | |||
363 | 381 | ||
364 | def status_reblog(self, id): | 382 | def status_reblog(self, id): |
365 | """Reblog a status. | 383 | """Reblog a status. |
366 | 384 | ||
367 | Returns a toot with with a new status that wraps around the reblogged one. | 385 | Returns a toot with with a new status that wraps around the reblogged one. |
368 | """ | 386 | """ |
369 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/reblog") | 387 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/reblog") |
@@ -371,7 +389,7 @@ class Mastodon: | |||
371 | def status_unreblog(self, id): | 389 | def status_unreblog(self, id): |
372 | """ | 390 | """ |
373 | Un-reblog a status. | 391 | Un-reblog a status. |
374 | 392 | ||
375 | Returns a toot dict with the status that used to be reblogged. | 393 | Returns a toot dict with the status that used to be reblogged. |
376 | """ | 394 | """ |
377 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unreblog") | 395 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unreblog") |
@@ -379,49 +397,49 @@ class Mastodon: | |||
379 | def status_favourite(self, id): | 397 | def status_favourite(self, id): |
380 | """ | 398 | """ |
381 | Favourite a status. | 399 | Favourite a status. |
382 | 400 | ||
383 | Returns a toot dict with the favourited status. | 401 | Returns a toot dict with the favourited status. |
384 | """ | 402 | """ |
385 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/favourite") | 403 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/favourite") |
386 | 404 | ||
387 | def status_unfavourite(self, id): | 405 | def status_unfavourite(self, id): |
388 | """Favourite a status. | 406 | """Favourite a status. |
389 | 407 | ||
390 | Returns a toot dict with the un-favourited status. | 408 | Returns a toot dict with the un-favourited status. |
391 | """ | 409 | """ |
392 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unfavourite") | 410 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unfavourite") |
393 | 411 | ||
394 | ### | 412 | ### |
395 | # Writing data: Accounts | 413 | # Writing data: Accounts |
396 | ### | 414 | ### |
397 | def account_follow(self, id): | 415 | def account_follow(self, id): |
398 | """ | 416 | """ |
399 | Follow a user. | 417 | Follow a user. |
400 | 418 | ||
401 | Returns a relationship dict containing the updated relationship to the user. | 419 | Returns a relationship dict containing the updated relationship to the user. |
402 | """ | 420 | """ |
403 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/follow") | 421 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/follow") |
404 | 422 | ||
405 | def account_unfollow(self, id): | 423 | def account_unfollow(self, id): |
406 | """ | 424 | """ |
407 | Unfollow a user. | 425 | Unfollow a user. |
408 | 426 | ||
409 | Returns a relationship dict containing the updated relationship to the user. | 427 | Returns a relationship dict containing the updated relationship to the user. |
410 | """ | 428 | """ |
411 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unfollow") | 429 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unfollow") |
412 | 430 | ||
413 | def account_block(self, id): | 431 | def account_block(self, id): |
414 | """ | 432 | """ |
415 | Block a user. | 433 | Block a user. |
416 | 434 | ||
417 | Returns a relationship dict containing the updated relationship to the user. | 435 | Returns a relationship dict containing the updated relationship to the user. |
418 | """ | 436 | """ |
419 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/block") | 437 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/block") |
420 | 438 | ||
421 | def account_unblock(self, id): | 439 | def account_unblock(self, id): |
422 | """ | 440 | """ |
423 | Unblock a user. | 441 | Unblock a user. |
424 | 442 | ||
425 | Returns a relationship dict containing the updated relationship to the user. | 443 | Returns a relationship dict containing the updated relationship to the user. |
426 | """ | 444 | """ |
427 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unblock") | 445 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unblock") |
@@ -435,8 +453,8 @@ class Mastodon: | |||
435 | a file name. If image data is passed directly, the mime | 453 | a file name. If image data is passed directly, the mime |
436 | type has to be specified manually, otherwise, it is | 454 | type has to be specified manually, otherwise, it is |
437 | determined from the file name. | 455 | determined from the file name. |
438 | 456 | ||
439 | Throws a MastodonIllegalArgumentError if the mime type of the | 457 | Throws a MastodonIllegalArgumentError if the mime type of the |
440 | passed data or file can not be determined properly. | 458 | passed data or file can not be determined properly. |
441 | 459 | ||
442 | Returns a media dict. This contains the id that can be used in | 460 | Returns a media dict. This contains the id that can be used in |
@@ -445,16 +463,16 @@ class Mastodon: | |||
445 | if os.path.isfile(media_file) and mime_type == None: | 463 | if os.path.isfile(media_file) and mime_type == None: |
446 | mime_type = mimetypes.guess_type(media_file)[0] | 464 | mime_type = mimetypes.guess_type(media_file)[0] |
447 | media_file = open(media_file, 'rb') | 465 | media_file = open(media_file, 'rb') |
448 | 466 | ||
449 | if mime_type == None: | 467 | if mime_type == None: |
450 | raise MastodonIllegalArgumentError('Could not determine mime type or data passed directly without mime type.') | 468 | raise MastodonIllegalArgumentError('Could not determine mime type or data passed directly without mime type.') |
451 | 469 | ||
452 | random_suffix = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10)) | 470 | random_suffix = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10)) |
453 | file_name = "mastodonpyupload_" + str(time.time()) + "_" + str(random_suffix) + mimetypes.guess_extension(mime_type) | 471 | file_name = "mastodonpyupload_" + str(time.time()) + "_" + str(random_suffix) + mimetypes.guess_extension(mime_type) |
454 | 472 | ||
455 | media_file_description = (file_name, media_file, mime_type) | 473 | media_file_description = (file_name, media_file, mime_type) |
456 | return self.__api_request('POST', '/api/v1/media', files = {'file': media_file_description}) | 474 | return self.__api_request('POST', '/api/v1/media', files = {'file': media_file_description}) |
457 | 475 | ||
458 | ### | 476 | ### |
459 | # Internal helpers, dragons probably | 477 | # Internal helpers, dragons probably |
460 | ### | 478 | ### |
@@ -462,7 +480,7 @@ class Mastodon: | |||
462 | """ | 480 | """ |
463 | Converts a python datetime to unix epoch, accounting for | 481 | Converts a python datetime to unix epoch, accounting for |
464 | time zones and such. | 482 | time zones and such. |
465 | 483 | ||
466 | Assumes UTC if timezone is not given. | 484 | Assumes UTC if timezone is not given. |
467 | """ | 485 | """ |
468 | date_time_utc = None | 486 | date_time_utc = None |
@@ -470,18 +488,18 @@ class Mastodon: | |||
470 | date_time_utc = date_time.replace(tzinfo = pytz.utc) | 488 | date_time_utc = date_time.replace(tzinfo = pytz.utc) |
471 | else: | 489 | else: |
472 | date_time_utc = date_time.astimezone(pytz.utc) | 490 | date_time_utc = date_time.astimezone(pytz.utc) |
473 | 491 | ||
474 | epoch_utc = datetime.datetime.utcfromtimestamp(0).replace(tzinfo = pytz.utc) | 492 | epoch_utc = datetime.datetime.utcfromtimestamp(0).replace(tzinfo = pytz.utc) |
475 | 493 | ||
476 | return (date_time_utc - epoch_utc).total_seconds() | 494 | return (date_time_utc - epoch_utc).total_seconds() |
477 | 495 | ||
478 | def __api_request(self, method, endpoint, params = {}, files = {}, do_ratelimiting = True): | 496 | def __api_request(self, method, endpoint, params = {}, files = {}, do_ratelimiting = True): |
479 | """ | 497 | """ |
480 | Internal API request helper. | 498 | Internal API request helper. |
481 | """ | 499 | """ |
482 | response = None | 500 | response = None |
483 | headers = None | 501 | headers = None |
484 | 502 | ||
485 | # "pace" mode ratelimiting: Assume constant rate of requests, sleep a little less long than it | 503 | # "pace" mode ratelimiting: Assume constant rate of requests, sleep a little less long than it |
486 | # would take to not hit the rate limit at that request rate. | 504 | # would take to not hit the rate limit at that request rate. |
487 | if do_ratelimiting and self.ratelimit_method == "pace": | 505 | if do_ratelimiting and self.ratelimit_method == "pace": |
@@ -495,16 +513,16 @@ class Mastodon: | |||
495 | time_waited = time.time() - self.ratelimit_lastcall | 513 | time_waited = time.time() - self.ratelimit_lastcall |
496 | time_wait = float(self.ratelimit_reset - time.time()) / float(self.ratelimit_remaining) | 514 | time_wait = float(self.ratelimit_reset - time.time()) / float(self.ratelimit_remaining) |
497 | remaining_wait = time_wait - time_waited | 515 | remaining_wait = time_wait - time_waited |
498 | 516 | ||
499 | if remaining_wait > 0: | 517 | if remaining_wait > 0: |
500 | to_next = remaining_wait / self.ratelimit_pacefactor | 518 | to_next = remaining_wait / self.ratelimit_pacefactor |
501 | to_next = min(to_next, 5 * 60) | 519 | to_next = min(to_next, 5 * 60) |
502 | time.sleep(to_next) | 520 | time.sleep(to_next) |
503 | 521 | ||
504 | # Generate request headers | 522 | # Generate request headers |
505 | if self.access_token != None: | 523 | if self.access_token != None: |
506 | headers = {'Authorization': 'Bearer ' + self.access_token} | 524 | headers = {'Authorization': 'Bearer ' + self.access_token} |
507 | 525 | ||
508 | if self.debug_requests == True: | 526 | if self.debug_requests == True: |
509 | print('Mastodon: Request to endpoint "' + endpoint + '" using method "' + method + '".') | 527 | print('Mastodon: Request to endpoint "' + endpoint + '" using method "' + method + '".') |
510 | print('Parameters: ' + str(params)) | 528 | print('Parameters: ' + str(params)) |
@@ -515,40 +533,40 @@ class Mastodon: | |||
515 | request_complete = False | 533 | request_complete = False |
516 | while not request_complete: | 534 | while not request_complete: |
517 | request_complete = True | 535 | request_complete = True |
518 | 536 | ||
519 | response_object = None | 537 | response_object = None |
520 | try: | 538 | try: |
521 | if method == 'GET': | 539 | if method == 'GET': |
522 | response_object = requests.get(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout) | 540 | response_object = requests.get(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout) |
523 | 541 | ||
524 | if method == 'POST': | 542 | if method == 'POST': |
525 | response_object = requests.post(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout) | 543 | response_object = requests.post(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout) |
526 | 544 | ||
527 | if method == 'DELETE': | 545 | if method == 'DELETE': |
528 | response_object = requests.delete(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout) | 546 | response_object = requests.delete(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout) |
529 | except: | 547 | except: |
530 | raise MastodonNetworkError("Could not complete request.") | 548 | raise MastodonNetworkError("Could not complete request.") |
531 | 549 | ||
532 | if response_object == None: | 550 | if response_object == None: |
533 | raise MastodonIllegalArgumentError("Illegal request.") | 551 | raise MastodonIllegalArgumentError("Illegal request.") |
534 | 552 | ||
535 | # Handle response | 553 | # Handle response |
536 | if self.debug_requests == True: | 554 | if self.debug_requests == True: |
537 | print('Mastodon: Response received with code ' + str(response_object.status_code) + '.') | 555 | print('Mastodon: Response received with code ' + str(response_object.status_code) + '.') |
538 | print('Respose headers: ' + str(response_object.headers)) | 556 | print('Respose headers: ' + str(response_object.headers)) |
539 | print('Response text content: ' + str(response_object.text)) | 557 | print('Response text content: ' + str(response_object.text)) |
540 | 558 | ||
541 | if response_object.status_code == 404: | 559 | if response_object.status_code == 404: |
542 | raise MastodonAPIError('Endpoint not found.') | 560 | raise MastodonAPIError('Endpoint not found.') |
543 | 561 | ||
544 | if response_object.status_code == 500: | 562 | if response_object.status_code == 500: |
545 | raise MastodonAPIError('General API problem.') | 563 | raise MastodonAPIError('General API problem.') |
546 | 564 | ||
547 | try: | 565 | try: |
548 | response = response_object.json() | 566 | response = response_object.json() |
549 | except: | 567 | except: |
550 | raise MastodonAPIError("Could not parse response as JSON, respose code was " + str(response_object.status_code)) | 568 | raise MastodonAPIError("Could not parse response as JSON, respose code was " + str(response_object.status_code)) |
551 | 569 | ||
552 | # Handle rate limiting | 570 | # Handle rate limiting |
553 | if 'X-RateLimit-Remaining' in response_object.headers and do_ratelimiting: | 571 | if 'X-RateLimit-Remaining' in response_object.headers and do_ratelimiting: |
554 | self.ratelimit_remaining = int(response_object.headers['X-RateLimit-Remaining']) | 572 | self.ratelimit_remaining = int(response_object.headers['X-RateLimit-Remaining']) |
@@ -566,7 +584,7 @@ class Mastodon: | |||
566 | self.ratelimit_lastcall = time.time() | 584 | self.ratelimit_lastcall = time.time() |
567 | except: | 585 | except: |
568 | raise MastodonRatelimitError("Rate limit time calculations failed.") | 586 | raise MastodonRatelimitError("Rate limit time calculations failed.") |
569 | 587 | ||
570 | if "error" in response and response["error"] == "Throttled": | 588 | if "error" in response and response["error"] == "Throttled": |
571 | if self.ratelimit_method == "throw": | 589 | if self.ratelimit_method == "throw": |
572 | raise MastodonRatelimitError("Hit rate limit.") | 590 | raise MastodonRatelimitError("Hit rate limit.") |
@@ -575,35 +593,35 @@ class Mastodon: | |||
575 | to_next = self.ratelimit_reset - time.time() | 593 | to_next = self.ratelimit_reset - time.time() |
576 | if to_next > 0: | 594 | if to_next > 0: |
577 | # As a precaution, never sleep longer than 5 minutes | 595 | # As a precaution, never sleep longer than 5 minutes |
578 | to_next = min(to_next, 5 * 60) | 596 | to_next = min(to_next, 5 * 60) |
579 | time.sleep(to_next) | 597 | time.sleep(to_next) |
580 | request_complete = False | 598 | request_complete = False |
581 | 599 | ||
582 | return response | 600 | return response |
583 | 601 | ||
584 | def __generate_params(self, params, exclude = []): | 602 | def __generate_params(self, params, exclude = []): |
585 | """ | 603 | """ |
586 | Internal named-parameters-to-dict helper. | 604 | Internal named-parameters-to-dict helper. |
587 | 605 | ||
588 | Note for developers: If called with locals() as params, | 606 | Note for developers: If called with locals() as params, |
589 | as is the usual practice in this code, the __generate_params call | 607 | as is the usual practice in this code, the __generate_params call |
590 | (or at least the locals() call) should generally be the first thing | 608 | (or at least the locals() call) should generally be the first thing |
591 | in your function. | 609 | in your function. |
592 | """ | 610 | """ |
593 | params = dict(params) | 611 | params = dict(params) |
594 | 612 | ||
595 | del params['self'] | 613 | del params['self'] |
596 | param_keys = list(params.keys()) | 614 | param_keys = list(params.keys()) |
597 | for key in param_keys: | 615 | for key in param_keys: |
598 | if params[key] == None or key in exclude: | 616 | if params[key] == None or key in exclude: |
599 | del params[key] | 617 | del params[key] |
600 | 618 | ||
601 | param_keys = list(params.keys()) | 619 | param_keys = list(params.keys()) |
602 | for key in param_keys: | 620 | for key in param_keys: |
603 | if isinstance(params[key], list): | 621 | if isinstance(params[key], list): |
604 | params[key + "[]"] = params[key] | 622 | params[key + "[]"] = params[key] |
605 | del params[key] | 623 | del params[key] |
606 | 624 | ||
607 | return params | 625 | return params |
608 | 626 | ||
609 | ## | 627 | ## |