diff options
author | Lorenz Diener <[email protected]> | 2017-04-10 10:28:50 +0200 |
---|---|---|
committer | GitHub <[email protected]> | 2017-04-10 10:28:50 +0200 |
commit | 98f636f581d21b6ba44b3b313e1bc9b527cb4440 (patch) | |
tree | f50468135f65f51c2eed1dabb55c3ef9553f3e8b | |
parent | 795354a835e6ef934d2764e69f947fb6a742a6be (diff) | |
parent | cb6e304043332bab5d9a4768eb697979cd5d96c4 (diff) | |
download | mastodon.py-98f636f581d21b6ba44b3b313e1bc9b527cb4440.tar.gz |
Merge pull request #27 from azillion/master
Add support for OAuth2, resolves issue #6
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | mastodon/Mastodon.py | 139 |
2 files changed, 106 insertions, 35 deletions
@@ -90,3 +90,5 @@ ENV/ | |||
90 | 90 | ||
91 | # Secret files (for credentials used in testing) | 91 | # Secret files (for credentials used in testing) |
92 | *.secret | 92 | *.secret |
93 | pytooter_clientcred.txt | ||
94 | pytooter_usercred.txt \ No newline at end of file | ||
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index 1ae888d..def3859 100644 --- a/mastodon/Mastodon.py +++ b/mastodon/Mastodon.py | |||
@@ -1,17 +1,20 @@ | |||
1 | # coding: utf-8 | 1 | # coding: utf-8 |
2 | 2 | ||
3 | import requests | 3 | |
4 | import os | 4 | import os |
5 | from urllib.parse import urlencode | ||
5 | import os.path | 6 | import os.path |
6 | import mimetypes | 7 | import mimetypes |
7 | import time | 8 | import time |
8 | import random | 9 | import random |
9 | import string | 10 | import string |
10 | import pytz | ||
11 | import datetime | 11 | import datetime |
12 | import dateutil | ||
13 | import dateutil.parser | 12 | import dateutil.parser |
14 | 13 | ||
14 | import pytz | ||
15 | import dateutil | ||
16 | import requests | ||
17 | |||
15 | class Mastodon: | 18 | class Mastodon: |
16 | """ | 19 | """ |
17 | Super basic but thorough and easy to use mastodon.social | 20 | Super basic but thorough and easy to use mastodon.social |
@@ -19,9 +22,10 @@ class Mastodon: | |||
19 | 22 | ||
20 | If anything is unclear, check the official API docs at | 23 | If anything is unclear, check the official API docs at |
21 | https://github.com/Gargron/mastodon/wiki/API | 24 | https://github.com/Gargron/mastodon/wiki/API |
22 | 25 | ||
23 | Presently, only username-password login is supported, somebody please | 26 | Supported: |
24 | patch in Real Proper OAuth if desired. | 27 | Username-Password Login |
28 | OAuth2 | ||
25 | """ | 29 | """ |
26 | __DEFAULT_BASE_URL = 'https://mastodon.social' | 30 | __DEFAULT_BASE_URL = 'https://mastodon.social' |
27 | __DEFAULT_TIMEOUT = 300 | 31 | __DEFAULT_TIMEOUT = 300 |
@@ -31,7 +35,7 @@ class Mastodon: | |||
31 | # Registering apps | 35 | # Registering apps |
32 | ### | 36 | ### |
33 | @staticmethod | 37 | @staticmethod |
34 | def create_app(client_name, scopes = ['read', 'write', 'follow'], redirect_uris = None, to_file = None, api_base_url = __DEFAULT_BASE_URL, request_timeout = __DEFAULT_TIMEOUT): | 38 | 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 | """ | 39 | """ |
36 | Create a new app with given client_name and scopes (read, write, follow) | 40 | Create a new app with given client_name and scopes (read, write, follow) |
37 | 41 | ||
@@ -50,12 +54,15 @@ class Mastodon: | |||
50 | } | 54 | } |
51 | 55 | ||
52 | try: | 56 | try: |
53 | if redirect_uris != None: | 57 | if redirect_uris is not None: |
54 | request_data['redirect_uris'] = redirect_uris; | 58 | request_data['redirect_uris'] = redirect_uris; |
55 | else: | 59 | else: |
56 | request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'; | 60 | request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'; |
57 | 61 | if website is not None: | |
58 | response = requests.post(api_base_url + '/api/v1/apps', data = request_data, timeout = request_timeout).json() | 62 | request_data['website'] = website |
63 | |||
64 | response = requests.post(api_base_url + '/api/v1/apps', data = request_data, timeout = request_timeout) | ||
65 | response = response.json() | ||
59 | except Exception as e: | 66 | except Exception as e: |
60 | import traceback | 67 | import traceback |
61 | traceback.print_exc() | 68 | traceback.print_exc() |
@@ -99,6 +106,8 @@ class Mastodon: | |||
99 | self.access_token = access_token | 106 | self.access_token = access_token |
100 | self.debug_requests = debug_requests | 107 | self.debug_requests = debug_requests |
101 | self.ratelimit_method = ratelimit_method | 108 | self.ratelimit_method = ratelimit_method |
109 | self._token_expired = datetime.datetime.now() | ||
110 | self._refresh_token = None | ||
102 | 111 | ||
103 | self.ratelimit_limit = 150 | 112 | self.ratelimit_limit = 150 |
104 | self.ratelimit_reset = time.time() | 113 | self.ratelimit_reset = time.time() |
@@ -122,32 +131,92 @@ class Mastodon: | |||
122 | if self.access_token != None and os.path.isfile(self.access_token): | 131 | if self.access_token != None and os.path.isfile(self.access_token): |
123 | with open(self.access_token, 'r') as token_file: | 132 | with open(self.access_token, 'r') as token_file: |
124 | self.access_token = token_file.readline().rstrip() | 133 | self.access_token = token_file.readline().rstrip() |
125 | 134 | ||
126 | def log_in(self, username, password, scopes = ['read', 'write', 'follow'], to_file = None): | 135 | |
127 | """ | 136 | @property |
128 | Log in and sets access_token to what was returned. Note that your | 137 | def token_expired(self) -> bool: |
129 | username is the e-mail you use to log in into mastodon. | 138 | if self._token_expired < datetime.datetime.now(): |
130 | 139 | return True | |
131 | Can persist access token to file, to be used in the constructor. | 140 | else: |
132 | 141 | return False | |
133 | Will throw a MastodonIllegalArgumentError if username / password | 142 | |
134 | are wrong, scopes are not valid or granted scopes differ from requested. | 143 | @token_expired.setter |
135 | 144 | def token_expired(self, value: int): | |
136 | Returns the access_token. | 145 | self._token_expired = datetime.datetime.now() + datetime.timedelta(seconds=value) |
137 | """ | 146 | return |
138 | params = self.__generate_params(locals()) | 147 | |
148 | @property | ||
149 | def refresh_token(self) -> str: | ||
150 | return self._refresh_token | ||
151 | |||
152 | @refresh_token.setter | ||
153 | def refresh_token(self, value): | ||
154 | self._refresh_token = value | ||
155 | return | ||
156 | |||
157 | def auth_request_url(self, client_id: str = None, redirect_uris: str = "urn:ietf:wg:oauth:2.0:oob") -> str: | ||
158 | """Returns the url that a client needs to request the grant from the server. | ||
159 | https://mastodon.social/oauth/authorize?client_id=XXX&response_type=code&redirect_uris=YYY | ||
160 | """ | ||
161 | if client_id is None: | ||
162 | client_id = self.client_id | ||
163 | else: | ||
164 | if os.path.isfile(client_id): | ||
165 | with open(client_id, 'r') as secret_file: | ||
166 | client_id = secret_file.readline().rstrip() | ||
167 | |||
168 | params = {} | ||
169 | params['client_id'] = client_id | ||
170 | params['response_type'] = "code" | ||
171 | params['redirect_uri'] = redirect_uris | ||
172 | formatted_params = urlencode(params) | ||
173 | return "".join([self.api_base_url, "/oauth/authorize?", formatted_params]) | ||
174 | |||
175 | def log_in(self, username: str = None, password: str = None,\ | ||
176 | code: str = None, redirect_uri: str = "urn:ietf:wg:oauth:2.0:oob", refresh_token: str = None,\ | ||
177 | scopes: list = ['read', 'write', 'follow'], to_file: str = None) -> str: | ||
178 | """ | ||
179 | Docs: https://github.com/doorkeeper-gem/doorkeeper/wiki/Interacting-as-an-OAuth-client-with-Doorkeeper | ||
180 | |||
181 | Notes: | ||
182 | Your username is the e-mail you use to log in into mastodon. | ||
183 | |||
184 | Can persist access token to file, to be used in the constructor. | ||
185 | |||
186 | Supports refresh_token but Mastodon.social doesn't implement it at the moment. | ||
187 | |||
188 | Handles password, authorization_code, and refresh_token authentication. | ||
189 | |||
190 | Will throw a MastodonIllegalArgumentError if username / password | ||
191 | are wrong, scopes are not valid or granted scopes differ from requested. | ||
192 | |||
193 | Returns: | ||
194 | str @access_token | ||
195 | """ | ||
196 | if username is not None and password is not None: | ||
197 | params = self.__generate_params(locals(), ['scopes', 'to_file', 'code', 'refresh_token']) | ||
198 | params['grant_type'] = 'password' | ||
199 | elif code is not None: | ||
200 | params = self.__generate_params(locals(), ['scopes', 'to_file', 'username', 'password', 'refresh_token']) | ||
201 | params['grant_type'] = 'authorization_code' | ||
202 | elif refresh_token is not None: | ||
203 | params = self.__generate_params(locals(), ['scopes', 'to_file', 'username', 'password', 'code']) | ||
204 | params['grant_type'] = 'refresh_token' | ||
205 | else: | ||
206 | raise MastodonIllegalArgumentError('Invalid user name, password, redirect_uris or scopes') | ||
207 | |||
139 | params['client_id'] = self.client_id | 208 | params['client_id'] = self.client_id |
140 | params['client_secret'] = self.client_secret | 209 | params['client_secret'] = self.client_secret |
141 | params['grant_type'] = 'password' | 210 | |
142 | params['scope'] = " ".join(scopes) | ||
143 | |||
144 | try: | 211 | try: |
145 | response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting = False) | 212 | response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting = False) |
146 | self.access_token = response['access_token'] | 213 | self.access_token = response['access_token'] |
214 | self.refresh_token = response.get('refresh_token') | ||
215 | self.token_expired = int(response.get('expires_in', 0)) | ||
147 | except Exception as e: | 216 | except Exception as e: |
148 | import traceback | 217 | import traceback |
149 | traceback.print_exc() | 218 | traceback.print_exc() |
150 | raise MastodonIllegalArgumentError('Invalid user name, password or scopes: %s' % e) | 219 | raise MastodonIllegalArgumentError('Invalid user name, password, redirect_uris or scopes: %s' % e) |
151 | 220 | ||
152 | requested_scopes = " ".join(sorted(scopes)) | 221 | requested_scopes = " ".join(sorted(scopes)) |
153 | received_scopes = " ".join(sorted(response["scope"].split(" "))) | 222 | received_scopes = " ".join(sorted(response["scope"].split(" "))) |
@@ -700,13 +769,13 @@ class Mastodon: | |||
700 | if self.ratelimit_method == "throw": | 769 | if self.ratelimit_method == "throw": |
701 | raise MastodonRatelimitError("Hit rate limit.") | 770 | raise MastodonRatelimitError("Hit rate limit.") |
702 | 771 | ||
703 | if self.ratelimit_method == "wait" or self.ratelimit_method == "pace": | 772 | if self.ratelimit_method == "wait" or self.ratelimit_method == "pace": |
704 | to_next = self.ratelimit_reset - time.time() | 773 | to_next = self.ratelimit_reset - time.time() |
705 | if to_next > 0: | 774 | if to_next > 0: |
706 | # As a precaution, never sleep longer than 5 minutes | 775 | # As a precaution, never sleep longer than 5 minutes |
707 | to_next = min(to_next, 5 * 60) | 776 | to_next = min(to_next, 5 * 60) |
708 | time.sleep(to_next) | 777 | time.sleep(to_next) |
709 | request_complete = False | 778 | request_complete = False |
710 | 779 | ||
711 | return response | 780 | return response |
712 | 781 | ||