diff options
Diffstat (limited to 'mastodon')
-rw-r--r-- | mastodon/Mastodon.py | 353 | ||||
-rw-r--r-- | mastodon/__init__.py | 2 |
2 files changed, 355 insertions, 0 deletions
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py new file mode 100644 index 0000000..8d54455 --- /dev/null +++ b/mastodon/Mastodon.py | |||
@@ -0,0 +1,353 @@ | |||
1 | # coding: utf-8 | ||
2 | |||
3 | import requests | ||
4 | import os | ||
5 | import os.path | ||
6 | |||
7 | class Mastodon: | ||
8 | """ | ||
9 | Super basic but thorough and easy to use mastodon.social | ||
10 | api wrapper in python. | ||
11 | |||
12 | If anything is unclear, check the official API docs at | ||
13 | https://github.com/Gargron/mastodon/wiki/API | ||
14 | |||
15 | Presently, only username-password login is supported, somebody please | ||
16 | patch in Real Proper OAuth if desired. | ||
17 | |||
18 | KNOWN BUGS: Media api does not work, reason unclear. | ||
19 | """ | ||
20 | __DEFAULT_BASE_URL = 'https://mastodon.social' | ||
21 | |||
22 | ### | ||
23 | # Registering apps | ||
24 | ### | ||
25 | @staticmethod | ||
26 | def create_app(client_name, scopes = ['read', 'write', 'follow'], redirect_uris = None, to_file = None, api_base_url = __DEFAULT_BASE_URL): | ||
27 | """ | ||
28 | Creates a new app with given client_name and scopes (read, write, follow) | ||
29 | |||
30 | Specify redirect_uris if you want users to be redirected to a certain page after authenticating. | ||
31 | Specify to_file to persist your apps info to a file so you can use them in the constructor. | ||
32 | Specify api_base_url if you want to register an app on an instance different from the flagship one. | ||
33 | |||
34 | Returns client_id and client_secret. | ||
35 | """ | ||
36 | request_data = { | ||
37 | 'client_name': client_name, | ||
38 | 'scopes': " ".join(scopes) | ||
39 | } | ||
40 | |||
41 | if redirect_uris != None: | ||
42 | request_data['redirect_uris'] = redirect_uris; | ||
43 | else: | ||
44 | request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'; | ||
45 | |||
46 | response = requests.post(api_base_url + '/api/v1/apps', data = request_data).json() | ||
47 | |||
48 | if to_file != None: | ||
49 | with open(to_file, 'w') as secret_file: | ||
50 | secret_file.write(response['client_id'] + '\n') | ||
51 | secret_file.write(response['client_secret'] + '\n') | ||
52 | |||
53 | return (response['client_id'], response['client_secret']) | ||
54 | |||
55 | ### | ||
56 | # Authentication, including constructor | ||
57 | ### | ||
58 | def __init__(self, client_id, client_secret = None, access_token = None, api_base_url = __DEFAULT_BASE_URL): | ||
59 | """ | ||
60 | Creates a new API wrapper instance based on the given client_secret and client_id. If you | ||
61 | give a client_id and it is not a file, you must also give a secret. | ||
62 | |||
63 | You can also directly specify an access_token, directly or as a file. | ||
64 | |||
65 | Specify api_base_url if you wish to talk to an instance other than the flagship one. | ||
66 | If a file is given as client_id, read client ID and secret from that file | ||
67 | """ | ||
68 | self.api_base_url = api_base_url | ||
69 | self.client_id = client_id | ||
70 | self.client_secret = client_secret | ||
71 | self.access_token = access_token | ||
72 | |||
73 | if os.path.isfile(self.client_id): | ||
74 | with open(self.client_id, 'r') as secret_file: | ||
75 | self.client_id = secret_file.readline().rstrip() | ||
76 | self.client_secret = secret_file.readline().rstrip() | ||
77 | else: | ||
78 | if self.client_secret == None: | ||
79 | raise ValueError('Specified client id directly, but did not supply secret') | ||
80 | |||
81 | if self.access_token != None and os.path.isfile(self.access_token): | ||
82 | with open(self.access_token, 'r') as token_file: | ||
83 | self.access_token = token_file.readline().rstrip() | ||
84 | |||
85 | def log_in(self, username, password, scopes = ['read', 'write', 'follow'], to_file = None): | ||
86 | """ | ||
87 | Logs in and sets access_token to what was returned. | ||
88 | Can persist access token to file. | ||
89 | |||
90 | Returns the access_token, as well. | ||
91 | """ | ||
92 | params = self.__generate_params(locals()) | ||
93 | params['client_id'] = self.client_id | ||
94 | params['client_secret'] = self.client_secret | ||
95 | params['grant_type'] = 'password' | ||
96 | params['scope'] = " ".join(scopes) | ||
97 | |||
98 | response = self.__api_request('POST', '/oauth/token', params) | ||
99 | self.access_token = response['access_token'] | ||
100 | |||
101 | if to_file != None: | ||
102 | with open(to_file, 'w') as token_file: | ||
103 | token_file.write(response['access_token'] + '\n') | ||
104 | |||
105 | return response['access_token'] | ||
106 | |||
107 | ### | ||
108 | # Reading data: Timelines | ||
109 | ## | ||
110 | def timeline(self, timeline = 'home', max_id = None, since_id = None, limit = None): | ||
111 | """ | ||
112 | Returns statuses, most recent ones first. Timeline can be home, mentions, public | ||
113 | or tag/:hashtag | ||
114 | """ | ||
115 | params = self.__generate_params(locals(), ['timeline']) | ||
116 | return self.__api_request('GET', '/api/v1/timelines/' + timeline, params) | ||
117 | |||
118 | ### | ||
119 | # Reading data: Statuses | ||
120 | ### | ||
121 | def status(self, id): | ||
122 | """ | ||
123 | Returns a status. | ||
124 | """ | ||
125 | return self.__api_request('GET', '/api/v1/statuses/' + str(id)) | ||
126 | |||
127 | def status_context(self, id): | ||
128 | """ | ||
129 | Returns ancestors and descendants of the status. | ||
130 | """ | ||
131 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/context') | ||
132 | |||
133 | def status_reblogged_by(self, id): | ||
134 | """ | ||
135 | Returns a list of users that have reblogged a status. | ||
136 | """ | ||
137 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/reblogged_by') | ||
138 | |||
139 | def status_favourited_by(self, id): | ||
140 | """ | ||
141 | Returns a list of users that have favourited a status. | ||
142 | """ | ||
143 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/favourited_by') | ||
144 | |||
145 | ### | ||
146 | # Reading data: Accounts | ||
147 | ### | ||
148 | def account(self, id): | ||
149 | """ | ||
150 | Returns account. | ||
151 | """ | ||
152 | return self.__api_request('GET', '/api/v1/accounts/' + str(id)) | ||
153 | |||
154 | def account_verify_credentials(self): | ||
155 | """ | ||
156 | Returns authenticated user's account. | ||
157 | """ | ||
158 | return self.__api_request('GET', '/api/v1/accounts/verify_credentials') | ||
159 | |||
160 | def account_statuses(self, id, max_id = None, since_id = None, limit = None): | ||
161 | """ | ||
162 | Returns statuses by user. Same options as timeline are permitted. | ||
163 | """ | ||
164 | params = self.__generate_params(locals(), ['id']) | ||
165 | return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/statuses') | ||
166 | |||
167 | def account_following(self, id): | ||
168 | """ | ||
169 | Returns users the given user is following. | ||
170 | """ | ||
171 | return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/following') | ||
172 | |||
173 | def account_followers(self, id): | ||
174 | """ | ||
175 | Returns users the given user is followed by. | ||
176 | """ | ||
177 | return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/followers') | ||
178 | |||
179 | def account_relationships(self, id): | ||
180 | """ | ||
181 | Returns relationships (following, followed_by, blocking) of the logged in user to | ||
182 | a given account. id can be a list. | ||
183 | """ | ||
184 | params = self.__generate_params(locals()) | ||
185 | return self.__api_request('GET', '/api/v1/accounts/relationships', params) | ||
186 | |||
187 | def account_suggestions(self): | ||
188 | """ | ||
189 | Returns accounts that the system suggests the authenticated user to follow. | ||
190 | """ | ||
191 | return self.__api_request('GET', '/api/v1/accounts/suggestions') | ||
192 | |||
193 | def account_search(self, q, limit = None): | ||
194 | """ | ||
195 | Returns matching accounts. Will lookup an account remotely if the search term is | ||
196 | in the username@domain format and not yet in the database. | ||
197 | """ | ||
198 | params = self.__generate_params(locals()) | ||
199 | return self.__api_request('GET', '/api/v1/accounts/search', params) | ||
200 | |||
201 | ### | ||
202 | # Writing data: Statuses | ||
203 | ### | ||
204 | def status_post(self, status, in_reply_to_id = None, media_ids = None): | ||
205 | """ | ||
206 | Posts a status. Can optionally be in reply to another status and contain | ||
207 | up to four pieces of media (Uploaded via media_post()). | ||
208 | |||
209 | Returns the new status. | ||
210 | """ | ||
211 | params = self.__generate_params(locals()) | ||
212 | return self.__api_request('POST', '/api/v1/statuses', params) | ||
213 | |||
214 | def toot(self, status): | ||
215 | """ | ||
216 | Synonym for status_post that only takes the status text as input. | ||
217 | """ | ||
218 | return self.status_post(status) | ||
219 | |||
220 | def status_delete(self, id): | ||
221 | """ | ||
222 | Deletes a status | ||
223 | """ | ||
224 | return self.__api_request('DELETE', '/api/v1/statuses/' + str(id)) | ||
225 | |||
226 | def status_reblog(self, id): | ||
227 | """Reblogs a status. | ||
228 | |||
229 | Returns a new status that wraps around the reblogged one.""" | ||
230 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/reblog") | ||
231 | |||
232 | def status_unreblog(self, id): | ||
233 | """ | ||
234 | Un-reblogs a status. | ||
235 | |||
236 | Returns the status that used to be reblogged. | ||
237 | """ | ||
238 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unreblog") | ||
239 | |||
240 | def status_favourite(self, id): | ||
241 | """ | ||
242 | Favourites a status. | ||
243 | |||
244 | Returns the favourited status. | ||
245 | """ | ||
246 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/favourite") | ||
247 | |||
248 | def status_unfavourite(self, id): | ||
249 | """Favourites a status. | ||
250 | |||
251 | Returns the un-favourited status. | ||
252 | """ | ||
253 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unfavourite") | ||
254 | |||
255 | ### | ||
256 | # Writing data: Statuses | ||
257 | ### | ||
258 | def account_follow(self, id): | ||
259 | """ | ||
260 | Follows a user. | ||
261 | |||
262 | Returns the updated relationship to the user. | ||
263 | """ | ||
264 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/follow") | ||
265 | |||
266 | def account_unfollow(self, id): | ||
267 | """ | ||
268 | Unfollows a user. | ||
269 | |||
270 | Returns the updated relationship to the user. | ||
271 | """ | ||
272 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unfollow") | ||
273 | |||
274 | def account_block(self, id): | ||
275 | """ | ||
276 | Blocks a user. | ||
277 | |||
278 | Returns the updated relationship to the user. | ||
279 | """ | ||
280 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/block") | ||
281 | |||
282 | def account_unblock(self, id): | ||
283 | """ | ||
284 | Unblocks a user. | ||
285 | |||
286 | Returns the updated relationship to the user. | ||
287 | """ | ||
288 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unblock") | ||
289 | |||
290 | ### | ||
291 | # Writing data: Media | ||
292 | ### | ||
293 | def media_post(self, media_file): | ||
294 | """ | ||
295 | Posts an image. media_file can either be image data or | ||
296 | a file name. | ||
297 | |||
298 | Returns the ID of the media that can then be used in status_post(). | ||
299 | """ | ||
300 | if os.path.isfile(media_file): | ||
301 | media_file = open(media_file, 'rb') | ||
302 | |||
303 | return self.__api_request('POST', '/api/v1/media', files = {'file': media_file}) | ||
304 | |||
305 | ### | ||
306 | # Internal helpers, dragons probably | ||
307 | ### | ||
308 | def __api_request(self, method, endpoint, params = {}, files = {}): | ||
309 | """ | ||
310 | Internal API request helper. | ||
311 | """ | ||
312 | response = None | ||
313 | headers = None | ||
314 | |||
315 | if self.access_token != None: | ||
316 | headers = {'Authorization': 'Bearer ' + self.access_token} | ||
317 | |||
318 | if method == 'GET': | ||
319 | response = requests.get(self.api_base_url + endpoint, data = params, headers = headers, files = files) | ||
320 | |||
321 | if method == 'POST': | ||
322 | response = requests.post(self.api_base_url + endpoint, data = params, headers = headers, files = files) | ||
323 | |||
324 | if method == 'DELETE': | ||
325 | response = requests.delete(self.api_base_url + endpoint, data = params, headers = headers, files = files) | ||
326 | |||
327 | if response.status_code == 404: | ||
328 | raise IOError('Endpoint not found.') | ||
329 | |||
330 | if response.status_code == 500: | ||
331 | raise IOError('General API problem.') | ||
332 | |||
333 | return response.json() | ||
334 | |||
335 | def __generate_params(self, params, exclude = []): | ||
336 | """ | ||
337 | Internal named-parameters-to-dict helper. | ||
338 | """ | ||
339 | params = dict(params) | ||
340 | |||
341 | del params['self'] | ||
342 | param_keys = list(params.keys()) | ||
343 | for key in param_keys: | ||
344 | if params[key] == None or key in exclude: | ||
345 | del params[key] | ||
346 | |||
347 | param_keys = list(params.keys()) | ||
348 | for key in param_keys: | ||
349 | if isinstance(params[key], list): | ||
350 | params[key + "[]"] = params[key] | ||
351 | del params[key] | ||
352 | |||
353 | return params | ||
diff --git a/mastodon/__init__.py b/mastodon/__init__.py new file mode 100644 index 0000000..0c0744e --- /dev/null +++ b/mastodon/__init__.py | |||
@@ -0,0 +1,2 @@ | |||
1 | from Mastodon import Mastodon | ||
2 | __all__ = ['Mastodon'] | ||