aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--mastodon/Mastodon.py526
-rw-r--r--mastodon/accounts.py4
-rw-r--r--mastodon/authentication.py370
-rw-r--r--mastodon/instance.py96
-rw-r--r--mastodon/internals.py2
-rw-r--r--mastodon/utility.py70
6 files changed, 549 insertions, 519 deletions
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py
index 76dccd3..35b8444 100644
--- a/mastodon/Mastodon.py
+++ b/mastodon/Mastodon.py
@@ -24,6 +24,7 @@ from .compat import urlparse
24 24
25from .utility import parse_version_string, max_version, api_version 25from .utility import parse_version_string, max_version, api_version
26from .utility import AttribAccessDict, AttribAccessDict 26from .utility import AttribAccessDict, AttribAccessDict
27from .utility import Mastodon as Utility
27 28
28from .error import * 29from .error import *
29from .versions import _DICT_VERSION_APPLICATION, _DICT_VERSION_MENTION, _DICT_VERSION_MEDIA, _DICT_VERSION_ACCOUNT, _DICT_VERSION_POLL, \ 30from .versions import _DICT_VERSION_APPLICATION, _DICT_VERSION_MENTION, _DICT_VERSION_MEDIA, _DICT_VERSION_ACCOUNT, _DICT_VERSION_POLL, \
@@ -39,12 +40,14 @@ from .defaults import _DEFAULT_TIMEOUT, _DEFAULT_SCOPES, _DEFAULT_STREAM_TIMEOUT
39from .defaults import _SCOPE_SETS 40from .defaults import _SCOPE_SETS
40 41
41from .internals import Mastodon as Internals 42from .internals import Mastodon as Internals
43from .authentication import Mastodon as Authentication
42from .accounts import Mastodon as Accounts 44from .accounts import Mastodon as Accounts
45from .instance import Mastodon as Instance
43 46
44## 47##
45# The actual Mastodon class 48# The actual Mastodon class
46### 49###
47class Mastodon(Internals, Accounts): 50class Mastodon(Utility, Authentication, Accounts, Instance):
48 """ 51 """
49 Thorough and easy to use Mastodon 52 Thorough and easy to use Mastodon
50 API wrapper in Python. 53 API wrapper in Python.
@@ -54,424 +57,6 @@ class Mastodon(Internals, Accounts):
54 # Support level 57 # Support level
55 __SUPPORTED_MASTODON_VERSION = "3.5.5" 58 __SUPPORTED_MASTODON_VERSION = "3.5.5"
56 59
57 ###
58 # Registering apps
59 ###
60 @staticmethod
61 def create_app(client_name, scopes=_DEFAULT_SCOPES, redirect_uris=None, website=None, to_file=None,
62 api_base_url=None, request_timeout=_DEFAULT_TIMEOUT, session=None):
63 """
64 Create a new app with given `client_name` and `scopes` (The basic scopes are "read", "write", "follow" and "push"
65 - more granular scopes are available, please refer to Mastodon documentation for which) on the instance given
66 by `api_base_url`.
67
68 Specify `redirect_uris` if you want users to be redirected to a certain page after authenticating in an OAuth flow.
69 You can specify multiple URLs by passing a list. Note that if you wish to use OAuth authentication with redirects,
70 the redirect URI must be one of the URLs specified here.
71
72 Specify `to_file` to persist your app's info to a file so you can use it in the constructor.
73 Specify `website` to give a website for your app.
74
75 Specify `session` with a requests.Session for it to be used instead of the default. This can be
76 used to, amongst other things, adjust proxy or SSL certificate settings.
77
78 Presently, app registration is open by default, but this is not guaranteed to be the case for all
79 Mastodon instances in the future.
80
81
82 Returns `client_id` and `client_secret`, both as strings.
83 """
84 if api_base_url is None:
85 raise MastodonIllegalArgumentError("API base URL is required.")
86 api_base_url = Mastodon.__protocolize(api_base_url)
87
88 request_data = {
89 'client_name': client_name,
90 'scopes': " ".join(scopes)
91 }
92
93 try:
94 if redirect_uris is not None:
95 if isinstance(redirect_uris, (list, tuple)):
96 redirect_uris = "\n".join(list(redirect_uris))
97 request_data['redirect_uris'] = redirect_uris
98 else:
99 request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'
100 if website is not None:
101 request_data['website'] = website
102 if session:
103 ret = session.post(api_base_url + '/api/v1/apps', data=request_data, timeout=request_timeout)
104 response = ret.json()
105 else:
106 response = requests.post(api_base_url + '/api/v1/apps', data=request_data, timeout=request_timeout)
107 response = response.json()
108 except Exception as e:
109 raise MastodonNetworkError("Could not complete request: %s" % e)
110
111 if to_file is not None:
112 with open(to_file, 'w') as secret_file:
113 secret_file.write(response['client_id'] + "\n")
114 secret_file.write(response['client_secret'] + "\n")
115 secret_file.write(api_base_url + "\n")
116 secret_file.write(client_name + "\n")
117
118 return (response['client_id'], response['client_secret'])
119
120 ###
121 # Authentication, including constructor
122 ###
123 def __init__(self, client_id=None, client_secret=None, access_token=None, api_base_url=None, debug_requests=False,
124 ratelimit_method="wait", ratelimit_pacefactor=1.1, request_timeout=_DEFAULT_TIMEOUT, mastodon_version=None,
125 version_check_mode="created", session=None, feature_set="mainline", user_agent="mastodonpy", lang=None):
126 """
127 Create a new API wrapper instance based on the given `client_secret` and `client_id` on the
128 instance given by `api_base_url`. If you give a `client_id` and it is not a file, you must
129 also give a secret. If you specify an `access_token` then you don't need to specify a `client_id`.
130 It is allowed to specify neither - in this case, you will be restricted to only using endpoints
131 that do not require authentication. If a file is given as `client_id`, client ID, secret and
132 base url are read from that file.
133
134 You can also specify an `access_token`, directly or as a file (as written by :ref:`log_in() <log_in()>`). If
135 a file is given, Mastodon.py also tries to load the base URL from this file, if present. A
136 client id and secret are not required in this case.
137
138 Mastodon.py can try to respect rate limits in several ways, controlled by `ratelimit_method`.
139 "throw" makes functions throw a `MastodonRatelimitError` when the rate
140 limit is hit. "wait" mode will, once the limit is hit, wait and retry the request as soon
141 as the rate limit resets, until it succeeds. "pace" works like throw, but tries to wait in
142 between calls so that the limit is generally not hit (how hard it tries to avoid hitting the rate
143 limit can be controlled by ratelimit_pacefactor). The default setting is "wait". Note that
144 even in "wait" and "pace" mode, requests can still fail due to network or other problems! Also
145 note that "pace" and "wait" are NOT thread safe.
146
147 By default, a timeout of 300 seconds is used for all requests. If you wish to change this,
148 pass the desired timeout (in seconds) as `request_timeout`.
149
150 For fine-tuned control over the requests object use `session` with a requests.Session.
151
152 The `mastodon_version` parameter can be used to specify the version of Mastodon that Mastodon.py will
153 expect to be installed on the server. The function will throw an error if an unparseable
154 Version is specified. If no version is specified, Mastodon.py will set `mastodon_version` to the
155 detected version.
156
157 The version check mode can be set to "created" (the default behaviour), "changed" or "none". If set to
158 "created", Mastodon.py will throw an error if the version of Mastodon it is connected to is too old
159 to have an endpoint. If it is set to "changed", it will throw an error if the endpoint's behaviour has
160 changed after the version of Mastodon that is connected has been released. If it is set to "none",
161 version checking is disabled.
162
163 `feature_set` can be used to enable behaviour specific to non-mainline Mastodon API implementations.
164 Details are documented in the functions that provide such functionality. Currently supported feature
165 sets are `mainline`, `fedibird` and `pleroma`.
166
167 For some Mastodon instances a `User-Agent` header is needed. This can be set by parameter `user_agent`. Starting from
168 Mastodon.py 1.5.2 `create_app()` stores the application name into the client secret file. If `client_id` points to this file,
169 the app name will be used as `User-Agent` header as default. It is possible to modify old secret files and append
170 a client app name to use it as a `User-Agent` name.
171
172 `lang` can be used to change the locale Mastodon will use to generate responses. Valid parameters are all ISO 639-1 (two letter)
173 or for a language that has none, 639-3 (three letter) language codes. This affects some error messages (those related to validation) and
174 trends. You can change the language using :ref:`set_language()`.
175
176 If no other `User-Agent` is specified, "mastodonpy" will be used.
177 """
178 self.api_base_url = api_base_url
179 if self.api_base_url is not None:
180 self.api_base_url = self.__protocolize(self.api_base_url)
181 self.client_id = client_id
182 self.client_secret = client_secret
183 self.access_token = access_token
184 self.debug_requests = debug_requests
185 self.ratelimit_method = ratelimit_method
186 self._token_expired = datetime.datetime.now()
187 self._refresh_token = None
188
189 self.__logged_in_id = None
190
191 self.ratelimit_limit = 300
192 self.ratelimit_reset = time.time()
193 self.ratelimit_remaining = 300
194 self.ratelimit_lastcall = time.time()
195 self.ratelimit_pacefactor = ratelimit_pacefactor
196
197 self.request_timeout = request_timeout
198
199 if session:
200 self.session = session
201 else:
202 self.session = requests.Session()
203
204 self.feature_set = feature_set
205 if not self.feature_set in ["mainline", "fedibird", "pleroma"]:
206 raise MastodonIllegalArgumentError('Requested invalid feature set')
207
208 # General defined user-agent
209 self.user_agent = user_agent
210
211 # Save language
212 self.lang = lang
213
214 # Token loading
215 if self.client_id is not None:
216 if os.path.isfile(self.client_id):
217 with open(self.client_id, 'r') as secret_file:
218 self.client_id = secret_file.readline().rstrip()
219 self.client_secret = secret_file.readline().rstrip()
220
221 try_base_url = secret_file.readline().rstrip()
222 if try_base_url is not None and len(try_base_url) != 0:
223 try_base_url = Mastodon.__protocolize(try_base_url)
224 if not (self.api_base_url is None or try_base_url == self.api_base_url):
225 raise MastodonIllegalArgumentError('Mismatch in base URLs between files and/or specified')
226 self.api_base_url = try_base_url
227
228 # With new registrations we support the 4th line to store a client_name and use it as user-agent
229 client_name = secret_file.readline()
230 if client_name and self.user_agent is None:
231 self.user_agent = client_name.rstrip()
232 else:
233 if self.client_secret is None:
234 raise MastodonIllegalArgumentError('Specified client id directly, but did not supply secret')
235
236 if self.access_token is not None and os.path.isfile(self.access_token):
237 with open(self.access_token, 'r') as token_file:
238 self.access_token = token_file.readline().rstrip()
239
240 # For newer versions, we also store the URL
241 try_base_url = token_file.readline().rstrip()
242 if try_base_url is not None and len(try_base_url) != 0:
243 try_base_url = Mastodon.__protocolize(try_base_url)
244 if not (self.api_base_url is None or try_base_url == self.api_base_url):
245 raise MastodonIllegalArgumentError('Mismatch in base URLs between files and/or specified')
246 self.api_base_url = try_base_url
247
248 # For EVEN newer vesions, we ALSO ALSO store the client id and secret so that you don't need to reauth to revoke
249 if self.client_id is None:
250 try:
251 self.client_id = token_file.readline().rstrip()
252 self.client_secret = token_file.readline().rstrip()
253 except:
254 pass
255
256 # Verify we have a base URL, protocolize
257 if self.api_base_url is None:
258 raise MastodonIllegalArgumentError("API base URL is required.")
259 self.api_base_url = Mastodon.__protocolize(self.api_base_url)
260
261 if not version_check_mode in ["created", "changed", "none"]:
262 raise MastodonIllegalArgumentError("Invalid version check method.")
263 self.version_check_mode = version_check_mode
264
265 self.mastodon_major = 1
266 self.mastodon_minor = 0
267 self.mastodon_patch = 0
268 self.version_check_worked = None
269
270 # Versioning
271 if mastodon_version is None and self.version_check_mode != 'none':
272 self.retrieve_mastodon_version()
273 elif self.version_check_mode != 'none':
274 try:
275 self.mastodon_major, self.mastodon_minor, self.mastodon_patch = parse_version_string(mastodon_version)
276 except:
277 raise MastodonVersionError("Bad version specified")
278
279 # Ratelimiting parameter check
280 if ratelimit_method not in ["throw", "wait", "pace"]:
281 raise MastodonIllegalArgumentError("Invalid ratelimit method.")
282
283 def auth_request_url(self, client_id=None, redirect_uris="urn:ietf:wg:oauth:2.0:oob", scopes=_DEFAULT_SCOPES, force_login=False, state=None, lang=None):
284 """
285 Returns the URL that a client needs to request an OAuth grant from the server.
286
287 To log in with OAuth, send your user to this URL. The user will then log in and
288 get a code which you can pass to :ref:`log_in() <log_in()>`.
289
290 `scopes` are as in :ref:`log_in() <log_in()>`, redirect_uris is where the user should be redirected to
291 after authentication. Note that `redirect_uris` must be one of the URLs given during
292 app registration. When using urn:ietf:wg:oauth:2.0:oob, the code is simply displayed,
293 otherwise it is added to the given URL as the "code" request parameter.
294
295 Pass force_login if you want the user to always log in even when already logged
296 into web Mastodon (i.e. when registering multiple different accounts in an app).
297
298 `state` is the oauth `state` parameter to pass to the server. It is strongly suggested
299 to use a random, nonguessable value (i.e. nothing meaningful and no incrementing ID)
300 to preserve security guarantees. It can be left out for non-web login flows.
301
302 Pass an ISO 639-1 (two letter) or, for languages that do not have one, 639-3 (three letter)
303 language code as `lang` to control the display language for the oauth form.
304 """
305 if client_id is None:
306 client_id = self.client_id
307 else:
308 if os.path.isfile(client_id):
309 with open(client_id, 'r') as secret_file:
310 client_id = secret_file.readline().rstrip()
311
312 params = dict()
313 params['client_id'] = client_id
314 params['response_type'] = "code"
315 params['redirect_uri'] = redirect_uris
316 params['scope'] = " ".join(scopes)
317 params['force_login'] = force_login
318 params['state'] = state
319 params['lang'] = lang
320 formatted_params = urlencode(params)
321 return "".join([self.api_base_url, "/oauth/authorize?", formatted_params])
322
323 def log_in(self, username=None, password=None, code=None, redirect_uri="urn:ietf:wg:oauth:2.0:oob", refresh_token=None, scopes=_DEFAULT_SCOPES, to_file=None):
324 """
325 Get the access token for a user.
326
327 The username is the email address used to log in into Mastodon.
328
329 Can persist access token to file `to_file`, to be used in the constructor.
330
331 Handles password and OAuth-based authorization.
332
333 Will throw a `MastodonIllegalArgumentError` if the OAuth flow data or the
334 username / password credentials given are incorrect, and
335 `MastodonAPIError` if all of the requested scopes were not granted.
336
337 For OAuth 2, obtain a code via having your user go to the URL returned by
338 :ref:`auth_request_url() <auth_request_url()>` and pass it as the code parameter. In this case,
339 make sure to also pass the same redirect_uri parameter as you used when
340 generating the auth request URL.
341
342 Returns the access token as a string.
343 """
344 if username is not None and password is not None:
345 params = self.__generate_params(locals(), ['scopes', 'to_file', 'code', 'refresh_token'])
346 params['grant_type'] = 'password'
347 elif code is not None:
348 params = self.__generate_params(locals(), ['scopes', 'to_file', 'username', 'password', 'refresh_token'])
349 params['grant_type'] = 'authorization_code'
350 elif refresh_token is not None:
351 params = self.__generate_params(locals(), ['scopes', 'to_file', 'username', 'password', 'code'])
352 params['grant_type'] = 'refresh_token'
353 else:
354 raise MastodonIllegalArgumentError('Invalid arguments given. username and password or code are required.')
355
356 params['client_id'] = self.client_id
357 params['client_secret'] = self.client_secret
358 params['scope'] = " ".join(scopes)
359
360 try:
361 response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting=False)
362 self.access_token = response['access_token']
363 self.__set_refresh_token(response.get('refresh_token'))
364 self.__set_token_expired(int(response.get('expires_in', 0)))
365 except Exception as e:
366 if username is not None or password is not None:
367 raise MastodonIllegalArgumentError('Invalid user name, password, or redirect_uris: %s' % e)
368 elif code is not None:
369 raise MastodonIllegalArgumentError('Invalid access token or redirect_uris: %s' % e)
370 else:
371 raise MastodonIllegalArgumentError('Invalid request: %s' % e)
372
373 received_scopes = response["scope"].split(" ")
374 for scope_set in _SCOPE_SETS.keys():
375 if scope_set in received_scopes:
376 received_scopes += _SCOPE_SETS[scope_set]
377
378 if not set(scopes) <= set(received_scopes):
379 raise MastodonAPIError('Granted scopes "' + " ".join(received_scopes) + '" do not contain all of the requested scopes "' + " ".join(scopes) + '".')
380
381 if to_file is not None:
382 with open(to_file, 'w') as token_file:
383 token_file.write(response['access_token'] + "\n")
384 token_file.write(self.api_base_url + "\n")
385 token_file.write(self.client_id + "\n")
386 token_file.write(self.client_secret + "\n")
387
388 self.__logged_in_id = None
389
390 # Retry version check if needed (might be required in limited federation mode)
391 if not self.version_check_worked:
392 self.retrieve_mastodon_version()
393
394 return response['access_token']
395
396
397 def revoke_access_token(self):
398 """
399 Revoke the oauth token the user is currently authenticated with, effectively removing
400 the apps access and requiring the user to log in again.
401 """
402 if self.access_token is None:
403 raise MastodonIllegalArgumentError("Not logged in, do not have a token to revoke.")
404 if self.client_id is None or self.client_secret is None:
405 raise MastodonIllegalArgumentError("Client authentication (id + secret) is required to revoke tokens.")
406 params = collections.OrderedDict([])
407 params['client_id'] = self.client_id
408 params['client_secret'] = self.client_secret
409 params['token'] = self.access_token
410 self.__api_request('POST', '/oauth/revoke', params)
411
412 # We are now logged out, clear token and logged in id
413 self.access_token = None
414 self.__logged_in_id = None
415
416 def set_language(self, lang):
417 """
418 Set the locale Mastodon will use to generate responses. Valid parameters are all ISO 639-1 (two letter) or, for languages that do
419 not have one, 639-3 (three letter) language codes. This affects some error messages (those related to validation) and trends.
420 """
421 self.lang = lang
422
423 def retrieve_mastodon_version(self):
424 """
425 Determine installed Mastodon version and set major, minor and patch (not including RC info) accordingly.
426
427 Returns the version string, possibly including rc info.
428 """
429 try:
430 version_str = self.__normalize_version_string(self.__instance()["version"])
431 self.version_check_worked = True
432 except:
433 # instance() was added in 1.1.0, so our best guess is 1.0.0.
434 version_str = "1.0.0"
435 self.version_check_worked = False
436
437 self.mastodon_major, self.mastodon_minor, self.mastodon_patch = parse_version_string(version_str)
438 return version_str
439
440 def verify_minimum_version(self, version_str, cached=False):
441 """
442 Update version info from server and verify that at least the specified version is present.
443
444 If you specify "cached", the version info update part is skipped.
445
446 Returns True if version requirement is satisfied, False if not.
447 """
448 if not cached:
449 self.retrieve_mastodon_version()
450 major, minor, patch = parse_version_string(version_str)
451 if major > self.mastodon_major:
452 return False
453 elif major == self.mastodon_major and minor > self.mastodon_minor:
454 return False
455 elif major == self.mastodon_major and minor == self.mastodon_minor and patch > self.mastodon_patch:
456 return False
457 return True
458
459 def get_approx_server_time(self):
460 """
461 Retrieve the approximate server time
462
463 We parse this from the hopefully present "Date" header, but make no effort to compensate for latency.
464 """
465 response = self.__api_request("HEAD", "/", return_response_object=True)
466 if 'Date' in response.headers:
467 server_time_datetime = dateutil.parser.parse(response.headers['Date'])
468
469 # Make sure we're in local time
470 epoch_time = self.__datetime_to_epoch(server_time_datetime)
471 return datetime.datetime.fromtimestamp(epoch_time)
472 else:
473 raise MastodonAPIError("No server time in response.")
474
475 @staticmethod 60 @staticmethod
476 def get_supported_version(): 61 def get_supported_version():
477 """ 62 """
@@ -480,95 +65,6 @@ class Mastodon(Internals, Accounts):
480 return Mastodon.__SUPPORTED_MASTODON_VERSION 65 return Mastodon.__SUPPORTED_MASTODON_VERSION
481 66
482 ### 67 ###
483 # Reading data: Instances
484 ###
485 @api_version("1.1.0", "2.3.0", _DICT_VERSION_INSTANCE)
486 def instance(self):
487 """
488 Retrieve basic information about the instance, including the URI and administrative contact email.
489
490 Does not require authentication unless locked down by the administrator.
491
492 Returns an :ref:`instance dict <instance dict>`.
493 """
494 return self.__instance()
495
496 def __instance(self):
497 """
498 Internal, non-version-checking helper that does the same as instance()
499 """
500 instance = self.__api_request('GET', '/api/v1/instance/')
501 return instance
502
503 @api_version("2.1.2", "2.1.2", _DICT_VERSION_ACTIVITY)
504 def instance_activity(self):
505 """
506 Retrieve activity stats about the instance. May be disabled by the instance administrator - throws
507 a MastodonNotFoundError in that case.
508
509 Activity is returned for 12 weeks going back from the current week.
510
511 Returns a list of :ref:`activity dicts <activity dicts>`.
512 """
513 return self.__api_request('GET', '/api/v1/instance/activity')
514
515 @api_version("2.1.2", "2.1.2", "2.1.2")
516 def instance_peers(self):
517 """
518 Retrieve the instances that this instance knows about. May be disabled by the instance administrator - throws
519 a MastodonNotFoundError in that case.
520
521 Returns a list of URL strings.
522 """
523 return self.__api_request('GET', '/api/v1/instance/peers')
524
525 @api_version("3.0.0", "3.0.0", "3.0.0")
526 def instance_health(self):
527 """
528 Basic health check. Returns True if healthy, False if not.
529 """
530 status = self.__api_request('GET', '/health', parse=False).decode("utf-8")
531 return status in ["OK", "success"]
532
533 @api_version("3.0.0", "3.0.0", "3.0.0")
534 def instance_nodeinfo(self, schema="http://nodeinfo.diaspora.software/ns/schema/2.0"):
535 """
536 Retrieves the instance's nodeinfo information.
537
538 For information on what the nodeinfo can contain, see the nodeinfo
539 specification: https://github.com/jhass/nodeinfo . By default,
540 Mastodon.py will try to retrieve the version 2.0 schema nodeinfo.
541
542 To override the schema, specify the desired schema with the `schema`
543 parameter.
544 """
545 links = self.__api_request('GET', '/.well-known/nodeinfo')["links"]
546
547 schema_url = None
548 for available_schema in links:
549 if available_schema.rel == schema:
550 schema_url = available_schema.href
551
552 if schema_url is None:
553 raise MastodonIllegalArgumentError(
554 "Requested nodeinfo schema is not available.")
555
556 try:
557 return self.__api_request('GET', schema_url, base_url_override="")
558 except MastodonNotFoundError:
559 parse = urlparse(schema_url)
560 return self.__api_request('GET', parse.path + parse.params + parse.query + parse.fragment)
561
562 @api_version("3.4.0", "3.4.0", _DICT_VERSION_INSTANCE)
563 def instance_rules(self):
564 """
565 Retrieve instance rules.
566
567 Returns a list of `id` + `text` dicts, same as the `rules` field in the :ref:`instance dicts <instance dicts>`.
568 """
569 return self.__api_request('GET', '/api/v1/instance/rules')
570
571 ###
572 # Reading data: Timelines 68 # Reading data: Timelines
573 ## 69 ##
574 @api_version("1.0.0", "3.1.4", _DICT_VERSION_STATUS) 70 @api_version("1.0.0", "3.1.4", _DICT_VERSION_STATUS)
@@ -3379,14 +2875,14 @@ class Mastodon(Internals, Accounts):
3379 2875
3380 push_key_pair = ec.generate_private_key(ec.SECP256R1(), default_backend()) 2876 push_key_pair = ec.generate_private_key(ec.SECP256R1(), default_backend())
3381 push_key_priv = push_key_pair.private_numbers().private_value 2877 push_key_priv = push_key_pair.private_numbers().private_value
3382 2878 try:
3383 crypto_ver = cryptography.__version__ 2879 push_key_pub = push_key_pair.public_key().public_bytes(
3384 if len(crypto_ver) < 5: 2880 serialization.Encoding.X962,
3385 crypto_ver += ".0" 2881 serialization.PublicFormat.UncompressedPoint,
3386 if parse_version_string(crypto_ver) == (2, 5, 0): 2882 )
3387 sapush_key_pub = push_key_pair.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) 2883 except:
3388 else:
3389 push_key_pub = push_key_pair.public_key().public_numbers().encode_point() 2884 push_key_pub = push_key_pair.public_key().public_numbers().encode_point()
2885
3390 push_shared_secret = os.urandom(16) 2886 push_shared_secret = os.urandom(16)
3391 2887
3392 priv_dict = { 2888 priv_dict = {
diff --git a/mastodon/accounts.py b/mastodon/accounts.py
index dcdd8de..5ecdf93 100644
--- a/mastodon/accounts.py
+++ b/mastodon/accounts.py
@@ -2,7 +2,9 @@ from .defaults import _DEFAULT_SCOPES, _SCOPE_SETS
2from .error import MastodonIllegalArgumentError, MastodonAPIError 2from .error import MastodonIllegalArgumentError, MastodonAPIError
3from .utility import api_version 3from .utility import api_version
4 4
5class Mastodon(): 5from .internals import Mastodon as Internals
6
7class Mastodon(Internals):
6 @api_version("2.7.0", "2.7.0", "3.4.0") 8 @api_version("2.7.0", "2.7.0", "3.4.0")
7 def create_account(self, username, password, email, agreement=False, reason=None, locale="en", scopes=_DEFAULT_SCOPES, to_file=None, return_detailed_error=False): 9 def create_account(self, username, password, email, agreement=False, reason=None, locale="en", scopes=_DEFAULT_SCOPES, to_file=None, return_detailed_error=False):
8 """ 10 """
diff --git a/mastodon/authentication.py b/mastodon/authentication.py
new file mode 100644
index 0000000..b7f15c9
--- /dev/null
+++ b/mastodon/authentication.py
@@ -0,0 +1,370 @@
1import requests
2from requests.models import urlencode
3import datetime
4import os
5import time
6import collections
7
8from .error import MastodonIllegalArgumentError, MastodonNetworkError, MastodonVersionError, MastodonAPIError
9from .defaults import _DEFAULT_SCOPES, _SCOPE_SETS, _DEFAULT_TIMEOUT
10from .utility import parse_version_string
11from .internals import Mastodon as Internals
12
13class Mastodon(Internals):
14 ###
15 # Registering apps
16 ###
17 @staticmethod
18 def create_app(client_name, scopes=_DEFAULT_SCOPES, redirect_uris=None, website=None, to_file=None,
19 api_base_url=None, request_timeout=_DEFAULT_TIMEOUT, session=None):
20 """
21 Create a new app with given `client_name` and `scopes` (The basic scopes are "read", "write", "follow" and "push"
22 - more granular scopes are available, please refer to Mastodon documentation for which) on the instance given
23 by `api_base_url`.
24
25 Specify `redirect_uris` if you want users to be redirected to a certain page after authenticating in an OAuth flow.
26 You can specify multiple URLs by passing a list. Note that if you wish to use OAuth authentication with redirects,
27 the redirect URI must be one of the URLs specified here.
28
29 Specify `to_file` to persist your app's info to a file so you can use it in the constructor.
30 Specify `website` to give a website for your app.
31
32 Specify `session` with a requests.Session for it to be used instead of the default. This can be
33 used to, amongst other things, adjust proxy or SSL certificate settings.
34
35 Presently, app registration is open by default, but this is not guaranteed to be the case for all
36 Mastodon instances in the future.
37
38
39 Returns `client_id` and `client_secret`, both as strings.
40 """
41 if api_base_url is None:
42 raise MastodonIllegalArgumentError("API base URL is required.")
43 api_base_url = Mastodon.__protocolize(api_base_url)
44
45 request_data = {
46 'client_name': client_name,
47 'scopes': " ".join(scopes)
48 }
49
50 try:
51 if redirect_uris is not None:
52 if isinstance(redirect_uris, (list, tuple)):
53 redirect_uris = "\n".join(list(redirect_uris))
54 request_data['redirect_uris'] = redirect_uris
55 else:
56 request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'
57 if website is not None:
58 request_data['website'] = website
59 if session:
60 ret = session.post(api_base_url + '/api/v1/apps', data=request_data, timeout=request_timeout)
61 response = ret.json()
62 else:
63 response = requests.post(api_base_url + '/api/v1/apps', data=request_data, timeout=request_timeout)
64 response = response.json()
65 except Exception as e:
66 raise MastodonNetworkError("Could not complete request: %s" % e)
67
68 if to_file is not None:
69 with open(to_file, 'w') as secret_file:
70 secret_file.write(response['client_id'] + "\n")
71 secret_file.write(response['client_secret'] + "\n")
72 secret_file.write(api_base_url + "\n")
73 secret_file.write(client_name + "\n")
74
75 return (response['client_id'], response['client_secret'])
76
77 ###
78 # Authentication, including constructor
79 ###
80 def __init__(self, client_id=None, client_secret=None, access_token=None, api_base_url=None, debug_requests=False,
81 ratelimit_method="wait", ratelimit_pacefactor=1.1, request_timeout=_DEFAULT_TIMEOUT, mastodon_version=None,
82 version_check_mode="created", session=None, feature_set="mainline", user_agent="mastodonpy", lang=None):
83 """
84 Create a new API wrapper instance based on the given `client_secret` and `client_id` on the
85 instance given by `api_base_url`. If you give a `client_id` and it is not a file, you must
86 also give a secret. If you specify an `access_token` then you don't need to specify a `client_id`.
87 It is allowed to specify neither - in this case, you will be restricted to only using endpoints
88 that do not require authentication. If a file is given as `client_id`, client ID, secret and
89 base url are read from that file.
90
91 You can also specify an `access_token`, directly or as a file (as written by :ref:`log_in() <log_in()>`). If
92 a file is given, Mastodon.py also tries to load the base URL from this file, if present. A
93 client id and secret are not required in this case.
94
95 Mastodon.py can try to respect rate limits in several ways, controlled by `ratelimit_method`.
96 "throw" makes functions throw a `MastodonRatelimitError` when the rate
97 limit is hit. "wait" mode will, once the limit is hit, wait and retry the request as soon
98 as the rate limit resets, until it succeeds. "pace" works like throw, but tries to wait in
99 between calls so that the limit is generally not hit (how hard it tries to avoid hitting the rate
100 limit can be controlled by ratelimit_pacefactor). The default setting is "wait". Note that
101 even in "wait" and "pace" mode, requests can still fail due to network or other problems! Also
102 note that "pace" and "wait" are NOT thread safe.
103
104 By default, a timeout of 300 seconds is used for all requests. If you wish to change this,
105 pass the desired timeout (in seconds) as `request_timeout`.
106
107 For fine-tuned control over the requests object use `session` with a requests.Session.
108
109 The `mastodon_version` parameter can be used to specify the version of Mastodon that Mastodon.py will
110 expect to be installed on the server. The function will throw an error if an unparseable
111 Version is specified. If no version is specified, Mastodon.py will set `mastodon_version` to the
112 detected version.
113
114 The version check mode can be set to "created" (the default behaviour), "changed" or "none". If set to
115 "created", Mastodon.py will throw an error if the version of Mastodon it is connected to is too old
116 to have an endpoint. If it is set to "changed", it will throw an error if the endpoint's behaviour has
117 changed after the version of Mastodon that is connected has been released. If it is set to "none",
118 version checking is disabled.
119
120 `feature_set` can be used to enable behaviour specific to non-mainline Mastodon API implementations.
121 Details are documented in the functions that provide such functionality. Currently supported feature
122 sets are `mainline`, `fedibird` and `pleroma`.
123
124 For some Mastodon instances a `User-Agent` header is needed. This can be set by parameter `user_agent`. Starting from
125 Mastodon.py 1.5.2 `create_app()` stores the application name into the client secret file. If `client_id` points to this file,
126 the app name will be used as `User-Agent` header as default. It is possible to modify old secret files and append
127 a client app name to use it as a `User-Agent` name.
128
129 `lang` can be used to change the locale Mastodon will use to generate responses. Valid parameters are all ISO 639-1 (two letter)
130 or for a language that has none, 639-3 (three letter) language codes. This affects some error messages (those related to validation) and
131 trends. You can change the language using :ref:`set_language()`.
132
133 If no other `User-Agent` is specified, "mastodonpy" will be used.
134 """
135 self.api_base_url = api_base_url
136 if self.api_base_url is not None:
137 self.api_base_url = self.__protocolize(self.api_base_url)
138 self.client_id = client_id
139 self.client_secret = client_secret
140 self.access_token = access_token
141 self.debug_requests = debug_requests
142 self.ratelimit_method = ratelimit_method
143 self._token_expired = datetime.datetime.now()
144 self._refresh_token = None
145
146 self.__logged_in_id = None
147
148 self.ratelimit_limit = 300
149 self.ratelimit_reset = time.time()
150 self.ratelimit_remaining = 300
151 self.ratelimit_lastcall = time.time()
152 self.ratelimit_pacefactor = ratelimit_pacefactor
153
154 self.request_timeout = request_timeout
155
156 if session:
157 self.session = session
158 else:
159 self.session = requests.Session()
160
161 self.feature_set = feature_set
162 if not self.feature_set in ["mainline", "fedibird", "pleroma"]:
163 raise MastodonIllegalArgumentError('Requested invalid feature set')
164
165 # General defined user-agent
166 self.user_agent = user_agent
167
168 # Save language
169 self.lang = lang
170
171 # Token loading
172 if self.client_id is not None:
173 if os.path.isfile(self.client_id):
174 with open(self.client_id, 'r') as secret_file:
175 self.client_id = secret_file.readline().rstrip()
176 self.client_secret = secret_file.readline().rstrip()
177
178 try_base_url = secret_file.readline().rstrip()
179 if try_base_url is not None and len(try_base_url) != 0:
180 try_base_url = Mastodon.__protocolize(try_base_url)
181 if not (self.api_base_url is None or try_base_url == self.api_base_url):
182 raise MastodonIllegalArgumentError('Mismatch in base URLs between files and/or specified')
183 self.api_base_url = try_base_url
184
185 # With new registrations we support the 4th line to store a client_name and use it as user-agent
186 client_name = secret_file.readline()
187 if client_name and self.user_agent is None:
188 self.user_agent = client_name.rstrip()
189 else:
190 if self.client_secret is None:
191 raise MastodonIllegalArgumentError('Specified client id directly, but did not supply secret')
192
193 if self.access_token is not None and os.path.isfile(self.access_token):
194 with open(self.access_token, 'r') as token_file:
195 self.access_token = token_file.readline().rstrip()
196
197 # For newer versions, we also store the URL
198 try_base_url = token_file.readline().rstrip()
199 if try_base_url is not None and len(try_base_url) != 0:
200 try_base_url = Mastodon.__protocolize(try_base_url)
201 if not (self.api_base_url is None or try_base_url == self.api_base_url):
202 raise MastodonIllegalArgumentError('Mismatch in base URLs between files and/or specified')
203 self.api_base_url = try_base_url
204
205 # For EVEN newer vesions, we ALSO ALSO store the client id and secret so that you don't need to reauth to revoke
206 if self.client_id is None:
207 try:
208 self.client_id = token_file.readline().rstrip()
209 self.client_secret = token_file.readline().rstrip()
210 except:
211 pass
212
213 # Verify we have a base URL, protocolize
214 if self.api_base_url is None:
215 raise MastodonIllegalArgumentError("API base URL is required.")
216 self.api_base_url = Mastodon.__protocolize(self.api_base_url)
217
218 if not version_check_mode in ["created", "changed", "none"]:
219 raise MastodonIllegalArgumentError("Invalid version check method.")
220 self.version_check_mode = version_check_mode
221
222 self.mastodon_major = 1
223 self.mastodon_minor = 0
224 self.mastodon_patch = 0
225 self.version_check_worked = None
226
227 # Versioning
228 if mastodon_version is None and self.version_check_mode != 'none':
229 self.retrieve_mastodon_version()
230 elif self.version_check_mode != 'none':
231 try:
232 self.mastodon_major, self.mastodon_minor, self.mastodon_patch = parse_version_string(mastodon_version)
233 except:
234 raise MastodonVersionError("Bad version specified")
235
236 # Ratelimiting parameter check
237 if ratelimit_method not in ["throw", "wait", "pace"]:
238 raise MastodonIllegalArgumentError("Invalid ratelimit method.")
239
240 def auth_request_url(self, client_id=None, redirect_uris="urn:ietf:wg:oauth:2.0:oob", scopes=_DEFAULT_SCOPES, force_login=False, state=None, lang=None):
241 """
242 Returns the URL that a client needs to request an OAuth grant from the server.
243
244 To log in with OAuth, send your user to this URL. The user will then log in and
245 get a code which you can pass to :ref:`log_in() <log_in()>`.
246
247 `scopes` are as in :ref:`log_in() <log_in()>`, redirect_uris is where the user should be redirected to
248 after authentication. Note that `redirect_uris` must be one of the URLs given during
249 app registration. When using urn:ietf:wg:oauth:2.0:oob, the code is simply displayed,
250 otherwise it is added to the given URL as the "code" request parameter.
251
252 Pass force_login if you want the user to always log in even when already logged
253 into web Mastodon (i.e. when registering multiple different accounts in an app).
254
255 `state` is the oauth `state` parameter to pass to the server. It is strongly suggested
256 to use a random, nonguessable value (i.e. nothing meaningful and no incrementing ID)
257 to preserve security guarantees. It can be left out for non-web login flows.
258
259 Pass an ISO 639-1 (two letter) or, for languages that do not have one, 639-3 (three letter)
260 language code as `lang` to control the display language for the oauth form.
261 """
262 if client_id is None:
263 client_id = self.client_id
264 else:
265 if os.path.isfile(client_id):
266 with open(client_id, 'r') as secret_file:
267 client_id = secret_file.readline().rstrip()
268
269 params = dict()
270 params['client_id'] = client_id
271 params['response_type'] = "code"
272 params['redirect_uri'] = redirect_uris
273 params['scope'] = " ".join(scopes)
274 params['force_login'] = force_login
275 params['state'] = state
276 params['lang'] = lang
277 formatted_params = urlencode(params)
278 return "".join([self.api_base_url, "/oauth/authorize?", formatted_params])
279
280 def log_in(self, username=None, password=None, code=None, redirect_uri="urn:ietf:wg:oauth:2.0:oob", refresh_token=None, scopes=_DEFAULT_SCOPES, to_file=None):
281 """
282 Get the access token for a user.
283
284 The username is the email address used to log in into Mastodon.
285
286 Can persist access token to file `to_file`, to be used in the constructor.
287
288 Handles password and OAuth-based authorization.
289
290 Will throw a `MastodonIllegalArgumentError` if the OAuth flow data or the
291 username / password credentials given are incorrect, and
292 `MastodonAPIError` if all of the requested scopes were not granted.
293
294 For OAuth 2, obtain a code via having your user go to the URL returned by
295 :ref:`auth_request_url() <auth_request_url()>` and pass it as the code parameter. In this case,
296 make sure to also pass the same redirect_uri parameter as you used when
297 generating the auth request URL.
298
299 Returns the access token as a string.
300 """
301 if username is not None and password is not None:
302 params = self.__generate_params(locals(), ['scopes', 'to_file', 'code', 'refresh_token'])
303 params['grant_type'] = 'password'
304 elif code is not None:
305 params = self.__generate_params(locals(), ['scopes', 'to_file', 'username', 'password', 'refresh_token'])
306 params['grant_type'] = 'authorization_code'
307 elif refresh_token is not None:
308 params = self.__generate_params(locals(), ['scopes', 'to_file', 'username', 'password', 'code'])
309 params['grant_type'] = 'refresh_token'
310 else:
311 raise MastodonIllegalArgumentError('Invalid arguments given. username and password or code are required.')
312
313 params['client_id'] = self.client_id
314 params['client_secret'] = self.client_secret
315 params['scope'] = " ".join(scopes)
316
317 try:
318 response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting=False)
319 self.access_token = response['access_token']
320 self.__set_refresh_token(response.get('refresh_token'))
321 self.__set_token_expired(int(response.get('expires_in', 0)))
322 except Exception as e:
323 if username is not None or password is not None:
324 raise MastodonIllegalArgumentError('Invalid user name, password, or redirect_uris: %s' % e)
325 elif code is not None:
326 raise MastodonIllegalArgumentError('Invalid access token or redirect_uris: %s' % e)
327 else:
328 raise MastodonIllegalArgumentError('Invalid request: %s' % e)
329
330 received_scopes = response["scope"].split(" ")
331 for scope_set in _SCOPE_SETS.keys():
332 if scope_set in received_scopes:
333 received_scopes += _SCOPE_SETS[scope_set]
334
335 if not set(scopes) <= set(received_scopes):
336 raise MastodonAPIError('Granted scopes "' + " ".join(received_scopes) + '" do not contain all of the requested scopes "' + " ".join(scopes) + '".')
337
338 if to_file is not None:
339 with open(to_file, 'w') as token_file:
340 token_file.write(response['access_token'] + "\n")
341 token_file.write(self.api_base_url + "\n")
342 token_file.write(self.client_id + "\n")
343 token_file.write(self.client_secret + "\n")
344
345 self.__logged_in_id = None
346
347 # Retry version check if needed (might be required in limited federation mode)
348 if not self.version_check_worked:
349 self.retrieve_mastodon_version()
350
351 return response['access_token']
352
353 def revoke_access_token(self):
354 """
355 Revoke the oauth token the user is currently authenticated with, effectively removing
356 the apps access and requiring the user to log in again.
357 """
358 if self.access_token is None:
359 raise MastodonIllegalArgumentError("Not logged in, do not have a token to revoke.")
360 if self.client_id is None or self.client_secret is None:
361 raise MastodonIllegalArgumentError("Client authentication (id + secret) is required to revoke tokens.")
362 params = collections.OrderedDict([])
363 params['client_id'] = self.client_id
364 params['client_secret'] = self.client_secret
365 params['token'] = self.access_token
366 self.__api_request('POST', '/oauth/revoke', params)
367
368 # We are now logged out, clear token and logged in id
369 self.access_token = None
370 self.__logged_in_id = None \ No newline at end of file
diff --git a/mastodon/instance.py b/mastodon/instance.py
new file mode 100644
index 0000000..88445d1
--- /dev/null
+++ b/mastodon/instance.py
@@ -0,0 +1,96 @@
1from .versions import _DICT_VERSION_INSTANCE, _DICT_VERSION_ACTIVITY
2from .error import MastodonIllegalArgumentError, MastodonNotFoundError
3from .utility import api_version
4from .compat import urlparse
5
6from .internals import Mastodon as Internals
7
8class Mastodon(Internals):
9 ###
10 # Reading data: Instances
11 ###
12 @api_version("1.1.0", "2.3.0", _DICT_VERSION_INSTANCE)
13 def instance(self):
14 """
15 Retrieve basic information about the instance, including the URI and administrative contact email.
16
17 Does not require authentication unless locked down by the administrator.
18
19 Returns an :ref:`instance dict <instance dict>`.
20 """
21 return self.__instance()
22
23 def __instance(self):
24 """
25 Internal, non-version-checking helper that does the same as instance()
26 """
27 instance = self.__api_request('GET', '/api/v1/instance/')
28 return instance
29
30 @api_version("2.1.2", "2.1.2", _DICT_VERSION_ACTIVITY)
31 def instance_activity(self):
32 """
33 Retrieve activity stats about the instance. May be disabled by the instance administrator - throws
34 a MastodonNotFoundError in that case.
35
36 Activity is returned for 12 weeks going back from the current week.
37
38 Returns a list of :ref:`activity dicts <activity dicts>`.
39 """
40 return self.__api_request('GET', '/api/v1/instance/activity')
41
42 @api_version("2.1.2", "2.1.2", "2.1.2")
43 def instance_peers(self):
44 """
45 Retrieve the instances that this instance knows about. May be disabled by the instance administrator - throws
46 a MastodonNotFoundError in that case.
47
48 Returns a list of URL strings.
49 """
50 return self.__api_request('GET', '/api/v1/instance/peers')
51
52 @api_version("3.0.0", "3.0.0", "3.0.0")
53 def instance_health(self):
54 """
55 Basic health check. Returns True if healthy, False if not.
56 """
57 status = self.__api_request('GET', '/health', parse=False).decode("utf-8")
58 return status in ["OK", "success"]
59
60 @api_version("3.0.0", "3.0.0", "3.0.0")
61 def instance_nodeinfo(self, schema="http://nodeinfo.diaspora.software/ns/schema/2.0"):
62 """
63 Retrieves the instance's nodeinfo information.
64
65 For information on what the nodeinfo can contain, see the nodeinfo
66 specification: https://github.com/jhass/nodeinfo . By default,
67 Mastodon.py will try to retrieve the version 2.0 schema nodeinfo.
68
69 To override the schema, specify the desired schema with the `schema`
70 parameter.
71 """
72 links = self.__api_request('GET', '/.well-known/nodeinfo')["links"]
73
74 schema_url = None
75 for available_schema in links:
76 if available_schema.rel == schema:
77 schema_url = available_schema.href
78
79 if schema_url is None:
80 raise MastodonIllegalArgumentError(
81 "Requested nodeinfo schema is not available.")
82
83 try:
84 return self.__api_request('GET', schema_url, base_url_override="")
85 except MastodonNotFoundError:
86 parse = urlparse(schema_url)
87 return self.__api_request('GET', parse.path + parse.params + parse.query + parse.fragment)
88
89 @api_version("3.4.0", "3.4.0", _DICT_VERSION_INSTANCE)
90 def instance_rules(self):
91 """
92 Retrieve instance rules.
93
94 Returns a list of `id` + `text` dicts, same as the `rules` field in the :ref:`instance dicts <instance dicts>`.
95 """
96 return self.__api_request('GET', '/api/v1/instance/rules')
diff --git a/mastodon/internals.py b/mastodon/internals.py
index 4ee2c5b..0e77421 100644
--- a/mastodon/internals.py
+++ b/mastodon/internals.py
@@ -13,7 +13,7 @@ import collections
13import base64 13import base64
14import os 14import os
15 15
16from .utility import AttribAccessDict, AttribAccessList 16from .utility import AttribAccessDict, AttribAccessList, parse_version_string
17from .error import MastodonNetworkError, MastodonIllegalArgumentError, MastodonRatelimitError, MastodonNotFoundError, \ 17from .error import MastodonNetworkError, MastodonIllegalArgumentError, MastodonRatelimitError, MastodonNotFoundError, \
18 MastodonUnauthorizedError, MastodonInternalServerError, MastodonBadGatewayError, MastodonServiceUnavailableError, \ 18 MastodonUnauthorizedError, MastodonInternalServerError, MastodonBadGatewayError, MastodonServiceUnavailableError, \
19 MastodonGatewayTimeoutError, MastodonServerError, MastodonAPIError, MastodonMalformedEventError 19 MastodonGatewayTimeoutError, MastodonServerError, MastodonAPIError, MastodonMalformedEventError
diff --git a/mastodon/utility.py b/mastodon/utility.py
index f393aa8..53980b6 100644
--- a/mastodon/utility.py
+++ b/mastodon/utility.py
@@ -2,7 +2,11 @@
2 2
3import re 3import re
4from decorator import decorate 4from decorator import decorate
5from .error import MastodonVersionError 5from .error import MastodonVersionError, MastodonAPIError
6import dateutil
7import datetime
8
9# Module level:
6 10
7### 11###
8# Version check functions, including decorator and parser 12# Version check functions, including decorator and parser
@@ -74,4 +78,66 @@ class AttribAccessList(list):
74 def __setattr__(self, attr, val): 78 def __setattr__(self, attr, val):
75 if attr in self: 79 if attr in self:
76 raise AttributeError("Attribute-style access is read only") 80 raise AttributeError("Attribute-style access is read only")
77 super(AttribAccessList, self).__setattr__(attr, val) \ No newline at end of file 81 super(AttribAccessList, self).__setattr__(attr, val)
82
83
84# Class level:
85class Mastodon():
86 def set_language(self, lang):
87 """
88 Set the locale Mastodon will use to generate responses. Valid parameters are all ISO 639-1 (two letter) or, for languages that do
89 not have one, 639-3 (three letter) language codes. This affects some error messages (those related to validation) and trends.
90 """
91 self.lang = lang
92
93 def retrieve_mastodon_version(self):
94 """
95 Determine installed Mastodon version and set major, minor and patch (not including RC info) accordingly.
96
97 Returns the version string, possibly including rc info.
98 """
99 try:
100 version_str = self.__normalize_version_string(self.__instance()["version"])
101 self.version_check_worked = True
102 except:
103 # instance() was added in 1.1.0, so our best guess is 1.0.0.
104 version_str = "1.0.0"
105 self.version_check_worked = False
106
107 self.mastodon_major, self.mastodon_minor, self.mastodon_patch = parse_version_string(version_str)
108 return version_str
109
110 def verify_minimum_version(self, version_str, cached=False):
111 """
112 Update version info from server and verify that at least the specified version is present.
113
114 If you specify "cached", the version info update part is skipped.
115
116 Returns True if version requirement is satisfied, False if not.
117 """
118 if not cached:
119 self.retrieve_mastodon_version()
120 major, minor, patch = parse_version_string(version_str)
121 if major > self.mastodon_major:
122 return False
123 elif major == self.mastodon_major and minor > self.mastodon_minor:
124 return False
125 elif major == self.mastodon_major and minor == self.mastodon_minor and patch > self.mastodon_patch:
126 return False
127 return True
128
129 def get_approx_server_time(self):
130 """
131 Retrieve the approximate server time
132
133 We parse this from the hopefully present "Date" header, but make no effort to compensate for latency.
134 """
135 response = self.__api_request("HEAD", "/", return_response_object=True)
136 if 'Date' in response.headers:
137 server_time_datetime = dateutil.parser.parse(response.headers['Date'])
138
139 # Make sure we're in local time
140 epoch_time = self.__datetime_to_epoch(server_time_datetime)
141 return datetime.datetime.fromtimestamp(epoch_time)
142 else:
143 raise MastodonAPIError("No server time in response.")
Powered by cgit v1.2.3 (git 2.41.0)