aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLorenz Diener <[email protected]>2017-04-10 10:28:50 +0200
committerGitHub <[email protected]>2017-04-10 10:28:50 +0200
commit98f636f581d21b6ba44b3b313e1bc9b527cb4440 (patch)
treef50468135f65f51c2eed1dabb55c3ef9553f3e8b
parent795354a835e6ef934d2764e69f947fb6a742a6be (diff)
parentcb6e304043332bab5d9a4768eb697979cd5d96c4 (diff)
downloadmastodon.py-98f636f581d21b6ba44b3b313e1bc9b527cb4440.tar.gz
Merge pull request #27 from azillion/master
Add support for OAuth2, resolves issue #6
-rw-r--r--.gitignore2
-rw-r--r--mastodon/Mastodon.py139
2 files changed, 106 insertions, 35 deletions
diff --git a/.gitignore b/.gitignore
index 9c28bab..e146528 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
93pytooter_clientcred.txt
94pytooter_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
3import requests 3
4import os 4import os
5from urllib.parse import urlencode
5import os.path 6import os.path
6import mimetypes 7import mimetypes
7import time 8import time
8import random 9import random
9import string 10import string
10import pytz
11import datetime 11import datetime
12import dateutil
13import dateutil.parser 12import dateutil.parser
14 13
14import pytz
15import dateutil
16import requests
17
15class Mastodon: 18class 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
Powered by cgit v1.2.3 (git 2.41.0)