├── .gitignore ├── README.md ├── README.rst ├── scratchapi.py ├── scratchapi_new.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /.idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scratchapi # 2 | scratchapi is a web API interface for [Scratch](https://scratch.mit.edu), written in [Python](https://www.python.org/). 3 | 4 | To get started, install it via pip by running `pip install scratchapi` 5 | Alternatively, you can download this repository and run `python setup.py install` 6 | 7 | ## Getting Started ## 8 | To use the api, you must log in to your scratch account: 9 | ```python 10 | import scratchapi 11 | scratch = scratchapi.ScratchUserSession('Username', 'password') 12 | ``` 13 | 14 | ### After login ### 15 | Now, you can verify your session to see if you logged in correctly: 16 | ```python 17 | scratch.tools.verify_session() # Should return True 18 | ``` 19 | There are a lot of things you can you when you're logged in! 20 | 21 | Take ownership of a new project: 22 | ```python 23 | scratch.lib.utils.request(path='/internalapi/project/new/set/?v=v442&title=Project', server=scratch.PROJECTS_SERVER, method='POST', payload={}) 24 | ``` 25 | 26 | Follow Someone: 27 | ```python 28 | scratch.users.follow('Bob') 29 | ``` 30 | 31 | Set a cloud variable: 32 | ```python 33 | s.cloud.set_var('Variable', 12345, 4453648) 34 | ``` 35 | 36 | ## Documentation ## 37 | I apologize for a lack of documentation at this very moment, some is on the way, and some is already located on scratchapi's [wiki](https://github.com/Dylan5797/scratchapi/wiki). 38 | 39 | ## Credits ## 40 | Some of the cloud data interface information was acquired from various topics on the [Scratch Forums](https://scratch.mit.edu/discuss). 41 | 42 | Certain code snips were based off [scratch-api](https://github.com/trumank/scratch-api), by Truman Kilen. 43 | 44 | [TheLogFather](https://github.com/TheLogFather) helped out with various wrappers and conveniences for cloud data. 45 | 46 | If you're using scratchapi for your project, I'd appreciate if you would give credit to me and my scratch account, [@Dylan5797](https://scratch.mit.edu/users/Dylan5797/). 47 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ScratchAPI 2 | 3 | Scratch API Interface 4 | 5 | ScratchAPI is a scratch API interface written in Python. 6 | 7 | To get started, install it with pip install scratchapi 8 | 9 | Logging in 10 | 11 | To use the api, you must log in to your scratch account: 12 | 13 | import scratchapi 14 | scratch = scratchapi.ScratchUserSession('Username', 'password') 15 | 16 | Now, you can verify your session to see if you logged in correctly: 17 | 18 | scratch.tools.verify_session() 19 | 20 | There are a lot of things you can you when you're logged in! 21 | 22 | Take ownership of a new project: 23 | 24 | scratch.lib.utils.request(path='/internalapi/project/new/set/?v=v442&title=Project', server=scratch.PROJECTS_SERVER, method='POST', payload={}) 25 | 26 | Follow Someone: 27 | 28 | scratch.users.follow('Bob') 29 | 30 | Set a cloud variable: 31 | 32 | s.cloud.set_var('Variable', 12345, 4453648) 33 | 34 | Credits 35 | The cloud data interface information was acquired from various topics on the Scratch Forums. 36 | 37 | TheLogFather helped out with various wrappers and conveniences for cloud data. 38 | -------------------------------------------------------------------------------- /scratchapi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Copyright (c) 2015 Dylan Beswick 6 | The MIT License (MIT) 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files 9 | (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, 10 | publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do 11 | so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 17 | FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 18 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | """ 20 | 21 | 22 | # ScratchAPI 1.0 23 | # Written by Dylan5797 [http://dylan4.com] 24 | # _____ _ _____ ______ ___ ______ 25 | # | __ \ | | | ____|____ / _ \____ | 26 | # | | | |_ _| | __ _ _ __ | |__ / / (_) | / / 27 | # | | | | | | | |/ _ | _ \|___ \ / / \__ | / / 28 | # | |__| | |_| | | (_| | | | |___) | / / / / / / 29 | # |_____/ \__ |_|\__|_|_| |_|____/ /_/ /_/ /_/ 30 | # __/ | 31 | # |___/ 32 | 33 | import requests as _requests 34 | import json as _json 35 | import socket as _socket 36 | import hashlib as _hashlib 37 | import os as _os 38 | import time as _time 39 | import webbrowser as _web 40 | 41 | class ScratchUserSession: 42 | def __init__(self, username, password, remember_password=False): 43 | self.SERVER = 'scratch.mit.edu' 44 | self.API_SERVER = 'api.scratch.mit.edu' 45 | self.PROJECTS_SERVER = 'projects.scratch.mit.edu' 46 | self.ASSETS_SERVER = 'assets.scratch.mit.edu' 47 | self.CDN_SERVER = 'cdn.scratch.mit.edu' 48 | self.CLOUD = 'cloud.scratch.mit.edu' 49 | self.CLOUD_PORT = 531 50 | 51 | self.lib.utils.request = self._request 52 | self.lib.set.username = username 53 | self.lib.set.password = None 54 | self.lib.set.password_remembered = remember_password 55 | if remember_password: 56 | self.lib.set.password = password 57 | self.lib.utils.session = _requests.session() 58 | 59 | self.tools.verify_session = self._tools_verifySession 60 | self.tools.update = self._tools_update 61 | self.tools.reload_session = self._tools_reload_session 62 | 63 | self.projects.get = self._projects_getProject 64 | self.projects.set = self._projects_setProject 65 | self.projects.comment = self._projects_comment 66 | self.projects.get_meta = self._projects_get_meta 67 | self.projects.get_remix_data = self._projects_get_remixtree 68 | 69 | self.backpack.get = self._backpack_getBackpack 70 | self.backpack.set = self._backpack_setBackpack 71 | 72 | self.userpage.set_bio = self._userpage_setBio 73 | self.userpage.set_status = self._userpage_setStatus 74 | self.userpage.toggle_comments = self._userpage_toggleComments 75 | 76 | self.users.follow = self._users_follow 77 | self.users.unfollow = self._users_unfollow 78 | self.users.get_message_count = self._users_get_message_count 79 | self.users.comment = self._users_comment 80 | 81 | self.messages.get_message_count = self._users_get_message_count 82 | self.messages.get_message_html = self._get_message_html 83 | 84 | self.studios.comment = self._studios_comment 85 | self.studios.get_meta = self._studios_get_meta 86 | 87 | self.cloud.set_var = self._cloud_setvar 88 | self.cloud.create_var = self._cloud_makevar 89 | self.cloud.get_var = self._cloud_getvar 90 | self.cloud.get_vars = self._cloud_getvars 91 | self.cloud.rename_var = self._cloud_rename_var 92 | self.cloud.delete_var = self._cloud_delete_var 93 | 94 | self.HEADERS = {'X-Requested-With': 'XMLHttpRequest', 'Referer':'https://scratch.mit.edu/'} 95 | self.lib.utils.request(path='/csrf_token/', update=False) 96 | self.HEADERS['Cookie'] = 'scratchcsrftoken=' + self.lib.utils.session.cookies.get('scratchcsrftoken') + '; scratchlanguage=en' 97 | self.HEADERS['X-CSRFToken'] = self.lib.utils.session.cookies.get('scratchcsrftoken') 98 | self.lib.utils.request(path='/login/', method='post', update=False, payload=_json.dumps({'username': username, 'password': password, 'csrftoken':self.lib.utils.session.cookies.get('scratchcsrftoken'), 'csrfmiddlewaretoken':self.lib.utils.session.cookies.get('scratchcsrftoken'),'captcha_challenge':'','captcha_response':'','embed_captcha':False,'timezone':'America/New_York'})) 99 | self.tools.update() 100 | def _projects_getProject(self, projectId): 101 | return self.lib.utils.request(path='/internalapi/project/' + projectId + '/get/', server=self.PROJECTS_SERVER).json() 102 | def _projects_setProject(self, projectId, payload): 103 | return self.lib.utils.request(server=self.PROJECTS_SERVER, path='/internalapi/project/' + projectId + '/set/', payload=_json.dumps(payload), method='post') 104 | def _projects_get_meta(self, projid): 105 | return self.lib.utils.request(path='/api/v1/project/' + str(projid) + '/?format=json').json() 106 | def _projects_get_remixtree(self, projid): 107 | return self.lib.utils.request(path='/projects/' + str(projid) + '/remixtree/bare/').json() 108 | def _tools_verifySession(self): 109 | return self.lib.utils.request(path='/messages/ajax/get-message-count/', port=None).status_code == 200 110 | def _tools_reload_session(self, password=None, remember_password=None): 111 | if remember_password == None: 112 | remember_password = self.lib.set.password_remembered 113 | if (password == None) and (not self.lib.set.password_remembered): 114 | raise AttributeError('Password not stored in class (use ScratchUserSesssion(\'User\', \'Password\', remember_password=True) to remember password, or supply your password in ScratchUserSession.tools.reload_session())') 115 | if password == None: 116 | password = self.lib.set.password 117 | self.__init__(self.lib.set.username, password, remember_password=remember_password) 118 | def _backpack_getBackpack(self): 119 | return self.lib.utils.request(path='/internalapi/backpack/' + self.lib.set.username + '/get/').json() 120 | def _backpack_setBackpack(self, payload): 121 | return self.lib.utils.request(server=self.CDN_SERVER, path='/internalapi/backpack/' + self.lib.set.username + '/set/', method="post", payload=payload) 122 | def _userpage_setStatus(self, payload): 123 | p2 = self.lib.utils.request(path='/site-api/users/all/' + self.lib.set.username).json() 124 | p = {} 125 | for i in p2: 126 | if i in ['comments_allowed', 'id', 'status', 'thumbnail_url', 'userId', 'username']: 127 | p[i] = p2[i] 128 | p['status'] = payload 129 | return self.lib.utils.request(path='/site-api/users/all/' + self.lib.set.username, method="put", payload=_json.dumps(p)) 130 | def _userpage_toggleComments(self): 131 | return self.lib.utils.request(path='/site-api/comments/user/' + self.lib.set.username + '/toggle-comments/', method="put", payload=_json.dumps(p)) 132 | def _userpage_setBio(self, payload): 133 | p2 = self.lib.utils.request(path='/site-api/users/all/' + self.lib.set.username).json() 134 | p = {} 135 | for i in p2: 136 | if i in ['comments_allowed', 'id', 'bio', 'thumbnail_url', 'userId', 'username']: 137 | p[i] = p2[i] 138 | p['bio'] = payload 139 | return self.lib.utils.request(path='/site-api/users/all/' + self.lib.set.username, method="put", payload=_json.dumps(p)) 140 | def _users_get_meta(self, usr): 141 | return self.lib.utils.request(path='/users/' + usr, server=self.API_SERVER).json() 142 | def _users_follow(self, usr): 143 | return self.lib.utils.request(path='/site-api/users/followers/' + usr + '/add/?usernames=' + self.lib.set.username, method='PUT') 144 | def _users_unfollow(self, usr): 145 | return self.lib.utils.request(path='/site-api/users/followers/' + usr + '/remove/?usernames=' + self.lib.set.username, method='PUT') 146 | def _users_comment(self, user, comment): 147 | return self.lib.utils.request(path='/site-api/comments/user/' + user + '/add/', method='POST', payload=_json.dumps({"content":comment,"parent_id":'',"commentee_id":''})) 148 | def _studios_comment(self, studioid, comment): 149 | return self.lib.utils.request(path='/site-api/comments/gallery/' + str(studioid) + '/add/', method='POST', payload=_json.dumps({"content":comment,"parent_id":'',"commentee_id":''})) 150 | def _studios_get_meta(self, studioid): 151 | return self.lib.utils.request(path='/site-api/galleries/all/' + str(studioid)).json() 152 | def _studios_invite(self, studioid, user): 153 | return self.lib.utils.request(path='/site-api/users/curators-in/' + str(studioid) + '/invite_curator/?usernames=' + user, method='PUT') 154 | def _projects_comment(self, projid, comment): 155 | return self.lib.utils.request(path='/site-api/comments/project/' + str(projid) + '/add/', method='POST', payload=_json.dumps({"content":comment,"parent_id":'',"commentee_id":''})) 156 | def _cloud_setvar(self, var, value, projId): 157 | return self._cloud_send('set', projId, {'name': '☁ ' + var, 'value': value}) 158 | def _cloud_makevar(self, var, value, projId): 159 | return self._cloud_send('create', projId, {'name': '☁ ' + var}) 160 | def _cloud_rename_var(self, oldname, newname, projId): 161 | self._cloud_send('rename', projId, {'name': '☁ ' + oldname, 'new_name': '☁ ' + newname}) 162 | def _cloud_delete_var(self, name, projId): 163 | self._cloud_send('delete', projId, {'name':'☁ ' + name}) 164 | def _cloud_getvar(self, var, projId): 165 | return self._cloud_getvars(projId)[var] 166 | def _cloud_getvars(self, projId): 167 | dt = self.lib.utils.request(path='/varserver/' + str(projId)).json()['variables'] 168 | vardict = {} 169 | for x in dt: 170 | xn = x['name'] 171 | if xn.startswith('☁ '): 172 | vardict[xn[2:]] = x['value'] 173 | else: 174 | vardict[xn] = x['value'] 175 | return vardict 176 | def _cloud_send(self, method, projId, options): 177 | cloudToken = self.lib.utils.request(method='GET', path='/projects/' + str(projId) + '/cloud-data.js').text.rsplit('\n')[-28].replace(' ', '')[13:49] 178 | bc = _hashlib.md5() 179 | bc.update(cloudToken.encode()) 180 | data = { 181 | "token": cloudToken, 182 | "token2": bc.hexdigest(), 183 | "project_id": str(projId), 184 | "method": str(method), 185 | "user": self.lib.set.username, 186 | } 187 | data.update(options) 188 | return self.lib.utils.request(method='POST', path='/varserver', payload=_json.dumps(data)) 189 | def _tools_update(self): 190 | self.lib.set.csrf_token = self.lib.utils.session.cookies.get('scratchcsrftoken') 191 | self.lib.set.sessions_id = self.lib.utils.session.cookies.get('scratchsessionsid') 192 | self.HEADERS['Cookie'] = 'scratchcsrftoken=' + self.lib.utils.session.cookies.get_dict()['scratchcsrftoken'] + '; scratchsessionsid=' + self.lib.utils.session.cookies.get('scratchsessionsid') + '; scratchlanguage=en' 193 | self.HEADERS['X-CSRFToken'] = self.lib.utils.session.cookies.get('scratchcsrftoken') 194 | def _assets_get(self, md5): 195 | return self.lib.utils.request(path='/internalapi/asset/' + md5 + '/get/', server=self.ASSETS_SERVER).content 196 | def _assets_set(self, md5, content, content_type=None): 197 | if not content_type: 198 | if _os.path.splitext(md5)[-1] == '.png': 199 | content_type = 'image/png' 200 | elif _os.path.splitext(md5)[-1] == '.svg': 201 | content_type = 'image/svg+xml' 202 | elif _os.path.splitext(md5)[-1] == '.wav': 203 | content_type = 'audio/wav' 204 | else: 205 | content_type = 'text/plain' 206 | headers = {'Content-Length':str(len(content)), 207 | 'Origin':'https://cdn.scratch.mit.edu', 208 | 'Content-Type':content_type, 209 | 'Referer':'https://cdn.scratch.mit.edu/scratchr2/static/__cc77646ad8a4b266f015616addd66756__/Scratch.swf'} 210 | return self.lib.utils.request(path='/internalapi/asset/' + md5 + '/set/', method='POST', server=self.ASSETS_SERVER, payload=content) 211 | def _users_get_message_count(self, user=None): 212 | if user == None: 213 | user = self.lib.set.username 214 | return self.lib.utils.request(path='/proxy/users/' + user + '/activity/count', server=self.API_SERVER).json()['msg_count'] 215 | def _get_message_html(self): 216 | return self.lib.utils.request(path='/messages/') 217 | def _request(self, **options): 218 | headers = {} 219 | for x in self.HEADERS: 220 | headers[x] = self.HEADERS[x] 221 | method = "get" 222 | server = self.SERVER 223 | port = '' 224 | update = True 225 | retry = 3 226 | if 'method' in options: 227 | method = options['method'] 228 | if 'server' in options: 229 | server = options['server'] 230 | if 'payload' in options: 231 | headers['Content-Length'] = len(str(options['payload'])) 232 | if 'port' in options: 233 | if options['port'] == None: 234 | port = '' 235 | else: 236 | port = ':' + str(options['port']) 237 | if 'update' in options: 238 | if options['update'] == True: 239 | self.tools.update() 240 | else: 241 | update = False 242 | else: 243 | self.tools.update() 244 | if 'headers' in options: 245 | headers.update(options['headers']) 246 | if 'retry' in options: 247 | retry = options['retry'] 248 | server = 'https://' + server 249 | headers = {x:str(headers[x]) for x in headers} 250 | def request(): 251 | if 'payload' in options: 252 | return getattr(self.lib.utils.session, method.lower())(server + port + options['path'], data=options['payload'], headers=headers) 253 | else: 254 | return getattr(self.lib.utils.session, method.lower())(server + port + options['path'], headers=headers) 255 | success = False 256 | for x in range(0, retry): 257 | try: 258 | r = request() 259 | except _requests.exceptions.BaseHTTPError: 260 | continue 261 | except AttributeError: 262 | raise ValueError('Unknown HTTP method') 263 | else: 264 | success = True 265 | break 266 | if not success: 267 | raise ConnectionError('Connection failed on all ' + str(retry) + ' attempts') 268 | if update: 269 | self.tools.update() 270 | return r 271 | class lib: 272 | class set: pass 273 | class utils: pass 274 | class tools: pass 275 | class projects: pass 276 | class backpack: pass 277 | class userpage: pass 278 | class users: pass 279 | class messages: pass 280 | class studios: pass 281 | class cloud: pass 282 | 283 | 284 | class CloudSession: 285 | def __init__(self, projectId, session): 286 | if type(session) == ScratchUserSession: 287 | self._scratch = session 288 | else: 289 | self._scratch = ScratchUserSession(session[0], session[1]) 290 | self._user = self._scratch.lib.set.username 291 | self._projectId = projectId 292 | self._cloudId = self._scratch.lib.set.sessions_id 293 | self._token = self._scratch.lib.utils.request(method='GET', path='/projects/' + str(self._projectId) + '/cloud-data.js').text.rsplit('\n')[-28].replace(' ', '')[13:49] 294 | md5 = _hashlib.md5() 295 | md5.update(self._cloudId.encode()) 296 | self._reset_last = _time.time() 297 | self._reset = True 298 | self._reset_interval = 300 299 | self._rollover = [] 300 | self._md5token = md5.hexdigest() 301 | self._connection = _socket.create_connection((self._scratch.CLOUD, self._scratch.CLOUD_PORT)) 302 | self._send('handshake', {}) 303 | def _send(self, method, options): 304 | if self._reset: 305 | if _time.time() > (self._reset_last + self._reset_interval): 306 | reset_interval = self._reset_interval 307 | self.__init__(self._projectId, self._scratch) 308 | self._reset_interval = reset_interval 309 | obj = { 310 | 'token': self._token, 311 | 'token2': self._md5token, 312 | 'user': self._user, 313 | 'project_id': str(self._projectId), 314 | 'method': method 315 | } 316 | obj.update(options) 317 | ob = (_json.dumps(obj) + '\r\n').encode('utf-8') 318 | try: 319 | self._connection.send(ob) 320 | except BrokenPipeError: 321 | self.__init__(self._projectId, self._scratch) 322 | self._connection.send(ob) 323 | md5 = _hashlib.md5() 324 | md5.update(self._md5token.encode()) 325 | self._md5token = md5.hexdigest() 326 | 327 | def set_var(self, name, value): 328 | self._send('set', {'name': '☁ ' + name, 'value': value}) 329 | 330 | def create_var(self, name, value=None): 331 | if value == None: 332 | value = 0 333 | self._send('create', {'name': '☁ ' + name, 'value':value}) 334 | 335 | def rename_var(self, oldname, newname): 336 | self._send('rename', {'name': '☁ ' + oldname, 'new_name': '☁ ' + newname}) 337 | 338 | def delete_var(self, name): 339 | self._send('delete', {'name':'☁ ' + name}) 340 | 341 | def get_var(self, name): 342 | return self._scratch.cloud.get_var(name, self._projectId) 343 | 344 | def get_vars(self): 345 | return self._scratch.cloud.get_vars(self._projectId) 346 | 347 | def get_updates(self, timeout, max_count=10): 348 | count = 0 349 | updates = [] 350 | self._connection.settimeout(timeout) 351 | while count < max_count: 352 | data = ''.encode('utf-8') 353 | while True: 354 | try: 355 | data = data + self._connection.recv(4096) 356 | if data[-1] == 10: 357 | break 358 | self._connection.settimeout(0.1) 359 | except: 360 | break 361 | if not data: 362 | break 363 | self._connection.settimeout(0.01) 364 | if data[0] == 123: 365 | self._rollover = [] 366 | data = self._rollover + data.decode('utf-8').split('\n') 367 | if data[-1]: 368 | self._rollover = [data[-1]] 369 | else: 370 | self._rollover = [] 371 | data = data[:-1] 372 | for line in data: 373 | if line: 374 | try: 375 | line = _json.loads(line) 376 | name = line['name'] 377 | value = line['value'] 378 | if name.startswith('☁ '): 379 | updates.append((name[2:], value)) 380 | else: 381 | updates.append((name, value)) 382 | count = count + 1 383 | except: 384 | continue 385 | self._connection.settimeout(None) 386 | return updates 387 | 388 | def get_new_values(self, timeout, max_values=10): 389 | nv = {} 390 | for x in self.get_updates(timeout, max_values): 391 | nv[x[0]] = x[1] 392 | return nv 393 | 394 | class _docs_view(): 395 | def __call__(self): 396 | _web.open("https://github.com/Dylan5797/scratchapi/wiki/") 397 | def __repr__(self): 398 | return "See https://github.com/Dylan5797/scratchapi/wiki/ or call scratchapi.__doc__()" 399 | __doc__ = _docs_view() 400 | -------------------------------------------------------------------------------- /scratchapi_new.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Copyright (c) 2015 dyspore.cc 6 | The MIT License (MIT) 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files 9 | (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, 10 | publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do 11 | so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 17 | FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 18 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | """ 20 | 21 | # ScratchAPI 2.0 22 | # Written by Dylan5797/PolyEdge [http://dyspore.cc] 23 | # __ 24 | # ____/ /_ ___________ ____ ________ __________ 25 | # / __ / / / / ___/ __ \/ __ \/ ___/ _ \ / ___/ ___/ 26 | # / /_/ / /_/ (__ ) /_/ / /_/ / / / __// /__/ /__ 27 | # \__,_/\__, /____/ .___/\____/_/ \___(_)___/\___/ 28 | # /____/ /_/ 29 | 30 | import traceback as _traceback 31 | import requests as _requests 32 | import json as _json 33 | import hashlib as _hashlib 34 | import os as _os 35 | import re as _re 36 | import time as _time 37 | import webbrowser as _web 38 | import asyncio as _asyncio 39 | import websockets as _websockets 40 | import websockets.client as _websockets_client 41 | import warnings as _warnings 42 | import io as _io 43 | import zipfile as _zipfile 44 | import urllib.parse as _urllib_parse 45 | 46 | 47 | # EXCEPTIONS 48 | 49 | class ScratchAPIExceptionBase(BaseException): "Base exception for the scratchapi module" 50 | 51 | 52 | class Unauthenticated(ScratchAPIExceptionBase): "Raised when an authenticated action is attempted without logging in" 53 | 54 | 55 | class InvalidCSRF(ScratchAPIExceptionBase): "Raised when the CSRF token is invalid or not fetched" 56 | 57 | 58 | # UTIL EXTENDS 59 | 60 | class _ScratchUtils: 61 | def _tree(self, item, *args): 62 | "reads a value from a data structure using the given tree" 63 | path = args[:-1] 64 | default = args[-1] 65 | for x in path: 66 | try: 67 | item = item[x] 68 | except (KeyError, IndexError): 69 | return default 70 | return item 71 | 72 | def _ptree(self, value, item, *args, force=False): 73 | "returns the `value` passed if not None, otherwise self._tree(item, *args)" 74 | if (value is not None) and not force: 75 | return value 76 | return self._tree(item, *args) 77 | 78 | def _put_nn(self, data, key, value): 79 | if value is not None: 80 | data[key] = value 81 | 82 | 83 | # CLIENT SESSION 84 | 85 | class ScratchSession: 86 | def __init__(self, username=None, password=None, auto_login=True, retain_password=False): 87 | """Creates a scratch session. By default when a username and password are passed, the session automatically logs in. 88 | Setting retain_password to true will allow login() to be called later if necessary without needing the password""" 89 | 90 | self.SERVER = 'scratch.mit.edu' 91 | self.API_SERVER = 'api.scratch.mit.edu' 92 | self.PROJECTS_SERVER = 'projects.scratch.mit.edu' 93 | self.ASSETS_SERVER = 'assets.scratch.mit.edu' 94 | self.CDN_SERVER = 'cdn.scratch.mit.edu' 95 | self.CLOUD = 'clouddata.scratch.mit.edu' 96 | 97 | self.username = username 98 | self.password = None 99 | self.retain_password = retain_password 100 | 101 | self.http_session = _requests.session() 102 | 103 | self._save(username, password) 104 | if username is not None and password is not None and auto_login: 105 | self.login(username, password) 106 | 107 | def _save(self, username, password): 108 | self.username = username 109 | if self.retain_password: 110 | self.password = password 111 | 112 | def get_csrf(self): 113 | "Gets the CSRF token currently in use" 114 | return self.http_session.cookies.get('scratchcsrftoken') 115 | 116 | def get_session_id(self): 117 | "Gets the session ID currently in use" 118 | return self.http_session.cookies.get('scratchsessionsid') 119 | 120 | def _cookie_header( 121 | self): # ironically, CookieJar does not supply a direct method of fetching a cookie header, here is a (probably not up to spec) method to do so. 122 | cookies = [] 123 | for name, value in self.http_session.cookies.get_dict().items(): 124 | cookies.append(name + "=" + value) 125 | return "; ".join(cookies) 126 | 127 | def _get_headers(self, cookie=False): 128 | headers = { 129 | 'X-Requested-With': 'XMLHttpRequest', 130 | 'Referer': 'https://scratch.mit.edu/', 131 | 'Origin': 'https://scratch.mit.edu' 132 | } 133 | if self.get_csrf() is not None: 134 | headers["X-CSRFToken"] = self.get_csrf() 135 | if cookie: 136 | headers["Cookie"] = self._cookie_header() 137 | return headers 138 | 139 | def authenticated(self): 140 | "Returns true if the session is authenticated, false otherwise" 141 | return self.get_session_id() is not None 142 | 143 | def rfa(self): 144 | "Raise-for-authentication. Raises an error if the session is not authenticated" 145 | _assert(self.authenticated(), Unauthenticated()) 146 | 147 | def new_csrf(self): 148 | "Fetches a new CSRF token" 149 | self.http('/csrf_token/', auth=False) 150 | _assert(self.get_csrf() is not None, InvalidCSRF("the csrf token could not be fetched")) 151 | return self.get_csrf() 152 | 153 | def csrf(self): 154 | "Fetches a new CSRF token if needed" 155 | if self.get_csrf() is None: 156 | self.new_csrf() 157 | 158 | def login(self, *args): 159 | """Logs into scratch. If a username is supplied at class construction, a username does not need to be passed to login(). 160 | If retain_password is set at class construction, no arguments need to be passed""" 161 | username = self.username 162 | password = self.password 163 | if len(args) == 0: 164 | assert username is not None and password is not None 165 | elif len(args) == 1: 166 | assert username is not None 167 | password = args[0] 168 | elif len(args) == 2: 169 | username = args[0] 170 | password = args[1] 171 | else: 172 | raise ValueError("wrong number of args") 173 | self.csrf() 174 | self._save(username, password) 175 | return self._login(username, password) 176 | 177 | def _login(self, username, password): 178 | return self.http('POST /login/', 179 | body={'username': username, 'password': password, 'csrftoken': self.get_csrf()}, auth=False) 180 | 181 | def logout(self): 182 | "Logs out of scratch. Does not clear CSRF token" 183 | return self.http('POST /accounts/logout/') 184 | 185 | def purge(self): 186 | "Clears all session data but DOES NOT log out. Use purge() only after logout()" 187 | self.http_session.cookies.clear() 188 | 189 | def session_valid(self): 190 | "Checks whether the session is valid. May raise an error if scratch is down or the internet connection is lost." 191 | return self.http('/messages/ajax/get-message-count/').status_code == 200 192 | 193 | def ahttp(self, *args, **kwargs): 194 | "Alias of http() with auth set to true" 195 | return self.http(*args, **kwargs, auth=True) 196 | 197 | def http(self, *args, **kwargs): 198 | """Makes a HTTP request. The request may be supplied in keyword arg only form, but can also be supplied using a specific argument pattern. 199 | 200 | Pattern syntax: http([SERVER], "[METHOD] PATH", [tuple(FIELDS)], PAYLOAD, **kwargs) 201 | > SERVER: optional server, defaults to self.SERVER 202 | > "METHOD PATH": a string containing an optional request method and the request path. Any text following a colon (:) will be treated as a field, 203 | similar to python %s syntax. When fields are specified, you must include a TUPLE, LIST, OR DICT of the fields directly after the path. 204 | > PAYLOAD: The request body. If not bytes, will default to UTF-8 encoding. You may pass a JSON serializable object and it will be converted to JSON internally. 205 | 206 | NOTE: eyword arguments will overwrite anything defined using the argument pattern defined above. 207 | Keyword args: 208 | > method: sets the request method 209 | > server: sets the server being requested to. Defaults to self.SERVER 210 | > body: The request body. Follows same behavior as PAYLOAD above 211 | > payload: alias of body 212 | > headers: dictionary of additional request headers to send to the server. 213 | > retry: number of retries on request failure (connection errors etc). Defaults to 3. 214 | > protocol: sets the request protocol. Defaults to https. Must be supported by the requests module. 215 | > port: sets the request port. The default port defined by requests will be used when none is passed. 216 | > auth: whether the request must be authenticated. Defaults to true. When set to true, an error will be raised if the session is not authenticated. 217 | > rfs: defaults to true. When true, calls request.raise_for_status() when completed. 218 | """ 219 | request_protocol = "https" 220 | request_server = self.SERVER 221 | request_port = None 222 | request_method = "GET" 223 | request_path = None 224 | request_authenticated = False 225 | request_headers = {} 226 | request_retries = 3 227 | request_body = None 228 | request_response = None 229 | request_raise_for_status = True 230 | 231 | if len(args) > 0: 232 | a0_split = args[0].split(" ", 1) 233 | if args[0].startswith('/'): 234 | request_path = args[0] 235 | args = args[1:] 236 | elif len(a0_split) >= 2 and a0_split[1].startswith("/"): 237 | request_method = a0_split[0].upper() 238 | request_path = a0_split[1] 239 | args = args[1:] 240 | else: 241 | request_server = args[0] 242 | if args[1].startswith('/'): 243 | request_path = args[1] 244 | else: 245 | a1_split = args[1].split(" ", 1) 246 | request_method = a1_split[0].upper() 247 | request_path = a1_split[1] 248 | args = args[2:] 249 | 250 | if (not "field" in kwargs) or kwargs["field"]: 251 | path_build = "" 252 | index = 0 253 | field_arg = None 254 | for x in _re.split("(:[a-zA-Z0-9]+)", request_path): 255 | if x.startswith(":"): 256 | if field_arg is None: 257 | assert len( 258 | args) > 0, "when supplying url fields using /:field/, supply a tuple, list or dictionary after the path or include (field=False) to disable field checking and use the raw URL." 259 | field_arg = args[0] if type(args[0]) in [tuple, list, dict] else (args[0],) 260 | args = args[1:] 261 | if type(field_arg) == dict: 262 | # noinspection PyTypeChecker 263 | path_build += field_arg[x[1:]] 264 | else: 265 | path_build += field_arg[index] 266 | index += 1 267 | else: 268 | path_build += x 269 | request_path = path_build 270 | 271 | if len(args) != 0: 272 | request_body = args[0] 273 | # args = args[1:] 274 | 275 | if 'method' in kwargs: 276 | request_method = kwargs['method'].upper() 277 | if 'server' in kwargs: 278 | request_server = kwargs['server'] 279 | if 'body' in kwargs: 280 | request_body = kwargs['body'] 281 | if 'payload' in kwargs: # alias of body 282 | request_body = kwargs['payload'] 283 | if 'headers' in kwargs: 284 | request_headers.update(kwargs['headers']) 285 | if 'retry' in kwargs: 286 | request_retries = kwargs['retry'] 287 | if 'protocol' in kwargs: 288 | request_protocol = kwargs['protocol'] 289 | if 'port' in kwargs: 290 | request_port = kwargs['port'] 291 | if 'auth' in kwargs: 292 | request_authenticated = kwargs['auth'] 293 | if 'rfs' in kwargs: 294 | request_raise_for_status = kwargs['rfs'] 295 | 296 | request_headers.update(self._get_headers()) 297 | 298 | if request_authenticated: 299 | _assert(self.get_session_id() is not None, Unauthenticated()) 300 | _assert("X-CSRFToken" in request_headers, InvalidCSRF()) 301 | _assert(request_headers["X-CSRFToken"] is not None, InvalidCSRF()) 302 | 303 | if not ((type(request_body) == bytes) or request_body is None): 304 | if type(request_body) in [list, dict]: 305 | request_body = _json.dumps(request_body).encode("utf-8") 306 | elif type(request_body) == str: 307 | request_body = request_body.encode("utf-8") 308 | else: 309 | raise TypeError("strange request body. expected bytes, str or json serializable object") 310 | if request_body is not None: 311 | request_headers["content-length"] = len(request_body) 312 | request_headers = { 313 | x: (str(request_headers[x]) if not isinstance(request_headers[x], (str, bytes)) else request_headers[x]) for 314 | x in request_headers} 315 | request_url = request_protocol + "://" + request_server + ( 316 | ":" + str(request_port) if request_port is not None else "") + request_path 317 | request_unprepared = _requests.Request(method=request_method.upper(), url=request_url, headers=request_headers, 318 | cookies=self.http_session.cookies) 319 | if request_body is not None: 320 | request_unprepared.data = request_body 321 | request_prepared = request_unprepared.prepare() 322 | 323 | for x in range(0, request_retries + 1): 324 | if x >= request_retries: 325 | raise ConnectionError('Connection failed on all ' + str(request_retries) + ' attempts') 326 | try: 327 | request_response = self.http_session.send(request_prepared) 328 | break 329 | except _requests.exceptions.BaseHTTPError: 330 | continue 331 | if request_raise_for_status and request_response is not None: 332 | request_response.raise_for_status() 333 | return request_response 334 | 335 | 336 | # AUTH HELPER 337 | 338 | class _ScratchAuthenticatable: 339 | """"Represents an online scratch entity that may contain additional features when the client is authenticated. 340 | An instance of ScratchSession may be stored in an instance of ScratchAuthenticatable in order for it to function without requiring a session to be passed every time""" 341 | 342 | def _chain(self, instance): 343 | "used by a parent authenticatable to inherit this authenticatables session from its session" 344 | assert instance != self 345 | self._scratch_session = instance._session() 346 | return self 347 | 348 | def _put_auth(self, session): 349 | "used by a parent authenticatable to set the session of this authenticatable" 350 | self._scratch_session = session 351 | return self 352 | 353 | def _auth(self, session: ScratchSession = None) -> ScratchSession(): 354 | "alias of _session(session, auth=True)" 355 | return self._session(session=session, auth=True) 356 | 357 | def _session(self, session: ScratchSession = None, auth=False) -> ScratchSession(): 358 | """returns the session the authenticatable is using unless another session is specified in the arguments as an override. will make a new session if needed. 359 | if auth is set to true and the session is not authenticated, will raise an error""" 360 | if getattr(self, "_scratch_session", None) is None: 361 | self._scratch_session = None 362 | if session is None: 363 | if self._scratch_session is None: 364 | self._scratch_session = ScratchSession() 365 | session = self._scratch_session 366 | if auth: 367 | _assert(session.authenticated(), Unauthenticated()) 368 | session.csrf() 369 | return session 370 | 371 | def authenticate(self, session: ScratchSession): 372 | "authenticates the object with the given session." 373 | self._scratch_session = session 374 | 375 | class _ScratchDSObjectMapping: 376 | "Represents a data structure provided by the scratch website that can be directly be converted to an object and back" 377 | def __init__(self): 378 | self.from_mapping = {} 379 | self.to_mapping = {} 380 | 381 | def fm(self, ds_name, ob_name): 382 | self.from_mapping[ds_name] = ob_name 383 | 384 | def to(self, ob_name, ds_name): 385 | self.to_mapping[ob_name] = ds_name 386 | 387 | def bt(self, ob_name, ds_name): 388 | "object name first" 389 | self.to(ob_name, ds_name) 390 | self.fm(ds_name, ob_name) 391 | 392 | def convert_from(self, ds, obj, extra=True): 393 | for x in ds: 394 | if x in self.from_mapping: 395 | setattr(obj, self.from_mapping[x], ds[x]) 396 | elif extra: 397 | if not hasattr(obj, "ds_extra"): 398 | setattr(obj, "ds_extra", {}) 399 | getattr(obj, "ds_extra")[x] = ds[x] 400 | 401 | def convert_to(self, obj, lenient=True, ignore_none=True): 402 | ds = {} 403 | for x in self.to_mapping: 404 | if hasattr(obj, x): 405 | if (getattr(obj, x) is None) and ignore_none: 406 | continue 407 | ds[self.to_mapping[x]] = getattr(obj, x) 408 | elif not lenient: 409 | raise AttributeError("object %s does not have attribute %s" % (obj, x)) 410 | return ds 411 | 412 | class ScratchAPI: 413 | "NOT UPDATED YET! A wrapper" 414 | 415 | def __init__(self, *args, **kwargs): 416 | auto_csrf = True 417 | if "auto_csrf" in kwargs: 418 | auto_csrf = kwargs["auto_csrf"] 419 | del kwargs["auto_csrf"] 420 | 421 | if len(args) >= 1 and type(args[0]) == ScratchSession: 422 | self.session = args[0] 423 | else: 424 | self.session = ScratchSession(*args, **kwargs) 425 | 426 | if auto_csrf: 427 | self.session.csrf() 428 | 429 | def projects_legacy_get(self, project_id): 430 | return self.session.http(path='/internalapi/project/' + project_id + '/get/', 431 | server=self.session.PROJECTS_SERVER).json() 432 | 433 | def projects_legacy_set(self, project_id, payload): 434 | return self.session.http(server=self.session.PROJECTS_SERVER, 435 | path='/internalapi/project/' + project_id + '/set/', payload=payload, method='post') 436 | 437 | def projects_get_meta(self, project_id): 438 | return self.session.http(path='/api/v1/project/' + str(project_id) + '/?format=json').json() 439 | 440 | def projects_get_remixtree(self, project_id): 441 | return self.session.http(path='/projects/' + str(project_id) + '/remixtree/bare/').json() 442 | 443 | def _tools_verifySession(self): 444 | return self.session.http(path='/messages/ajax/get-message-count/', port=None).status_code == 200 445 | 446 | def _backpack_getBackpack(self): 447 | return self.session.http(path='/internalapi/backpack/' + self.lib.set.username + '/get/').json() 448 | 449 | def _backpack_setBackpack(self, payload): 450 | return self.session.http(server=self.CDN_SERVER, 451 | path='/internalapi/backpack/' + self.lib.set.username + '/set/', method="post", 452 | payload=payload) 453 | 454 | def _userpage_setStatus(self, payload): 455 | p2 = self.session.http(path='/site-api/users/all/' + self.lib.set.username).json() 456 | p = {} 457 | for i in p2: 458 | if i in ['comments_allowed', 'id', 'status', 'thumbnail_url', 'userId', 'username']: 459 | p[i] = p2[i] 460 | p['status'] = payload 461 | return self.session.http(path='/site-api/users/all/' + self.lib.set.username, method="put", 462 | payload=_json.dumps(p)) 463 | 464 | def _userpage_toggleComments(self): 465 | return self.session.http(path='/site-api/comments/user/' + self.lib.set.username + '/toggle-comments/', 466 | method="put") 467 | 468 | def _userpage_setBio(self, payload): 469 | p2 = self.session.http(path='/site-api/users/all/' + self.lib.set.username).json() 470 | p = {} 471 | for i in p2: 472 | if i in ['comments_allowed', 'id', 'bio', 'thumbnail_url', 'userId', 'username']: 473 | p[i] = p2[i] 474 | p['bio'] = payload 475 | return self.session.http(path='/site-api/users/all/' + self.lib.set.username, method="put", 476 | payload=_json.dumps(p)) 477 | 478 | def _users_get_meta(self, usr): 479 | return self.session.http(path='/users/' + usr, server=self.API_SERVER).json() 480 | 481 | def _users_follow(self, usr): 482 | return self.session.http(path='/site-api/users/followers/' + usr + '/add/?usernames=' + self.lib.set.username, 483 | method='PUT') 484 | 485 | def _users_unfollow(self, usr): 486 | return self.session.http( 487 | path='/site-api/users/followers/' + usr + '/remove/?usernames=' + self.lib.set.username, method='PUT') 488 | 489 | def _users_comment(self, user, comment): 490 | return self.session.http(path='/site-api/comments/user/' + user + '/add/', method='POST', 491 | payload=_json.dumps({"content": comment, "parent_id": '', "commentee_id": ''})) 492 | 493 | def _studios_comment(self, studioid, comment): 494 | return self.session.http(path='/site-api/comments/gallery/' + str(studioid) + '/add/', method='POST', 495 | payload=_json.dumps({"content": comment, "parent_id": '', "commentee_id": ''})) 496 | 497 | def _studios_get_meta(self, studioid): 498 | return self.session.http(path='/site-api/galleries/all/' + str(studioid)).json() 499 | 500 | def _studios_invite(self, studioid, user): 501 | return self.session.http( 502 | path='/site-api/users/curators-in/' + str(studioid) + '/invite_curator/?usernames=' + user, method='PUT') 503 | 504 | def _projects_comment(self, project_id, comment): 505 | return self.session.http(path='/site-api/comments/project/' + str(project_id) + '/add/', method='POST', 506 | payload=_json.dumps({"content": comment, "parent_id": '', "commentee_id": ''})) 507 | 508 | def _assets_get(self, md5): 509 | return self.session.http(path='/internalapi/asset/' + md5 + '/get/', server=self.ASSETS_SERVER).content 510 | 511 | def _assets_set(self, md5, content, content_type=None): 512 | if not content_type: 513 | if _os.path.splitext(md5)[-1] == '.png': 514 | content_type = 'image/png' 515 | elif _os.path.splitext(md5)[-1] == '.svg': 516 | content_type = 'image/svg+xml' 517 | elif _os.path.splitext(md5)[-1] == '.wav': 518 | content_type = 'audio/wav' 519 | else: 520 | content_type = 'text/plain' 521 | headers = {'Content-Length': str(len(content)), 522 | 'Origin': 'https://cdn.scratch.mit.edu', 523 | 'Content-Type': content_type, 524 | 'Referer': 'https://cdn.scratch.mit.edu/scratchr2/static/__cc77646ad8a4b266f015616addd66756__/Scratch.swf'} 525 | return self.session.http(path='/internalapi/asset/' + md5 + '/set/', method='POST', server=self.ASSETS_SERVER, 526 | payload=content) 527 | 528 | def _users_get_message_count(self, user=None): 529 | if user == None: 530 | user = self.lib.set.username 531 | return self.session.http(path='/proxy/users/' + user + '/activity/count', server=self.API_SERVER).json()[ 532 | 'msg_count'] 533 | 534 | def _get_message_html(self): 535 | return self.session.http(path='/messages/') 536 | 537 | 538 | class ScratchUserSession: 539 | def __init__(self, *args, **kwargs): 540 | _warnings.warn("""Scratch user sessions and interfacing with the API have been seperated into 2 classes, ScratchSession and ScratchAPI. 541 | ScratchUserSession has been made an alias of ScratchAPI, which will still accept a username and password to create its own ScratchSession with. 542 | This alias may be removed in the future, it is suggested that you change over to the new class names.""") 543 | super().__init__(*args, **kwargs) 544 | 545 | 546 | # OFFLINE PROJECTS 547 | 548 | class ScratchProject: 549 | def __init__(self, load=None, reader=None): 550 | """represents an offline scratch project. In order to upload a scratch project, you must create an online project first 551 | (using ScratchAPI.new_project() or creating a new OnlineScratchProject)""" 552 | self.json = None 553 | self.assets = {} 554 | self.reader = reader 555 | self.stream = None 556 | if load is not None: 557 | ScratchProject.load(load, reader) 558 | 559 | def load(self, stream, reader=None): 560 | project_json, stream = self._stream(stream) 561 | if reader is None: 562 | reader = _ScratchProjectReader.get_reader(project_json) 563 | assert reader is not None, "couldn't read this file. no reader accepted the JSON content." 564 | self.json = project_json 565 | self.reader = reader 566 | self.stream = stream 567 | self._read_assets(self.json, self.stream) 568 | 569 | def _get_zipfile(self, stream): 570 | if type(stream) == bytes: 571 | stream = _io.BytesIO(stream) 572 | return _zipfile.ZipFile(stream) 573 | 574 | def _stream(self, stream): 575 | try: 576 | scratch_file = self._get_zipfile(stream) 577 | except: 578 | raise ValueError("scratch file is invalid. NOTE: scratch 1.4 is not supported") 579 | json_filename = ([x.filename for x in scratch_file.filelist if x.filename == 'project.json'] + [x.filename for x in scratch_file.filelist if 'project.json' in x.filename]) # have seen prefixed project.json files that scratch still opens ...lol 580 | json_file = scratch_file.open(json_filename) 581 | project_json = _json.loads(json_file.read().decode('utf-8')) 582 | json_file.close() 583 | return project_json, scratch_file 584 | 585 | def _read_assets(self, json, stream): 586 | return self.reader.from_binary(json, stream, self) 587 | 588 | def get_json(self): 589 | "gets the JSON of the project" 590 | assert self.json is not None 591 | return self.json 592 | 593 | def get_asset(self, name): 594 | "gets an asset with the specified name, returns None if not found." 595 | if not (name in self.assets): 596 | self.read_asset(name) 597 | if name in self.assets: 598 | return self.assets[name] 599 | return None 600 | 601 | def put_asset(self, data: bytes, filetype): 602 | "adds an asset to the project" 603 | md5 = _hashlib.md5(data).hexdigest() 604 | self.assets[md5 + "." + filetype] = data 605 | return md5 606 | 607 | def del_asset(self, filename): 608 | "removes an asset with the specified name" 609 | while filename in self.assets: 610 | del self.assets[filename] 611 | 612 | # Override methods 613 | 614 | def read_asset(self, name): 615 | "will return an asset with the name specified" 616 | if name in self.assets: 617 | return self.assets[name] 618 | return None 619 | 620 | 621 | class _ScratchProjectReader(_ScratchUtils): 622 | def __init__(self, parent): 623 | self.parent = parent 624 | 625 | def get_reader(*args): 626 | parent = args[-1] 627 | for reader in _ScratchProjectReader.__subclasses__(): 628 | instance = reader(parent) 629 | if instance.includes(args[-1]): 630 | return instance 631 | return None 632 | 633 | def includes(self, json): 634 | "returns true if the passed json is implemented by this reader" 635 | raise NotImplemented() 636 | 637 | def from_binary(self, json, stream, parent: ScratchProject): 638 | "generates a list of all assets from a scratch binary stream" 639 | raise NotImplemented() 640 | 641 | def to_binary(self, json, assets): 642 | "converts json and assets to a scratch binary file" 643 | raise NotImplemented() 644 | 645 | def get_asset_list(self, json): # unused for now 646 | "creates an asset list from the passed json object" 647 | raise NotImplemented() 648 | 649 | 650 | class _ScratchReader20(_ScratchProjectReader): 651 | def includes(self, json): 652 | raise NotImplemented("not currently implemented.") 653 | 654 | def from_binary(self, json, stream, parent): 655 | raise NotImplemented("not currently implemented.") 656 | 657 | def to_binary(self, json, assets): 658 | raise NotImplemented("not currently implemented.") 659 | 660 | def get_asset_list(self, json): 661 | raise NotImplemented("not currently implemented.") 662 | 663 | 664 | class _ScratchReader30(_ScratchProjectReader): 665 | def includes(self, json): 666 | raise NotImplemented("not currently implemented.") 667 | 668 | def from_binary(self, json, stream, parent): 669 | raise NotImplemented("not currently implemented.") 670 | 671 | def to_binary(self, json, assets): 672 | raise NotImplemented("not currently implemented.") 673 | 674 | def get_asset_list(self, json): 675 | raise NotImplemented("not currently implemented.") 676 | 677 | 678 | # ONLINE PROJECTS 679 | 680 | class ScratchProjectOnlineProxy(ScratchProject, _ScratchAuthenticatable): 681 | def __init__(self, project_id=None): 682 | """a proxy implementation of ScratchProject representing a scratch project stored on MIT servers 683 | NOTE: assets will not be re-uploaded on save unless they are downloaded. This API does not provide high level 684 | project modification capabilities. If you wish to change the project assets, you must call project.put_asset() 685 | with any assets that you are adding to project, unless you know for sure they exist on the scratch assets server.""" 686 | self.project_id = None 687 | super().__init__(self, load=project_id, instance=self) 688 | 689 | def _get_zipfile(self, stream): 690 | raise NotImplemented("zipfile loading is not supported in this implementation") 691 | 692 | def _stream(self, stream): 693 | assert type(stream) in [int, str], "expected a project ID but received " + type(stream).__name__ 694 | stream = str(stream) 695 | self.project_id = stream 696 | session = self._session() 697 | project_json = session.http(session.PROJECTS_SERVER, "/:id", stream).json() 698 | return project_json, stream 699 | 700 | def _read_assets(self, json, stream): 701 | return None 702 | 703 | 704 | class OnlineScratchProject(_ScratchAuthenticatable, _ScratchUtils): 705 | def __init__(self, project_id=None): 706 | "Represents a project stored on the scratch website. May not exist on the website yet if id is none" 707 | self.project_id = project_id 708 | self.loaded = False 709 | 710 | self.author = None 711 | self.title = None 712 | self.shared = None 713 | self.comments_enabled = None 714 | self.created = None 715 | self.modified = None 716 | self.shared = None 717 | self.thumbnails = None 718 | self.instructions = None 719 | self.credits = None 720 | self.remix = None 721 | self.remix_original = None 722 | self.remix_parent = None 723 | self.view_count = None 724 | self.love_count = None 725 | self.favorite_count = None 726 | self.comment_count = None 727 | self.remix_count = None 728 | 729 | self.project = None 730 | 731 | def _ensure_created(self): 732 | assert self.created(), "the project must be created to execute this operation" 733 | 734 | def _ensure_loaded(self, session=None): 735 | if not self.loaded: 736 | return self.load(session) 737 | 738 | def _downstream(self, data, force=True): 739 | # self.author = ?? 740 | self.shared = self._ptree(self.shared, data, "is_published", False, force=force) 741 | self.comments_enabled = self._ptree(self.comments_enabled, data, "comments_allowed", False, force=force) 742 | self.shared = self._ptree(data, "is_published", False, force=force) 743 | 744 | def _upstream(self): 745 | data = {} 746 | self._put_nn(data, "title", self.title) 747 | self._put_nn(data, "comments_allowed", self.comments_enabled) 748 | self._put_nn(data, "instructions", self.instructions) 749 | self._put_nn(data, "credits", self.credits) 750 | 751 | def created(self): 752 | "returns true if the project has an ID on the scratch website" 753 | return self.project_id is not None 754 | 755 | def new(self, session=None, project: ScratchProject=None, title=None, remix_parent=None, load=True): 756 | "creates a new scratch project and saves it to this instance. If this instance is already a scratch project, raises an error." 757 | assert not self.created(), "cannot overwrite an existing project with a new instance. if you wish to update the project's contents, " 758 | session = self._auth(session) 759 | project_json = {} 760 | if project is not None: 761 | project_json = project.get_json() 762 | self._upload_assets(project) 763 | qs = {} 764 | if title is not None: 765 | qs["title"] = title 766 | if remix_parent is not None: 767 | qs["is_remix"] = 1 768 | qs["original_id"] = remix_parent 769 | result = session.ahttp(session.PROJECTS_SERVER, "POST /:qs", _urllib_parse.urlencode(qs), payload=project_json).json() 770 | assert result["status"] == "ok", 'server responded with unexpected status: "%s"' % result["status"] 771 | self.project_id = result["content-name"] 772 | if load: 773 | self.load(session=session) 774 | return self.project_id 775 | 776 | def load(self, session=None): 777 | "downloads the projects metadata from the scratch website" 778 | self._ensure_created() 779 | session = self._session(session) 780 | self._downstream(session.http(session.API_SERVER, "/projects/:project", self.project_id).json()) 781 | 782 | def get_project(self, session=None): 783 | "gets a ScratchProject-like object representing the project stored on scratch." 784 | self._ensure_created() 785 | project = ScratchProjectOnlineProxy(self.project_id) 786 | project._put_auth(self._session(session)) 787 | return project 788 | 789 | def _upload_assets(self, project, session=None): 790 | pass # TODO 791 | 792 | # CLOUD SESSIONS 793 | 794 | class _CloudSessionWatchdog: 795 | def __init__(self, instance): 796 | self.enabled = True 797 | self.instance = instance 798 | self.read_last = _time.time() 799 | self.read_timeout = 45 # after this many seconds with no reads, the connection will be reestablished 800 | 801 | def reconnect_needed(self): 802 | return _time.time() > self.read_last + self.read_timeout 803 | 804 | def reset_read(self): 805 | self.read_last = _time.time() 806 | 807 | async def handle_connection(self): 808 | if (not self.enabled) or not self.instance._connected: 809 | return 810 | if (not (self.instance.socket.state in [_websockets_client.OPEN, _websockets_client.CONNECTING])) or self.reconnect_needed(): 811 | self.instance._debug("watchdog: reconnect -> " + str(self.instance.socket.state)) 812 | self.reset_read() 813 | await AIOCloudSession.reconnect(self.instance) 814 | 815 | 816 | class AIOCloudSession: # not sure exactly the usage cases of this thing because the main api isn't AIO but better to include this than not 817 | def __init__(self, *args, loop=None, **kwargs): 818 | """Creates an asyncio based cloud session. The event loop used defaults to asyncio.get_event_loop() unless one is passed with (loop=) 819 | Passing a project ID as the first argument will set the project_id parameter in the created instance and will be connected to using connect() 820 | A session must be provided in the form of a ScratchSession, ScratchAPI or username and password.""" 821 | 822 | self.event_loop = loop or _asyncio.get_event_loop() 823 | self.project_id = None 824 | self.session = None 825 | 826 | if len(args) > 0 and (type(args[0]) == int or (len(args) == 3 and type(args[0]) == str)): 827 | self.project_id = str(args[0]) 828 | args = args[1:] 829 | if len(args) > 0: 830 | if type(args[0]) == ScratchSession: 831 | self.session = args[0] 832 | elif type(args[0]) == ScratchAPI: 833 | self.session = args[0].session 834 | elif type(args[0]) in [list, tuple]: 835 | self.session = ScratchSession(args[0][0], args[0][1], **kwargs) 836 | else: 837 | self.session = ScratchSession(*args, **kwargs) 838 | 839 | self.watchdog = _CloudSessionWatchdog(self) 840 | self._connected = False # whether the client should be connected. could be incorrect as to whether the socket is actually connected or not. use connected() 841 | self._debug_enabled = False # displays diagnostics when set to true 842 | 843 | self.variables = _AIOCloudVariablesList(self) 844 | self._client_outbound = [] 845 | self._client_inbound = [] 846 | self.socket = None 847 | 848 | # self._rollover = [] 849 | 850 | def _debug(self, message): 851 | if self._debug_enabled: 852 | print(message) 853 | 854 | def _check_connected(self): 855 | assert self._connected, "The client is not connected" 856 | assert self.socket is not None, "No connection was established" 857 | 858 | async def connect(self, project_id=None): 859 | """connects to the cloud data server. specifying a project_id will overwrite any id given when the class is constructed. 860 | one must be given at class contruction or passed to connect() or an error will be raised""" 861 | self.project_id = project_id or self.project_id 862 | assert self.project_id is not None 863 | assert self.session.authenticated() 864 | _websockets_client.USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36" # look like a normal client if this works the way i expect it does :sunglasses: 865 | self._debug("connect: establish") 866 | self.socket = await _websockets_client.connect("ws://" + self.session.CLOUD + "/", loop=self.event_loop, 867 | extra_headers=[(key, value) for key, value in 868 | self.session._get_headers(cookie=True).items()]) 869 | self.socket.timeout = 30 # not sure how big this is server side but this number will be used to mark the socket as timed out after a small period of no reads 870 | self._connected = True 871 | self.watchdog.reset_read() 872 | self._debug("connect: handshake") 873 | await AIOCloudSession._write_packet(self, self._create_packet('handshake', {})) 874 | self._debug("connect: write()") 875 | await AIOCloudSession._write(self) 876 | self._debug("connect: complete") 877 | 878 | async def disconnect(self, timeout=0.25): 879 | "disconnects from the cloud data server" 880 | try: 881 | await _asyncio.wait_for(self.socket.close(), timeout) 882 | except _asyncio.TimeoutError: # dirty disconnect but oh well i guess 883 | del self.socket 884 | self.socket = None 885 | self._connected = False 886 | 887 | async def reconnect(self): 888 | "reconnects the client to the cloud data server, if connected, otherwise an error is raised" 889 | self._check_connected() 890 | self._debug("reconnect: disconnect") 891 | await AIOCloudSession.disconnect(self) 892 | self._debug("reconnect: connect") 893 | await AIOCloudSession.connect(self) 894 | 895 | async def keep_alive(self): 896 | "keeps the connection alive if connected" 897 | await AIOCloudSession._read(self, 0) 898 | 899 | def _create_packet(self, method, options=None): 900 | "creates a cloud data packet with the set method and options and parameters" 901 | base_packet = { 902 | 'user': self.session.username, 903 | 'project_id': str(self.project_id), 904 | 'method': method 905 | } 906 | packet = (options or {}).copy() 907 | packet.update(base_packet) 908 | return packet 909 | 910 | async def _read(self, timeout=0, max_count=None): 911 | "reads updates from the websocket and keeps the connection alive" 912 | AIOCloudSession._check_connected(self) 913 | await self.watchdog.handle_connection() 914 | max_count = float('inf') if max_count is None else max_count - 1 915 | updates = [] 916 | count = -1 917 | while count < max_count: 918 | count += 1 919 | try: 920 | await _asyncio.sleep(0) 921 | data = await _asyncio.wait_for(self.socket.recv(), timeout) 922 | except _asyncio.TimeoutError: 923 | break 924 | except _websockets.ConnectionClosed: 925 | await AIOCloudSession.reconnect(self) 926 | continue 927 | if (data is None) or (data == b""): 928 | break 929 | try: 930 | packet = _json.loads(data) 931 | except _json.JSONDecodeError: 932 | _traceback.print_exc() 933 | continue 934 | updates.append(packet) 935 | self._client_inbound.append(packet) 936 | if ("method" in packet) and (packet["method"].lower().strip() == "set"): 937 | self.variables._put(packet["name"], packet["value"]) 938 | self.watchdog.reset_read() 939 | return updates 940 | 941 | async def _write(self): 942 | "sends all queued packets to the cloud data server" 943 | self._debug("write: keep alive") 944 | await AIOCloudSession.keep_alive(self) 945 | self._debug("write: write") 946 | for packet in self._client_outbound.copy(): 947 | del self._client_outbound[0] 948 | try: 949 | await AIOCloudSession._write_packet(self, packet) 950 | except _websockets.ConnectionClosed: 951 | self._debug("write: closed and reconnecting") 952 | self._client_outbound.insert(0, packet) 953 | await AIOCloudSession.reconnect(self) 954 | return 955 | self._debug("write: success") 956 | self._debug("write: complete") 957 | 958 | async def _write_packet(self, packet): 959 | "sends a packet to the cloud data server" 960 | ob = (_json.dumps(packet) + '\n').encode('utf-8') 961 | self._debug("write_packet: sending " + str(ob)) 962 | await self.socket.send(ob) 963 | 964 | async def _send(self, *args, **kwargs): 965 | "queues a packet to be sent to the cloud data server. in a normal situation, the packet will be sent immediately" 966 | packet = self._create_packet(*args, **kwargs) 967 | self._debug("send: queueing " + str(packet)) 968 | self._client_outbound.append(packet) 969 | self._debug("send: write()") 970 | await AIOCloudSession._write(self) 971 | self._debug("send: complete") 972 | 973 | async def get_updates(self): 974 | "fetches a list of packets received from the server" 975 | await AIOCloudSession._read(self, 0) 976 | updates = self._client_inbound.copy() 977 | self._client_inbound.clear() 978 | return updates 979 | 980 | async def set_var(self, name, value): 981 | "sets an existing cloud variable. a cloud symbol and space (☁ ) must be included in the name if present on the server" 982 | await AIOCloudSession._send(self, 'set', {'name': name, 'value': value}) 983 | self.variables._put(name, value) 984 | 985 | async def create_var(self, name, value=None): 986 | "creates a cloud variable. a cloud symbol and space (☁ ) must be included in the name if present on the server" 987 | value = value or 0 988 | await AIOCloudSession._send(self, 'create', {'name': name, 'value': value}) 989 | 990 | async def rename_var(self, old_name, new_name): 991 | "changes the name of an existing cloud variable. a cloud symbol and space (☁ ) must be included in both names if present on the server" 992 | await AIOCloudSession._send(self, 'rename', {'name': old_name, 'new_name': new_name}) 993 | 994 | async def delete_var(self, name): 995 | "deletes an existing cloud variable. a cloud symbol and space (☁ ) must be included in the name if present on the server" 996 | await AIOCloudSession._send(self, 'delete', {'name': name}) 997 | 998 | async def get_var(self, name, timeout=0): 999 | """[it is recommended to use session.variables.get(name, default) instead] 1000 | gets the value a cloud variable, raises KeyError if not found. a cloud symbol and space (☁ ) must be included in the name if present on the server""" 1001 | await AIOCloudSession._read(self, timeout=timeout) 1002 | return self.variables[name] 1003 | 1004 | async def get_vars(self, timeout=0): 1005 | """returns a dictionary of all tracked cloud variables""" 1006 | await AIOCloudSession._read(self, timeout=timeout) 1007 | return self.variables.variables 1008 | 1009 | 1010 | # noinspection PyArgumentList 1011 | class CloudSession(AIOCloudSession): # not sure of standard conventions for running asyncs like this properly. 1012 | # async isnt even supposed to be used like this but it's a documented in a way that 1013 | # that beginners struggle to understand, and syncronous programming is the best start 1014 | # 1015 | def __init__(self, *args, **kwargs): 1016 | """Synchronous wrapper for AIOCloudSession, mostly compatable with the old CloudSessiom. 1017 | Passing a project ID as the first argument will connect the the project's cloud right away. 1018 | A session must be provided in the form of a ScratchSession, ScratchAPI or username and password.""" 1019 | super().__init__(*args, **kwargs) 1020 | self.variables = _CloudVariablesList(self) 1021 | 1022 | def connect(self, *args, **kwargs): 1023 | return self.event_loop.run_until_complete(super().connect(*args, **kwargs)) 1024 | 1025 | def disconnect(self, *args, **kwargs): 1026 | return self.event_loop.run_until_complete(super().disconnect(*args, **kwargs)) 1027 | 1028 | def reconnect(self, *args, **kwargs): 1029 | return self.event_loop.run_until_complete(super().reconnect(*args, **kwargs)) 1030 | 1031 | def keep_alive(self, *args, **kwargs): 1032 | return self.event_loop.run_until_complete(super().keep_alive(*args, **kwargs)) 1033 | 1034 | def _read(self, *args, **kwargs): 1035 | return self.event_loop.run_until_complete(super()._read(*args, **kwargs)) 1036 | 1037 | def _write(self, *args, **kwargs): 1038 | return self.event_loop.run_until_complete(super()._write(*args, **kwargs)) 1039 | 1040 | def _write_packet(self, *args, **kwargs): 1041 | return self.event_loop.run_until_complete(super()._write_packet(*args, **kwargs)) 1042 | 1043 | def _send(self, *args, **kwargs): 1044 | return self.event_loop.run_until_complete(super()._send(*args, **kwargs)) 1045 | 1046 | def get_updates(self, *args, **kwargs): 1047 | return self.event_loop.run_until_complete(super().get_updates(*args, **kwargs)) 1048 | 1049 | def set_var(self, *args, **kwargs): 1050 | return self.event_loop.run_until_complete(super().set_var(*args, **kwargs)) 1051 | 1052 | def create_var(self, *args, **kwargs): 1053 | return self.event_loop.run_until_complete(super().create_var(*args, **kwargs)) 1054 | 1055 | def rename_var(self, *args, **kwargs): 1056 | return self.event_loop.run_until_complete(super().rename_var(*args, **kwargs)) 1057 | 1058 | def delete_var(self, *args, **kwargs): 1059 | return self.event_loop.run_until_complete(super().delete_var(*args, **kwargs)) 1060 | 1061 | def get_var(self, *args, **kwargs): 1062 | return self.event_loop.run_until_complete(super().get_var(*args, **kwargs)) 1063 | 1064 | def get_vars(self, *args, **kwargs): 1065 | return self.event_loop.run_until_complete(super().get_vars(*args, **kwargs)) 1066 | 1067 | 1068 | class _AIOCloudVariablesList: 1069 | def __init__(self, parent: AIOCloudSession): 1070 | self.parent = parent 1071 | self.variables = {} 1072 | 1073 | def _put(self, var, value): 1074 | self.variables[var] = value 1075 | 1076 | async def _aio_get(self, name, *args, **kwargs): 1077 | await AIOCloudSession._read(self.parent, 0) 1078 | has_default = len(args) == 1 or "default" in kwargs 1079 | default = args[0] if len(args) == 1 else None 1080 | default = kwargs["default"] if "default" in kwargs else default 1081 | if name in self.variables: 1082 | return self.variables[name] 1083 | elif not name.startswith("☁ "): 1084 | return await _AIOCloudVariablesList._aio_get(self, "☁ " + name, *args, **kwargs) 1085 | elif has_default: 1086 | return default 1087 | if name.startswith("☁ "): 1088 | raise ValueError( 1089 | "no variable with name " + name + ". if you specified the variable without a cloud, neither versions were found.") 1090 | raise ValueError("no variable with name " + name) 1091 | 1092 | async def _aio_set(self, name, value, auto_cloud=True): 1093 | await AIOCloudSession._read(self.parent, 0) 1094 | if (not name in self.variables) and (not name.startswith("☁ ") and auto_cloud): 1095 | return await _AIOCloudVariablesList._aio_set(self, "☁ " + name, value, False) 1096 | await AIOCloudSession.set_var(self.parent, name, value) 1097 | 1098 | async def get(self, *args, **kwargs): 1099 | "gets a cloud variable, returns default if specified in keyword args. raises ValueError otherwise. " 1100 | return await _AIOCloudVariablesList._aio_get(self, *args, **kwargs) 1101 | 1102 | async def set(self, *args, **kwargs): 1103 | """will attempt set any variable including ones that do not exist. 1104 | when auto_cloud is true, will append a cloud to the variables name if it is not found in the list. 1105 | NOTE: for future compatability, the type of the value is preserved. make sure to use NUMBER TYPES ONLY""" 1106 | return await _AIOCloudVariablesList._aio_set(self, *args, **kwargs) 1107 | 1108 | def __getitem__(self, *args): 1109 | raise NotImplemented("cannot use a __getitem__ implementation with async wrapper") 1110 | 1111 | def __setitem__(self, *args): 1112 | raise NotImplemented("cannot use a __setitem__ implementation with async wrapper") 1113 | 1114 | 1115 | class _CloudVariablesList(_AIOCloudVariablesList): 1116 | def __init__(self, *args, **kwargs): 1117 | super().__init__(*args, **kwargs) 1118 | 1119 | def get(self, *args, **kwargs): 1120 | return self.parent.event_loop.run_until_complete(super()._aio_get(*args, **kwargs)) 1121 | 1122 | def set(self, *args, **kwargs): 1123 | return self.parent.event_loop.run_until_complete(super()._aio_set(*args, **kwargs)) 1124 | 1125 | def __getitem__(self, *args): 1126 | return self.get(*args) 1127 | 1128 | def __setitem__(self, args, value): 1129 | args = args if type(args) == tuple else (args,) 1130 | return self.set(*args[0:1], value, *args[1:]) 1131 | 1132 | 1133 | class __docs_view: 1134 | "Call __doc__() to view the scratchapi docs online" 1135 | 1136 | def __call__(self): 1137 | _web.open("https://github.com/PolyEdge/scratchapi/wiki/") 1138 | 1139 | def __repr__(self): 1140 | return "See https://github.com/PolyEdge/scratchapi/wiki/ or call this object." 1141 | 1142 | 1143 | __doc__ = __docs_view() 1144 | 1145 | 1146 | def _assert(condition, exception=None): 1147 | if not condition: 1148 | raise exception or AssertionError() 1149 | 1150 | 1151 | print("t") 1152 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | The MIT License (MIT) 3 | Copyright (c) 2015 Dylan Beswick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files 6 | (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 13 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 14 | FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 15 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | """ 17 | 18 | 19 | from setuptools import setup, find_packages 20 | # To use a consistent encoding 21 | from codecs import open 22 | from os import path 23 | 24 | here = path.abspath(path.dirname(__file__)) 25 | 26 | # Get the long description from the README file 27 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 28 | long_description = f.read() 29 | 30 | setup( 31 | name='scratchapi', 32 | 33 | version='1.0.0', 34 | 35 | description='ScratchAPI is a Scratch API interface written in Python', 36 | long_description=long_description, 37 | 38 | url='https://github.com/Dylan5797/ScratchAPI', 39 | 40 | author='Dylan Beswick', 41 | author_email='djbeswick64@gmail.com', 42 | 43 | classifiers=[ 44 | 'Development Status :: 3 - Alpha', 45 | 'Intended Audience :: Developers', 46 | 47 | 'Programming Language :: Python :: 3', 48 | ], 49 | 50 | keywords=['scratch', 'api', 'cloud'], 51 | 52 | py_modules=["scratchapi"], 53 | 54 | install_requires=['requests', 'websockets'], 55 | 56 | ) 57 | --------------------------------------------------------------------------------