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