scratchattach 2.1.9__py3-none-any.whl → 2.1.10a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. scratchattach/__init__.py +28 -25
  2. scratchattach/cloud/__init__.py +2 -0
  3. scratchattach/cloud/_base.py +454 -282
  4. scratchattach/cloud/cloud.py +171 -168
  5. scratchattach/editor/__init__.py +21 -0
  6. scratchattach/editor/asset.py +199 -0
  7. scratchattach/editor/backpack_json.py +117 -0
  8. scratchattach/editor/base.py +142 -0
  9. scratchattach/editor/block.py +507 -0
  10. scratchattach/editor/blockshape.py +353 -0
  11. scratchattach/editor/build_defaulting.py +47 -0
  12. scratchattach/editor/comment.py +74 -0
  13. scratchattach/editor/commons.py +243 -0
  14. scratchattach/editor/extension.py +43 -0
  15. scratchattach/editor/field.py +90 -0
  16. scratchattach/editor/inputs.py +132 -0
  17. scratchattach/editor/meta.py +106 -0
  18. scratchattach/editor/monitor.py +175 -0
  19. scratchattach/editor/mutation.py +317 -0
  20. scratchattach/editor/pallete.py +91 -0
  21. scratchattach/editor/prim.py +170 -0
  22. scratchattach/editor/project.py +273 -0
  23. scratchattach/editor/sbuild.py +2837 -0
  24. scratchattach/editor/sprite.py +586 -0
  25. scratchattach/editor/twconfig.py +113 -0
  26. scratchattach/editor/vlb.py +134 -0
  27. scratchattach/eventhandlers/_base.py +99 -92
  28. scratchattach/eventhandlers/cloud_events.py +110 -103
  29. scratchattach/eventhandlers/cloud_recorder.py +26 -21
  30. scratchattach/eventhandlers/cloud_requests.py +460 -452
  31. scratchattach/eventhandlers/cloud_server.py +246 -244
  32. scratchattach/eventhandlers/cloud_storage.py +135 -134
  33. scratchattach/eventhandlers/combine.py +29 -27
  34. scratchattach/eventhandlers/filterbot.py +160 -159
  35. scratchattach/eventhandlers/message_events.py +41 -40
  36. scratchattach/other/other_apis.py +284 -212
  37. scratchattach/other/project_json_capabilities.py +475 -546
  38. scratchattach/site/_base.py +64 -46
  39. scratchattach/site/activity.py +414 -122
  40. scratchattach/site/backpack_asset.py +118 -84
  41. scratchattach/site/classroom.py +430 -142
  42. scratchattach/site/cloud_activity.py +107 -103
  43. scratchattach/site/comment.py +220 -190
  44. scratchattach/site/forum.py +400 -399
  45. scratchattach/site/project.py +806 -787
  46. scratchattach/site/session.py +1134 -867
  47. scratchattach/site/studio.py +611 -609
  48. scratchattach/site/user.py +835 -837
  49. scratchattach/utils/commons.py +243 -148
  50. scratchattach/utils/encoder.py +157 -156
  51. scratchattach/utils/enums.py +197 -190
  52. scratchattach/utils/exceptions.py +233 -206
  53. scratchattach/utils/requests.py +67 -59
  54. {scratchattach-2.1.9.dist-info → scratchattach-2.1.10a1.dist-info}/METADATA +155 -146
  55. scratchattach-2.1.10a1.dist-info/RECORD +62 -0
  56. {scratchattach-2.1.9.dist-info → scratchattach-2.1.10a1.dist-info}/WHEEL +1 -1
  57. {scratchattach-2.1.9.dist-info → scratchattach-2.1.10a1.dist-info/licenses}/LICENSE +21 -21
  58. scratchattach-2.1.9.dist-info/RECORD +0 -40
  59. {scratchattach-2.1.9.dist-info → scratchattach-2.1.10a1.dist-info}/top_level.txt +0 -0
@@ -1,867 +1,1134 @@
1
- """Session class and login function"""
2
-
3
- import json
4
- import re
5
- import warnings
6
- import pathlib
7
- import hashlib
8
- import time
9
- import random
10
- import base64
11
- import secrets
12
- from typing import Type
13
- import zipfile
14
-
15
- from . import forum
16
-
17
- from ..utils import commons
18
-
19
- from ..cloud import cloud, _base
20
- from . import user, project, backpack_asset, classroom
21
- from ..utils import exceptions
22
- from . import studio
23
- from . import classroom
24
- from ..eventhandlers import message_events, filterbot
25
- from . import activity
26
- from ._base import BaseSiteComponent
27
- from ..utils.commons import headers, empty_project_json
28
- from bs4 import BeautifulSoup
29
- from ..other import project_json_capabilities
30
- from ..utils.requests import Requests as requests
31
-
32
- CREATE_PROJECT_USES = []
33
-
34
- class Session(BaseSiteComponent):
35
-
36
- '''
37
- Represents a Scratch log in / session. Stores authentication data (session id and xtoken).
38
-
39
- Attributes:
40
-
41
- :.id: The session id associated with the login
42
-
43
- :.username: The username associated with the login
44
-
45
- :.xtoken: The xtoken associated with the login
46
-
47
- :.email: The email address associated with the logged in account
48
-
49
- :.new_scratcher: Returns True if the associated account is a new Scratcher
50
-
51
- :.mute_status: Information about commenting restrictions of the associated account
52
-
53
- :.banned: Returns True if the associated account is banned
54
- '''
55
-
56
- def __str__(self):
57
- return f"Login for account: {self.username}"
58
-
59
- def __init__(self, **entries):
60
-
61
- # Info on how the .update method has to fetch the data:
62
- self.update_function = requests.post
63
- self.update_API = "https://scratch.mit.edu/session"
64
-
65
- # Set attributes every Session object needs to have:
66
- self.id = None
67
- self.username = None
68
- self.xtoken = None
69
- self.new_scratcher = None
70
-
71
- # Update attributes from entries dict:
72
- self.__dict__.update(entries)
73
-
74
- # Set alternative attributes:
75
- self._username = self.username # backwards compatibility with v1
76
-
77
- # Base headers and cookies of every session:
78
- self._headers = dict(headers)
79
- self._cookies = {
80
- "scratchsessionsid" : self.id,
81
- "scratchcsrftoken" : "a",
82
- "scratchlanguage" : "en",
83
- "accept": "application/json",
84
- "Content-Type": "application/json",
85
- }
86
-
87
- def _update_from_dict(self, data):
88
- # Note: there are a lot more things you can get from this data dict.
89
- # Maybe it would be a good idea to also store the dict itself?
90
- # self.data = data
91
-
92
- self.xtoken = data['user']['token']
93
- self._headers["X-Token"] = self.xtoken
94
-
95
- self.has_outstanding_email_confirmation = data["flags"]["has_outstanding_email_confirmation"]
96
-
97
- self.email = data["user"]["email"]
98
-
99
- self.new_scratcher = data["permissions"]["new_scratcher"]
100
- self.mute_status = data["permissions"]["mute_status"]
101
-
102
- self.username = data["user"]["username"]
103
- self._username = data["user"]["username"]
104
- self.banned = data["user"]["banned"]
105
-
106
- if self.banned:
107
- warnings.warn(f"Warning: The account {self._username} you logged in to is BANNED. Some features may not work properly.")
108
- if self.has_outstanding_email_confirmation:
109
- warnings.warn(f"Warning: The account {self._username} you logged is not email confirmed. Some features may not work properly.")
110
- return True
111
-
112
- def connect_linked_user(self) -> 'user.User':
113
- '''
114
- Gets the user associated with the log in / session.
115
-
116
- Warning:
117
- The returned User object is cached. To ensure its attribute are up to date, you need to run .update() on it.
118
-
119
- Returns:
120
- scratchattach.user.User: Object representing the user associated with the log in / session.
121
- '''
122
- if not hasattr(self, "_user"):
123
- self._user = self.connect_user(self._username)
124
- return self._user
125
-
126
- def get_linked_user(self):
127
- # backwards compatibility with v1
128
- return self.connect_linked_user() # To avoid inconsistencies with "connect" and "get", this function was renamed
129
-
130
- def set_country(self, country: str="Antarctica"):
131
- requests.post("https://scratch.mit.edu/accounts/settings/",
132
- data={"country": country},
133
- headers=self._headers, cookies=self._cookies)
134
-
135
- def resend_email(self, password: str):
136
- """
137
- Sends a request to resend a confirmation email for this session's account
138
-
139
- Keyword arguments:
140
- password (str): Password associated with the session (not stored)
141
- """
142
- requests.post("https://scratch.mit.edu/accounts/email_change/",
143
- data={"email_address": self.new_email_address,
144
- "password": password},
145
- headers=self._headers, cookies=self._cookies)
146
- @property
147
- def new_email_address(self) -> str | None:
148
- """
149
- Gets the (unconfirmed) email address that this session has requested to transfer to, if any, otherwise the current address.
150
-
151
- Returns:
152
- str: The email that this session wants to switch to
153
- """
154
- response = requests.get("https://scratch.mit.edu/accounts/email_change/",
155
- headers=self._headers, cookies=self._cookies)
156
-
157
- soup = BeautifulSoup(response.content, "html.parser")
158
-
159
- email = None
160
- for label_span in soup.find_all("span", {"class": "label"}):
161
- if label_span.contents[0] == "New Email Address":
162
- return label_span.parent.contents[-1].text.strip("\n ")
163
- elif label_span.contents[0] == "Current Email Address":
164
- email = label_span.parent.contents[-1].text.strip("\n ")
165
-
166
- return email
167
-
168
- def logout(self):
169
- """
170
- Sends a logout request to scratch. Might not do anything, might log out this account on other ips/sessions? I am not sure
171
- """
172
- requests.post("https://scratch.mit.edu/accounts/logout/",
173
- headers=self._headers, cookies=self._cookies)
174
-
175
- def messages(self, *, limit=40, offset=0, date_limit=None, filter_by=None):
176
- '''
177
- Returns the messages.
178
-
179
- Keyword arguments:
180
- limit, offset, date_limit
181
- filter_by (str or None): Can either be None (no filter), "comments", "projects", "studios" or "forums"
182
-
183
- Returns:
184
- list<scratch.activity.Activity>: List that contains all messages as Activity objects.
185
- '''
186
- add_params = ""
187
- if date_limit is not None:
188
- add_params += f"&dateLimit={date_limit}"
189
- if filter_by is not None:
190
- add_params += f"&filter={filter_by}"
191
- data = commons.api_iterative(
192
- f"https://api.scratch.mit.edu/users/{self._username}/messages",
193
- limit = limit, offset = offset, headers = self._headers, cookies = self._cookies, add_params=add_params
194
- )
195
- return commons.parse_object_list(data, activity.Activity, self)
196
-
197
- def admin_messages(self, *, limit=40, offset=0):
198
- """
199
- Returns your messages sent by the Scratch team (alerts).
200
- """
201
- return commons.api_iterative(
202
- f"https://api.scratch.mit.edu/users/{self._username}/messages/admin",
203
- limit = limit, offset = offset, headers = self._headers, cookies = self._cookies
204
- )
205
-
206
-
207
- def clear_messages(self):
208
- '''
209
- Clears all messages.
210
- '''
211
- return requests.post(
212
- "https://scratch.mit.edu/site-api/messages/messages-clear/",
213
- headers = self._headers,
214
- cookies = self._cookies,
215
- timeout = 10,
216
- ).text
217
-
218
- def message_count(self):
219
- '''
220
- Returns the message count.
221
-
222
- Returns:
223
- int: message count
224
- '''
225
- return json.loads(requests.get(
226
- f"https://scratch.mit.edu/messages/ajax/get-message-count/",
227
- headers = self._headers,
228
- cookies = self._cookies,
229
- timeout = 10,
230
- ).text)["msg_count"]
231
-
232
- # Front-page-related stuff:
233
-
234
- def feed(self, *, limit=20, offset=0, date_limit=None):
235
- '''
236
- Returns the "What's happening" section (frontpage).
237
-
238
- Returns:
239
- list<scratch.activity.Activity>: List that contains all "What's happening" entries as Activity objects
240
- '''
241
- add_params = ""
242
- if date_limit is not None:
243
- add_params = f"&dateLimit={date_limit}"
244
- data = commons.api_iterative(
245
- f"https://api.scratch.mit.edu/users/{self._username}/following/users/activity",
246
- limit = limit, offset = offset, headers = self._headers, cookies = self._cookies, add_params=add_params
247
- )
248
- return commons.parse_object_list(data, activity.Activity, self)
249
-
250
- def get_feed(self, *, limit=20, offset=0, date_limit=None):
251
- # for more consistent names, this method was renamed
252
- return self.feed(limit=limit, offset=offset, date_limit=date_limit) # for backwards compatibility with v1
253
-
254
- def loved_by_followed_users(self, *, limit=40, offset=0):
255
- '''
256
- Returns the "Projects loved by Scratchers I'm following" section (frontpage).
257
-
258
- Returns:
259
- list<scratchattach.project.Project>: List that contains all "Projects loved by Scratchers I'm following" entries as Project objects
260
- '''
261
- data = commons.api_iterative(
262
- f"https://api.scratch.mit.edu/users/{self._username}/following/users/loves",
263
- limit = limit, offset = offset, headers = self._headers, cookies = self._cookies
264
- )
265
- return commons.parse_object_list(data, project.Project, self)
266
-
267
- """
268
- These methods are disabled because it is unclear if there is any case in which the response is not empty.
269
- def shared_by_followed_users(self, *, limit=40, offset=0):
270
- '''
271
- Returns the "Projects by Scratchers I'm following" section (frontpage).
272
- This section is only visible to old accounts (according to the Scratch wiki).
273
- For newer users, this method will always return an empty list.
274
-
275
- Returns:
276
- list<scratchattach.project.Project>: List that contains all "Projects loved by Scratchers I'm following" entries as Project objects
277
- '''
278
- data = commons.api_iterative(
279
- f"https://api.scratch.mit.edu/users/{self._username}/following/users/projects",
280
- limit = limit, offset = offset, headers = self._headers, cookies = self._cookies
281
- )
282
- return commons.parse_object_list(data, project.Project, self)
283
-
284
- def in_followed_studios(self, *, limit=40, offset=0):
285
- '''
286
- Returns the "Projects in studios I'm following" section (frontpage).
287
- This section is only visible to old accounts (according to the Scratch wiki).
288
- For newer users, this method will always return an empty list.
289
-
290
- Returns:
291
- list<scratchattach.project.Project>: List that contains all "Projects loved by Scratchers I'm following" entries as Project objects
292
- '''
293
- data = commons.api_iterative(
294
- f"https://api.scratch.mit.edu/users/{self._username}/following/studios/projects",
295
- limit = limit, offset = offset, headers = self._headers, cookies = self._cookies
296
- )
297
- return commons.parse_object_list(data, project.Project, self)"""
298
-
299
- # -- Project JSON editing capabilities ---
300
-
301
- def connect_empty_project_pb() -> 'project_json_capabilities.ProjectBody':
302
- pb = project_json_capabilities.ProjectBody()
303
- pb.from_json(empty_project_json)
304
- return pb
305
-
306
- def connect_pb_from_dict(project_json:dict) -> 'project_json_capabilities.ProjectBody':
307
- pb = project_json_capabilities.ProjectBody()
308
- pb.from_json(project_json)
309
- return pb
310
-
311
- def connect_pb_from_file(path_to_file) -> 'project_json_capabilities.ProjectBody':
312
- pb = project_json_capabilities.ProjectBody()
313
- pb.from_json(project_json_capabilities._load_sb3_file(path_to_file))
314
- return pb
315
-
316
- def download_asset(asset_id_with_file_ext, *, filename=None, dir=""):
317
- if not (dir.endswith("/") or dir.endswith("\\")):
318
- dir = dir+"/"
319
- try:
320
- if filename is None:
321
- filename = str(asset_id_with_file_ext)
322
- response = requests.get(
323
- "https://assets.scratch.mit.edu/"+str(asset_id_with_file_ext),
324
- timeout=10,
325
- )
326
- open(f"{dir}{filename}", "wb").write(response.content)
327
- except Exception:
328
- raise (
329
- exceptions.FetchError(
330
- "Failed to download asset"
331
- )
332
- )
333
-
334
- def upload_asset(self, asset_content, *, asset_id=None, file_ext=None):
335
- data = asset_content if isinstance(asset_content, bytes) else open(asset_content, "rb").read()
336
-
337
- if isinstance(asset_content, str):
338
- file_ext = pathlib.Path(asset_content).suffix
339
- file_ext = file_ext.replace(".", "")
340
-
341
- if asset_id is None:
342
- asset_id = hashlib.md5(data).hexdigest()
343
-
344
- requests.post(
345
- f"https://assets.scratch.mit.edu/{asset_id}.{file_ext}",
346
- headers=self._headers,
347
- cookies = self._cookies,
348
- data=data,
349
- timeout=10,
350
- )
351
-
352
- # --- Search ---
353
-
354
- def search_projects(self, *, query="", mode="trending", language="en", limit=40, offset=0):
355
- '''
356
- Uses the Scratch search to search projects.
357
-
358
- Keyword arguments:
359
- query (str): The query that will be searched.
360
- mode (str): Has to be one of these values: "trending", "popular" or "recent". Defaults to "trending".
361
- language (str): A language abbreviation, defaults to "en". (Depending on the language used on the Scratch website, Scratch displays you different results.)
362
- limit (int): Max. amount of returned projects.
363
- offset (int): Offset of the first returned project.
364
-
365
- Returns:
366
- list<scratchattach.project.Project>: List that contains the search results.
367
- '''
368
- response = commons.api_iterative(
369
- f"https://api.scratch.mit.edu/search/projects", limit=limit, offset=offset, add_params=f"&language={language}&mode={mode}&q={query}")
370
- return commons.parse_object_list(response, project.Project, self)
371
-
372
- def explore_projects(self, *, query="*", mode="trending", language="en", limit=40, offset=0):
373
- '''
374
- Gets projects from the explore page.
375
-
376
- Keyword arguments:
377
- query (str): Specifies the tag of the explore page. To get the projects from the "All" tag, set this argument to "*".
378
- mode (str): Has to be one of these values: "trending", "popular" or "recent". Defaults to "trending".
379
- language (str): A language abbreviation, defaults to "en". (Depending on the language used on the Scratch website, Scratch displays you different explore pages.)
380
- limit (int): Max. amount of returned projects.
381
- offset (int): Offset of the first returned project.
382
-
383
- Returns:
384
- list<scratchattach.project.Project>: List that contains the explore page projects.
385
- '''
386
- response = commons.api_iterative(
387
- f"https://api.scratch.mit.edu/explore/projects", limit=limit, offset=offset, add_params=f"&language={language}&mode={mode}&q={query}")
388
- return commons.parse_object_list(response, project.Project, self)
389
-
390
- def search_studios(self, *, query="", mode="trending", language="en", limit=40, offset=0):
391
- if not query:
392
- raise ValueError("The query can't be empty for search")
393
- response = commons.api_iterative(
394
- f"https://api.scratch.mit.edu/search/studios", limit=limit, offset=offset, add_params=f"&language={language}&mode={mode}&q={query}")
395
- return commons.parse_object_list(response, studio.Studio, self)
396
-
397
-
398
- def explore_studios(self, *, query="", mode="trending", language="en", limit=40, offset=0):
399
- if not query:
400
- raise ValueError("The query can't be empty for explore")
401
- response = commons.api_iterative(
402
- f"https://api.scratch.mit.edu/explore/studios", limit=limit, offset=offset, add_params=f"&language={language}&mode={mode}&q={query}")
403
- return commons.parse_object_list(response, studio.Studio, self)
404
-
405
-
406
- # --- Create project API ---
407
-
408
- def create_project(self, *, title=None, project_json=empty_project_json, parent_id=None): # not working
409
- """
410
- Creates a project on the Scratch website.
411
-
412
- Warning:
413
- Don't spam this method - it WILL get you banned from Scratch.
414
- To prevent accidental spam, a rate limit (5 projects per minute) is implemented for this function.
415
- """
416
- global CREATE_PROJECT_USES
417
- if len(CREATE_PROJECT_USES) < 5:
418
- CREATE_PROJECT_USES.insert(0, time.time())
419
- else:
420
- if CREATE_PROJECT_USES[-1] < time.time() - 300:
421
- CREATE_PROJECT_USES.pop()
422
- else:
423
- raise exceptions.BadRequest("Rate limit for creating Scratch projects exceeded.\nThis rate limit is enforced by scratchattach, not by the Scratch API.\nFor security reasons, it cannot be turned off.\n\nDon't spam-create projects, it WILL get you banned.")
424
- return
425
- CREATE_PROJECT_USES.insert(0, time.time())
426
-
427
- if title is None:
428
- title = f'Untitled-{random.randint(0, 200)}'
429
-
430
- params = {
431
- 'is_remix': '0' if parent_id is None else "1",
432
- 'original_id': parent_id,
433
- 'title': title,
434
- }
435
-
436
- response = requests.post('https://projects.scratch.mit.edu/', params=params, cookies=self._cookies, headers=self._headers, json=project_json).json()
437
- return self.connect_project(response["content-name"])
438
-
439
- # --- My stuff page ---
440
-
441
- def mystuff_projects(self, filter_arg="all", *, page=1, sort_by="", descending=True):
442
- '''
443
- Gets the projects from the "My stuff" page.
444
-
445
- Args:
446
- filter_arg (str): Possible values for this parameter are "all", "shared", "unshared" and "trashed"
447
-
448
- Keyword Arguments:
449
- page (int): The page of the "My Stuff" projects that should be returned
450
- sort_by (str): The key the projects should be sorted based on. Possible values for this parameter are "" (then the projects are sorted based on last modified), "view_count", love_count", "remixers_count" (then the projects are sorted based on remix count) and "title" (then the projects are sorted based on title)
451
- descending (boolean): Determines if the element with the highest key value (the key is specified in the sort_by argument) should be returned first. Defaults to True.
452
-
453
- Returns:
454
- list<scratchattach.project.Project>: A list with the projects from the "My Stuff" page, each project is represented by a Project object.
455
- '''
456
- if descending:
457
- ascsort = ""
458
- descsort = sort_by
459
- else:
460
- ascsort = sort_by
461
- descsort = ""
462
- try:
463
- targets = requests.get(
464
- f"https://scratch.mit.edu/site-api/projects/{filter_arg}/?page={page}&ascsort={ascsort}&descsort={descsort}",
465
- headers = headers,
466
- cookies = self._cookies,
467
- timeout = 10,
468
- ).json()
469
- projects = []
470
- for target in targets:
471
- projects.append(project.Project(
472
- id = target["pk"], _session=self, author_name=self._username,
473
- comments_allowed=None, instructions=None, notes=None,
474
- created=target["fields"]["datetime_created"],
475
- last_modified=target["fields"]["datetime_modified"],
476
- share_date=target["fields"]["datetime_shared"],
477
- thumbnail_url="https:"+target["fields"]["thumbnail_url"],
478
- favorites=target["fields"]["favorite_count"],
479
- loves=target["fields"]["love_count"],
480
- remixes=target["fields"]["remixers_count"],
481
- views=target["fields"]["view_count"],
482
- title=target["fields"]["title"],
483
- comment_count=target["fields"]["commenters_count"]
484
- ))
485
- return projects
486
- except Exception:
487
- raise(exceptions.FetchError)
488
-
489
- def mystuff_studios(self, filter_arg="all", *, page=1, sort_by="", descending=True):
490
- if descending:
491
- ascsort = ""
492
- descsort = sort_by
493
- else:
494
- ascsort = sort_by
495
- descsort = ""
496
- try:
497
- targets = requests.get(
498
- f"https://scratch.mit.edu/site-api/galleries/{filter_arg}/?page={page}&ascsort={ascsort}&descsort={descsort}",
499
- headers = headers,
500
- cookies = self._cookies,
501
- timeout = 10,
502
- ).json()
503
- studios = []
504
- for target in targets:
505
- studios.append(studio.Studio(
506
- id = target["pk"], _session=self,
507
- title = target["fields"]["title"],
508
- description = None,
509
- host_id = target["fields"]["owner"]["pk"],
510
- host_name = target["fields"]["owner"]["username"],
511
- open_to_all = None, comments_allowed=None,
512
- image_url = "https:"+target["fields"]["thumbnail_url"],
513
- created = target["fields"]["datetime_created"],
514
- modified = target["fields"]["datetime_modified"],
515
- follower_count = None, manager_count = None,
516
- curator_count = target["fields"]["curators_count"],
517
- project_count = target["fields"]["projecters_count"]
518
- ))
519
- return studios
520
- except Exception:
521
- raise(exceptions.FetchError)
522
-
523
-
524
- def backpack(self,limit=20, offset=0):
525
- '''
526
- Lists the assets that are in the backpack of the user associated with the session.
527
-
528
- Returns:
529
- list<dict>: List that contains the backpack items as dicts
530
- '''
531
- data = commons.api_iterative(
532
- f"https://backpack.scratch.mit.edu/{self._username}",
533
- limit = limit, offset = offset, headers = self._headers
534
- )
535
- return commons.parse_object_list(data, backpack_asset.BackpackAsset, self)
536
-
537
- def delete_from_backpack(self, backpack_asset_id):
538
- '''
539
- Deletes an asset from the backpack.
540
-
541
- Args:
542
- backpack_asset_id: ID of the backpack asset that will be deleted
543
- '''
544
- return backpack_asset.BackpackAsset(id=backpack_asset_id, _session=self).delete()
545
-
546
-
547
- def become_scratcher_invite(self):
548
- """
549
- If you are a new Scratcher and have been invited for becoming a Scratcher, this API endpoint will provide more info on the invite.
550
- """
551
- return requests.get(f"https://api.scratch.mit.edu/users/{self.username}/invites", headers=self._headers, cookies=self._cookies).json()
552
-
553
- # --- Connect classes inheriting from BaseCloud ---
554
-
555
- def connect_cloud(self, project_id, *, CloudClass:Type[_base.BaseCloud]=cloud.ScratchCloud) -> Type[_base.BaseCloud]:
556
- """
557
- Connects to a cloud (by default Scratch's cloud) as logged in user.
558
-
559
- Args:
560
- project_id:
561
-
562
- Keyword arguments:
563
- CloudClass: The class that the returned object should be of. By default this class is scratchattach.cloud.ScratchCloud.
564
-
565
- Returns:
566
- Type[scratchattach._base.BaseCloud]: An object representing the cloud of a project. Can be of any class inheriting from BaseCloud.
567
- """
568
- return CloudClass(project_id=project_id, _session=self)
569
-
570
- def connect_scratch_cloud(self, project_id) -> 'cloud.ScratchCloud':
571
- """
572
- Returns:
573
- scratchattach.cloud.ScratchCloud: An object representing the Scratch cloud of a project.
574
- """
575
- return cloud.ScratchCloud(project_id=project_id, _session=self)
576
-
577
- def connect_tw_cloud(self, project_id, *, purpose="", contact="", cloud_host="wss://clouddata.turbowarp.org") -> 'cloud.TwCloud':
578
- """
579
- Returns:
580
- scratchattach.cloud.TwCloud: An object representing the TurboWarp cloud of a project.
581
- """
582
- return cloud.TwCloud(project_id=project_id, purpose=purpose, contact=contact, cloud_host=cloud_host, _session=self)
583
-
584
- # --- Connect classes inheriting from BaseSiteComponent ---
585
-
586
- def _make_linked_object(self, identificator_name, identificator, Class, NotFoundException):
587
- """
588
- The Session class doesn't save the login in a ._session attribute, but IS the login ITSELF.
589
-
590
- Therefore the _make_linked_object method has to be adjusted
591
- to get it to work for in the Session class.
592
-
593
- Class must inherit from BaseSiteComponent
594
- """
595
- return commons._get_object(identificator_name, identificator, Class, NotFoundException, self)
596
-
597
-
598
- def connect_user(self, username) -> 'user.User':
599
- """
600
- Gets a user using this session, connects the session to the User object to allow authenticated actions
601
-
602
- Args:
603
- username (str): Username of the requested user
604
-
605
- Returns:
606
- scratchattach.user.User: An object that represents the requested user and allows you to perform actions on the user (like user.follow)
607
- """
608
- return self._make_linked_object("username", username, user.User, exceptions.UserNotFound)
609
-
610
- def find_username_from_id(self, user_id:int):
611
- """
612
- Warning:
613
- Every time this functions is run, a comment on your profile is posted and deleted. Therefore you shouldn't run this too often.
614
-
615
- Returns:
616
- str: The username that corresponds to the user id
617
- """
618
- you = user.User(username=self.username, _session=self)
619
- try:
620
- comment = you.post_comment("scratchattach", commentee_id=int(user_id))
621
- except exceptions.CommentPostFailure:
622
- raise exceptions.BadRequest("After posting a comment, you need to wait 10 seconds before you can connect users by id again.")
623
- except exceptions.BadRequest:
624
- raise exceptions.UserNotFound("Invalid user id")
625
- except Exception as e:
626
- raise e
627
- you.delete_comment(comment_id=comment.id)
628
- try:
629
- username = comment.content.split('">@')[1]
630
- username = username.split("</a>")[0]
631
- except IndexError:
632
- raise exceptions.UserNotFound()
633
- return username
634
-
635
-
636
- def connect_user_by_id(self, user_id:int) -> 'user.User':
637
- """
638
- Gets a user using this session, connects the session to the User object to allow authenticated actions
639
-
640
- This method ...
641
- 1) gets the username by posting a comment with the user_id as commentee_id.
642
- 2) deletes the posted comment.
643
- 3) fetches other information about the user using Scratch's api.scratch.mit.edu/users/username API.
644
-
645
- Warning:
646
- Every time this functions is run, a comment on your profile is posted and deleted. Therefore you shouldn't run this too often.
647
-
648
- Args:
649
- user_id (int): User ID of the requested user
650
-
651
- Returns:
652
- scratchattach.user.User: An object that represents the requested user and allows you to perform actions on the user (like user.follow)
653
- """
654
- return self._make_linked_object("username", self.find_username_from_id(user_id), user.User, exceptions.UserNotFound)
655
-
656
- def connect_project(self, project_id) -> 'project.Project':
657
- """
658
- Gets a project using this session, connects the session to the Project object to allow authenticated actions
659
- sess
660
- Args:
661
- project_id (int): ID of the requested project
662
-
663
- Returns:
664
- scratchattach.project.Project: An object that represents the requested project and allows you to perform actions on the project (like project.love)
665
- """
666
- return self._make_linked_object("id", int(project_id), project.Project, exceptions.ProjectNotFound)
667
-
668
- def connect_studio(self, studio_id) -> 'studio.Studio':
669
- """
670
- Gets a studio using this session, connects the session to the Studio object to allow authenticated actions
671
-
672
- Args:
673
- studio_id (int): ID of the requested studio
674
-
675
- Returns:
676
- scratchattach.studio.Studio: An object that represents the requested studio and allows you to perform actions on the studio (like studio.follow)
677
- """
678
- return self._make_linked_object("id", int(studio_id), studio.Studio, exceptions.StudioNotFound)
679
-
680
- def connect_classroom(self, class_id) -> 'classroom.Classroom':
681
- """
682
- Gets a class using this session.
683
-
684
- Args:
685
- class_id (str): class id of the requested class
686
-
687
- Returns:
688
- scratchattach.classroom.Classroom: An object representing the requested classroom
689
- """
690
- return self._make_linked_object("id", int(class_id), classroom.Classroom, exceptions.ClassroomNotFound)
691
-
692
- def connect_classroom_from_token(self, class_token) -> 'classroom.Classroom':
693
- """
694
- Gets a class using this session.
695
-
696
- Args:
697
- class_token (str): class token of the requested class
698
-
699
- Returns:
700
- scratchattach.classroom.Classroom: An object representing the requested classroom
701
- """
702
- return self._make_linked_object("classtoken", int(class_token), classroom.Classroom, exceptions.ClassroomNotFound)
703
-
704
- def connect_topic(self, topic_id) -> 'forum.ForumTopic':
705
- """
706
- Gets a forum topic using this session, connects the session to the ForumTopic object to allow authenticated actions
707
- Data is up-to-date. Data received from Scratch's RSS feed XML API.
708
-
709
- Args:
710
- topic_id (int): ID of the requested forum topic (can be found in the browser URL bar)
711
-
712
- Returns:
713
- scratchattach.forum.ForumTopic: An object that represents the requested forum topic
714
- """
715
- return self._make_linked_object("id", int(topic_id), forum.ForumTopic, exceptions.ForumContentNotFound)
716
-
717
-
718
- def connect_topic_list(self, category_id, *, page=1):
719
-
720
- """
721
- Gets the topics from a forum category. Data web-scraped from Scratch's forums UI.
722
- Data is up-to-date.
723
-
724
- Args:
725
- category_id (str): ID of the forum category
726
-
727
- Keyword Arguments:
728
- page (str): Page of the category topics that should be returned
729
-
730
- Returns:
731
- list<scratchattach.forum.ForumTopic>: A list containing the forum topics from the specified category
732
- """
733
-
734
- try:
735
- response = requests.get(f"https://scratch.mit.edu/discuss/{category_id}/?page={page}", headers=self._headers, cookies=self._cookies)
736
- soup = BeautifulSoup(response.content, 'html.parser')
737
- except Exception as e:
738
- raise exceptions.FetchError(str(e))
739
-
740
- try:
741
- category_name = soup.find('h4').find("span").get_text()
742
- except Exception as e:
743
- raise exceptions.BadRequest("Invalid category id")
744
-
745
- try:
746
- topics = soup.find_all('tr')
747
- topics.pop(0)
748
- return_topics = []
749
-
750
- for topic in topics:
751
- title_link = topic.find('a')
752
- title = title_link.text.strip()
753
- topic_id = title_link['href'].split('/')[-2]
754
-
755
- columns = topic.find_all('td')
756
- columns = [column.text for column in columns]
757
- if len(columns) == 1:
758
- # This is a sticky topic -> Skip it
759
- continue
760
-
761
- last_updated = columns[3].split(" ")[0] + " " + columns[3].split(" ")[1]
762
-
763
- return_topics.append(forum.ForumTopic(_session=self, id=int(topic_id), title=title, category_name=category_name, last_updated=last_updated, reply_count=int(columns[1]), view_count=int(columns[2])))
764
- return return_topics
765
- except Exception as e:
766
- raise exceptions.ScrapeError(str(e))
767
-
768
- # --- Connect classes inheriting from BaseEventHandler ---
769
-
770
- def connect_message_events(self, *, update_interval=2) -> 'message_events.MessageEvents':
771
- # shortcut for connect_linked_user().message_events()
772
- return message_events.MessageEvents(user.User(username=self.username, _session=self), update_interval=update_interval)
773
-
774
- def connect_filterbot(self, *, log_deletions=True) -> 'filterbot.Filterbot':
775
- return filterbot.Filterbot(user.User(username=self.username, _session=self), log_deletions=log_deletions)
776
-
777
- # ------ #
778
-
779
- def login_by_id(session_id, *, username=None, password=None, xtoken=None) -> Session:
780
- """
781
- Creates a session / log in to the Scratch website with the specified session id.
782
- Structured similarly to Session._connect_object method.
783
-
784
- Args:
785
- session_id (str)
786
- password (str)
787
-
788
- Keyword arguments:
789
- timeout (int): Optional, but recommended. Specify this when the Python environment's IP address is blocked by Scratch's API, but you still want to use cloud variables.
790
-
791
- Returns:
792
- scratchattach.session.Session: An object that represents the created log in / session
793
- """
794
- # Generate session_string (a scratchattach-specific authentication method)
795
- if password is not None:
796
- session_data = dict(session_id=session_id, username=username, password=password)
797
- session_string = base64.b64encode(json.dumps(session_data).encode())
798
- else:
799
- session_string = None
800
- _session = Session(id=session_id, username=username, session_string=session_string, xtoken=xtoken)
801
- try:
802
- status = _session.update()
803
- except Exception as e:
804
- status = False
805
- print(f"Key error at key "+str(e)+" when reading scratch.mit.edu/session API response")
806
- if status is not True:
807
- if _session.xtoken is None:
808
- if _session.username is None:
809
- print(f"Warning: Logged in by id, but couldn't fetch XToken. Make sure the provided session id is valid. Setting cloud variables can still work if you provide a `username='username'` keyword argument to the sa.login_by_id function")
810
- else:
811
- print(f"Warning: Logged in by id, but couldn't fetch XToken. Make sure the provided session id is valid.")
812
- else:
813
- print(f"Warning: Logged in by id, but couldn't fetch session info. This won't affect any other features.")
814
- return _session
815
-
816
- def login(username, password, *, timeout=10) -> Session:
817
- """
818
- Creates a session / log in to the Scratch website with the specified username and password.
819
-
820
- This method ...
821
- 1. creates a session id by posting a login request to Scratch's login API. (If this fails, scratchattach.exceptions.LoginFailure is raised)
822
- 2. fetches the xtoken and other information by posting a request to scratch.mit.edu/session. (If this fails, a warning is displayed)
823
-
824
- Args:
825
- username (str)
826
- password (str)
827
-
828
- Keyword arguments:
829
- timeout (int): Timeout for the request to Scratch's login API (in seconds). Defaults to 10.
830
-
831
- Returns:
832
- scratchattach.session.Session: An object that represents the created log in / session
833
- """
834
-
835
- # Post request to login API:
836
- data = json.dumps({"username": username, "password": password})
837
- _headers = dict(headers)
838
- _headers["Cookie"] = "scratchcsrftoken=a;scratchlanguage=en;"
839
- request = requests.post(
840
- "https://scratch.mit.edu/login/", data=data, headers=_headers,
841
- timeout = timeout,
842
- errorhandling = False
843
- )
844
- try:
845
- session_id = str(re.search('"(.*)"', request.headers["Set-Cookie"]).group())
846
- except Exception:
847
- raise exceptions.LoginFailure(
848
- "Either the provided authentication data is wrong or your network is banned from Scratch.\n\nIf you're using an online IDE (like replit.com) Scratch possibly banned its IP adress. In this case, try logging in with your session id: https://github.com/TimMcCool/scratchattach/wiki#logging-in")
849
-
850
- # Create session object:
851
- return login_by_id(session_id, username=username, password=password)
852
-
853
-
854
- def login_by_session_string(session_string) -> Session:
855
- session_string = base64.b64decode(session_string).decode() # unobfuscate
856
- session_data = json.loads(session_string)
857
- try:
858
- assert session_data.get("session_id")
859
- return login_by_id(session_data["session_id"], username=session_data.get("username"), password=session_data.get("password"))
860
- except Exception:
861
- pass
862
- try:
863
- assert session_data.get("username") and session_data.get("password")
864
- return login(username=session_data["username"], password=session_data["password"])
865
- except Exception:
866
- pass
867
- raise ValueError("Couldn't log in.")
1
+ """Session class and login function"""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import datetime
6
+ import hashlib
7
+ import json
8
+ import pathlib
9
+ import random
10
+ import re
11
+ import time
12
+ import warnings
13
+ from typing import Optional, TypeVar, TYPE_CHECKING, overload
14
+ from contextlib import contextmanager
15
+ from threading import local
16
+
17
+ # import secrets
18
+ # import zipfile
19
+ # from typing import Type
20
+ Type = type
21
+ try:
22
+ from warnings import deprecated
23
+ except ImportError:
24
+ deprecated = lambda x: (lambda y: y)
25
+ if TYPE_CHECKING:
26
+ from _typeshed import FileDescriptorOrPath, SupportsRead
27
+ from ..cloud._base import BaseCloud
28
+ T = TypeVar("T", bound=BaseCloud)
29
+ else:
30
+ T = TypeVar("T")
31
+
32
+ from bs4 import BeautifulSoup
33
+
34
+ from . import activity, classroom, forum, studio, user, project, backpack_asset
35
+ # noinspection PyProtectedMember
36
+ from ._base import BaseSiteComponent
37
+ from ..cloud import cloud, _base
38
+ from ..eventhandlers import message_events, filterbot
39
+ from ..other import project_json_capabilities
40
+ from ..utils import commons
41
+ from ..utils import exceptions
42
+ from ..utils.commons import headers, empty_project_json, webscrape_count, get_class_sort_mode
43
+ from ..utils.requests import Requests as requests
44
+
45
+ ratelimit_cache: dict[str, list[float]] = {}
46
+
47
+ def enforce_ratelimit(__type: str, name: str, amount: int = 5, duration: int = 60) -> None:
48
+ cache = ratelimit_cache
49
+ cache.setdefault(__type, [])
50
+ uses = cache[__type]
51
+ while uses[-1] < time.time() - duration:
52
+ uses.pop()
53
+ if len(uses) < amount:
54
+ uses.insert(0, time.time())
55
+ return
56
+ raise exceptions.RateLimitedError(
57
+ f"Rate limit for {name} exceeded.\n"
58
+ "This rate limit is enforced by scratchattach, not by the Scratch API.\n"
59
+ "For security reasons, it cannot be turned off.\n\n"
60
+ "Don't spam-create studios or similar, it WILL get you banned."
61
+ )
62
+
63
+ C = TypeVar("C", bound=BaseSiteComponent)
64
+
65
+ class Session(BaseSiteComponent):
66
+ """
67
+ Represents a Scratch log in / session. Stores authentication data (session id and xtoken).
68
+
69
+ Attributes:
70
+ id: The session id associated with the login
71
+ username: The username associated with the login
72
+ xtoken: The xtoken associated with the login
73
+ email: The email address associated with the logged in account
74
+ new_scratcher: True if the associated account is a new Scratcher
75
+ mute_status: Information about commenting restrictions of the associated account
76
+ banned: Returns True if the associated account is banned
77
+ """
78
+
79
+ def __str__(self) -> str:
80
+ return f"Login for account {self.username!r}"
81
+
82
+ def __init__(self, **entries):
83
+ # Info on how the .update method has to fetch the data:
84
+ self.update_function = requests.post
85
+ self.update_API = "https://scratch.mit.edu/session"
86
+
87
+ # Set attributes every Session object needs to have:
88
+ self.id = None
89
+ self.username = None
90
+ self.xtoken = None
91
+ self.new_scratcher = None
92
+
93
+ # Set attributes that Session object may get
94
+ self._user: user.User = None
95
+
96
+ # Update attributes from entries dict:
97
+ self.__dict__.update(entries)
98
+
99
+ # Set alternative attributes:
100
+ self._username = self.username # backwards compatibility with v1
101
+
102
+ # Base headers and cookies of every session:
103
+ self._headers = dict(headers)
104
+ self._cookies = {
105
+ "scratchsessionsid": self.id,
106
+ "scratchcsrftoken": "a",
107
+ "scratchlanguage": "en",
108
+ "accept": "application/json",
109
+ "Content-Type": "application/json",
110
+ }
111
+
112
+ def _update_from_dict(self, data: dict):
113
+ # Note: there are a lot more things you can get from this data dict.
114
+ # Maybe it would be a good idea to also store the dict itself?
115
+ # self.data = data
116
+
117
+ self.xtoken = data['user']['token']
118
+ self._headers["X-Token"] = self.xtoken
119
+
120
+ self.has_outstanding_email_confirmation = data["flags"]["has_outstanding_email_confirmation"]
121
+
122
+ self.email = data["user"]["email"]
123
+
124
+ self.new_scratcher = data["permissions"]["new_scratcher"]
125
+ self.is_teacher = data["permissions"]["educator"]
126
+
127
+ self.mute_status = data["permissions"]["mute_status"]
128
+
129
+ self.username = data["user"]["username"]
130
+ self._username = data["user"]["username"]
131
+ self.banned = data["user"]["banned"]
132
+
133
+ if self.banned:
134
+ warnings.warn(f"Warning: The account {self._username} you logged in to is BANNED. "
135
+ f"Some features may not work properly.")
136
+ if self.has_outstanding_email_confirmation:
137
+ warnings.warn(f"Warning: The account {self._username} you logged is not email confirmed. "
138
+ f"Some features may not work properly.")
139
+ return True
140
+
141
+ def connect_linked_user(self) -> user.User:
142
+ """
143
+ Gets the user associated with the login / session.
144
+
145
+ Warning:
146
+ The returned User object is cached. To ensure its attribute are up to date, you need to run .update() on it.
147
+
148
+ Returns:
149
+ scratchattach.user.User: Object representing the user associated with the session.
150
+ """
151
+ cached = hasattr(self, "_user")
152
+ if cached:
153
+ cached = self._user is not None
154
+
155
+ if not cached:
156
+ self._user = self.connect_user(self._username)
157
+ return self._user
158
+
159
+ def get_linked_user(self) -> 'user.User':
160
+ # backwards compatibility with v1
161
+
162
+ # To avoid inconsistencies with "connect" and "get", this function was renamed
163
+ return self.connect_linked_user()
164
+
165
+ def set_country(self, country: str = "Antarctica"):
166
+ """
167
+ Sets the profile country of the session's associated user
168
+
169
+ Arguments:
170
+ country (str): The country to relocate to
171
+ """
172
+ requests.post("https://scratch.mit.edu/accounts/settings/",
173
+ data={"country": country},
174
+ headers=self._headers, cookies=self._cookies)
175
+
176
+ def resend_email(self, password: str):
177
+ """
178
+ Sends a request to resend a confirmation email for this session's account
179
+
180
+ Keyword arguments:
181
+ password (str): Password associated with the session (not stored)
182
+ """
183
+ requests.post("https://scratch.mit.edu/accounts/email_change/",
184
+ data={"email_address": self.new_email_address,
185
+ "password": password},
186
+ headers=self._headers, cookies=self._cookies)
187
+
188
+ @property
189
+ def new_email_address(self) -> str:
190
+ """
191
+ Gets the (unconfirmed) email address that this session has requested to transfer to, if any,
192
+ otherwise the current address.
193
+
194
+ Returns:
195
+ str: The email that this session wants to switch to
196
+ """
197
+ response = requests.get("https://scratch.mit.edu/accounts/email_change/",
198
+ headers=self._headers, cookies=self._cookies)
199
+
200
+ soup = BeautifulSoup(response.content, "html.parser")
201
+
202
+ email = None
203
+ for label_span in soup.find_all("span", {"class": "label"}):
204
+ if label_span.contents[0] == "New Email Address":
205
+ return label_span.parent.contents[-1].text.strip("\n ")
206
+
207
+ elif label_span.contents[0] == "Current Email Address":
208
+ email = label_span.parent.contents[-1].text.strip("\n ")
209
+ assert email is not None
210
+ return email
211
+
212
+ def logout(self):
213
+ """
214
+ Sends a logout request to scratch. (Might not do anything, might log out this account on other ips/sessions.)
215
+ """
216
+ requests.post("https://scratch.mit.edu/accounts/logout/",
217
+ headers=self._headers, cookies=self._cookies)
218
+
219
+ def messages(self, *, limit: int = 40, offset: int = 0, date_limit=None, filter_by=None) -> list[activity.Activity]:
220
+ """
221
+ Returns the messages.
222
+
223
+ Keyword arguments:
224
+ limit, offset, date_limit
225
+ filter_by (str or None): Can either be None (no filter), "comments", "projects", "studios" or "forums"
226
+
227
+ Returns:
228
+ list<scratch.activity.Activity>: List that contains all messages as Activity objects.
229
+ """
230
+ add_params = ""
231
+ if date_limit is not None:
232
+ add_params += f"&dateLimit={date_limit}"
233
+ if filter_by is not None:
234
+ add_params += f"&filter={filter_by}"
235
+
236
+ data = commons.api_iterative(
237
+ f"https://api.scratch.mit.edu/users/{self._username}/messages",
238
+ limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies, add_params=add_params
239
+ )
240
+ return commons.parse_object_list(data, activity.Activity, self)
241
+
242
+ def admin_messages(self, *, limit=40, offset=0) -> list[dict]:
243
+ """
244
+ Returns your messages sent by the Scratch team (alerts).
245
+ """
246
+ return commons.api_iterative(
247
+ f"https://api.scratch.mit.edu/users/{self._username}/messages/admin",
248
+ limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies
249
+ )
250
+
251
+ def classroom_alerts(self, _classroom: Optional[classroom.Classroom | int] = None, mode: str = "Last created",
252
+ page: Optional[int] = None):
253
+ if isinstance(_classroom, classroom.Classroom):
254
+ _classroom = _classroom.id
255
+
256
+ if _classroom is None:
257
+ _classroom_str = ''
258
+ else:
259
+ _classroom_str = f"{_classroom}/"
260
+
261
+ ascsort, descsort = get_class_sort_mode(mode)
262
+
263
+ data = requests.get(f"https://scratch.mit.edu/site-api/classrooms/alerts/{_classroom_str}",
264
+ params={"page": page, "ascsort": ascsort, "descsort": descsort},
265
+ headers=self._headers, cookies=self._cookies).json()
266
+
267
+ return data
268
+
269
+ def clear_messages(self):
270
+ """
271
+ Clears all messages.
272
+ """
273
+ return requests.post(
274
+ "https://scratch.mit.edu/site-api/messages/messages-clear/",
275
+ headers=self._headers,
276
+ cookies=self._cookies,
277
+ timeout=10,
278
+ ).text
279
+
280
+ def message_count(self) -> int:
281
+ """
282
+ Returns the message count.
283
+
284
+ Returns:
285
+ int: message count
286
+ """
287
+ return json.loads(requests.get(
288
+ f"https://scratch.mit.edu/messages/ajax/get-message-count/",
289
+ headers=self._headers,
290
+ cookies=self._cookies,
291
+ timeout=10,
292
+ ).text)["msg_count"]
293
+
294
+ # Front-page-related stuff:
295
+
296
+ def feed(self, *, limit=20, offset=0, date_limit=None) -> list[activity.Activity]:
297
+ """
298
+ Returns the "What's happening" section (frontpage).
299
+
300
+ Returns:
301
+ list<scratch.activity.Activity>: List that contains all "What's happening" entries as Activity objects
302
+ """
303
+ add_params = ""
304
+ if date_limit is not None:
305
+ add_params = f"&dateLimit={date_limit}"
306
+ data = commons.api_iterative(
307
+ f"https://api.scratch.mit.edu/users/{self._username}/following/users/activity",
308
+ limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies, add_params=add_params
309
+ )
310
+ return commons.parse_object_list(data, activity.Activity, self)
311
+
312
+ def get_feed(self, *, limit=20, offset=0, date_limit=None):
313
+ # for more consistent names, this method was renamed
314
+ return self.feed(limit=limit, offset=offset, date_limit=date_limit) # for backwards compatibility with v1
315
+
316
+ def loved_by_followed_users(self, *, limit=40, offset=0) -> list[project.Project]:
317
+ """
318
+ Returns the "Projects loved by Scratchers I'm following" section (frontpage).
319
+
320
+ Returns:
321
+ list<scratchattach.project.Project>: List that contains all "Projects loved by Scratchers I'm following"
322
+ entries as Project objects
323
+ """
324
+ data = commons.api_iterative(
325
+ f"https://api.scratch.mit.edu/users/{self._username}/following/users/loves",
326
+ limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies
327
+ )
328
+ return commons.parse_object_list(data, project.Project, self)
329
+
330
+ """
331
+ These methods are disabled because it is unclear if there is any case in which the response is not empty.
332
+ def shared_by_followed_users(self, *, limit=40, offset=0) -> list[project.Project]:
333
+ '''
334
+ Returns the "Projects by Scratchers I'm following" section (frontpage).
335
+ This section is only visible to old accounts (according to the Scratch wiki).
336
+ For newer users, this method will always return an empty list.
337
+
338
+ Returns:
339
+ list<scratchattach.project.Project>: List that contains all "Projects loved by Scratchers I'm following"
340
+ entries as Project objects
341
+ '''
342
+ data = commons.api_iterative(
343
+ f"https://api.scratch.mit.edu/users/{self._username}/following/users/projects",
344
+ limit = limit, offset = offset, headers = self._headers, cookies = self._cookies
345
+ )
346
+ return commons.parse_object_list(data, project.Project, self)
347
+
348
+ def in_followed_studios(self, *, limit=40, offset=0) -> list['project.Project']:
349
+ '''
350
+ Returns the "Projects in studios I'm following" section (frontpage).
351
+ This section is only visible to old accounts (according to the Scratch wiki).
352
+ For newer users, this method will always return an empty list.
353
+
354
+ Returns:
355
+ list<scratchattach.project.Project>: List that contains all "Projects loved by Scratchers I'm following"
356
+ entries as Project objects
357
+ '''
358
+ data = commons.api_iterative(
359
+ f"https://api.scratch.mit.edu/users/{self._username}/following/studios/projects",
360
+ limit = limit, offset = offset, headers = self._headers, cookies = self._cookies
361
+ )
362
+ return commons.parse_object_list(data, project.Project, self)"""
363
+
364
+ # -- Project JSON editing capabilities ---
365
+ # These are set to staticmethods right now, but they probably should not be
366
+ @staticmethod
367
+ def connect_empty_project_pb() -> project_json_capabilities.ProjectBody:
368
+ pb = project_json_capabilities.ProjectBody()
369
+ pb.from_json(empty_project_json)
370
+ return pb
371
+
372
+ @staticmethod
373
+ def connect_pb_from_dict(project_json: dict) -> project_json_capabilities.ProjectBody:
374
+ pb = project_json_capabilities.ProjectBody()
375
+ pb.from_json(project_json)
376
+ return pb
377
+
378
+ @staticmethod
379
+ def connect_pb_from_file(path_to_file) -> project_json_capabilities.ProjectBody:
380
+ pb = project_json_capabilities.ProjectBody()
381
+ # noinspection PyProtectedMember
382
+ # _load_sb3_file starts with an underscore
383
+ pb.from_json(project_json_capabilities._load_sb3_file(path_to_file))
384
+ return pb
385
+
386
+ @staticmethod
387
+ def download_asset(asset_id_with_file_ext, *, filename: Optional[str] = None, fp=""):
388
+ if not (fp.endswith("/") or fp.endswith("\\")):
389
+ fp = fp + "/"
390
+ try:
391
+ if filename is None:
392
+ filename = str(asset_id_with_file_ext)
393
+ response = requests.get(
394
+ "https://assets.scratch.mit.edu/" + str(asset_id_with_file_ext),
395
+ timeout=10,
396
+ )
397
+ open(f"{fp}{filename}", "wb").write(response.content)
398
+ except Exception:
399
+ raise (
400
+ exceptions.FetchError(
401
+ "Failed to download asset"
402
+ )
403
+ )
404
+
405
+ def upload_asset(self, asset_content, *, asset_id=None, file_ext=None):
406
+ data = asset_content if isinstance(asset_content, bytes) else open(asset_content, "rb").read()
407
+
408
+ if isinstance(asset_content, str):
409
+ file_ext = pathlib.Path(asset_content).suffix
410
+ file_ext = file_ext.replace(".", "")
411
+
412
+ if asset_id is None:
413
+ asset_id = hashlib.md5(data).hexdigest()
414
+
415
+ requests.post(
416
+ f"https://assets.scratch.mit.edu/{asset_id}.{file_ext}",
417
+ headers=self._headers,
418
+ cookies=self._cookies,
419
+ data=data,
420
+ timeout=10,
421
+ )
422
+
423
+ # --- Search ---
424
+
425
+ def search_projects(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
426
+ offset: int = 0) -> list[project.Project]:
427
+ """
428
+ Uses the Scratch search to search projects.
429
+
430
+ Keyword arguments:
431
+ query (str): The query that will be searched.
432
+ mode (str): Has to be one of these values: "trending", "popular" or "recent". Defaults to "trending".
433
+ language (str): A language abbreviation, defaults to "en".
434
+ (Depending on the language used on the Scratch website, Scratch displays you different results.)
435
+ limit (int): Max. amount of returned projects.
436
+ offset (int): Offset of the first returned project.
437
+
438
+ Returns:
439
+ list<scratchattach.project.Project>: List that contains the search results.
440
+ """
441
+ response = commons.api_iterative(
442
+ f"https://api.scratch.mit.edu/search/projects", limit=limit, offset=offset,
443
+ add_params=f"&language={language}&mode={mode}&q={query}")
444
+ return commons.parse_object_list(response, project.Project, self)
445
+
446
+ def explore_projects(self, *, query: str = "*", mode: str = "trending", language: str = "en", limit: int = 40,
447
+ offset: int = 0) -> list[project.Project]:
448
+ """
449
+ Gets projects from the explore page.
450
+
451
+ Keyword arguments:
452
+ query (str): Specifies the tag of the explore page.
453
+ To get the projects from the "All" tag, set this argument to "*".
454
+ mode (str): Has to be one of these values: "trending", "popular" or "recent".
455
+ Defaults to "trending".
456
+ language (str): A language abbreviation, defaults to "en".
457
+ (Depending on the language used on the Scratch website, Scratch displays you different explore pages.)
458
+ limit (int): Max. amount of returned projects.
459
+ offset (int): Offset of the first returned project.
460
+
461
+ Returns:
462
+ list<scratchattach.project.Project>: List that contains the explore page projects.
463
+ """
464
+ response = commons.api_iterative(
465
+ f"https://api.scratch.mit.edu/explore/projects", limit=limit, offset=offset,
466
+ add_params=f"&language={language}&mode={mode}&q={query}")
467
+ return commons.parse_object_list(response, project.Project, self)
468
+
469
+ def search_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
470
+ offset: int = 0) -> list[studio.Studio]:
471
+ if not query:
472
+ raise ValueError("The query can't be empty for search")
473
+ response = commons.api_iterative(
474
+ f"https://api.scratch.mit.edu/search/studios", limit=limit, offset=offset,
475
+ add_params=f"&language={language}&mode={mode}&q={query}")
476
+ return commons.parse_object_list(response, studio.Studio, self)
477
+
478
+ def explore_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
479
+ offset: int = 0) -> list[studio.Studio]:
480
+ if not query:
481
+ raise ValueError("The query can't be empty for explore")
482
+ response = commons.api_iterative(
483
+ f"https://api.scratch.mit.edu/explore/studios", limit=limit, offset=offset,
484
+ add_params=f"&language={language}&mode={mode}&q={query}")
485
+ return commons.parse_object_list(response, studio.Studio, self)
486
+
487
+ # --- Create project API ---
488
+
489
+ def create_project(self, *, title: Optional[str] = None, project_json: dict = empty_project_json,
490
+ parent_id=None) -> project.Project: # not working
491
+ """
492
+ Creates a project on the Scratch website.
493
+
494
+ Warning:
495
+ Don't spam this method - it WILL get you banned from Scratch.
496
+ To prevent accidental spam, a rate limit (5 projects per minute) is implemented for this function.
497
+ """
498
+ enforce_ratelimit("create_scratch_project", "creating Scratch projects")
499
+
500
+ if title is None:
501
+ title = f'Untitled-{random.randint(0, 1<<16)}'
502
+
503
+ params = {
504
+ 'is_remix': '0' if parent_id is None else "1",
505
+ 'original_id': parent_id,
506
+ 'title': title,
507
+ }
508
+
509
+ response = requests.post('https://projects.scratch.mit.edu/', params=params, cookies=self._cookies,
510
+ headers=self._headers, json=project_json).json()
511
+ return self.connect_project(response["content-name"])
512
+
513
+ def create_studio(self, *, title: Optional[str] = None, description: Optional[str] = None) -> studio.Studio:
514
+ """
515
+ Create a studio on the scratch website
516
+
517
+ Warning:
518
+ Don't spam this method - it WILL get you banned from Scratch.
519
+ To prevent accidental spam, a rate limit (5 studios per minute) is implemented for this function.
520
+ """
521
+ enforce_ratelimit("create_scratch_studio", "creating Scratch studios")
522
+
523
+ if self.new_scratcher:
524
+ raise exceptions.Unauthorized(f"\nNew scratchers (like {self.username}) cannot create studios.")
525
+
526
+ response = requests.post("https://scratch.mit.edu/studios/create/",
527
+ cookies=self._cookies, headers=self._headers)
528
+
529
+ studio_id = webscrape_count(response.json()["redirect"], "/studios/", "/")
530
+ new_studio = self.connect_studio(studio_id)
531
+
532
+ if title is not None:
533
+ new_studio.set_title(title)
534
+ if description is not None:
535
+ new_studio.set_description(description)
536
+
537
+ return new_studio
538
+
539
+ def create_class(self, title: str, desc: str = '') -> classroom.Classroom:
540
+ """
541
+ Create a class on the scratch website
542
+
543
+ Warning:
544
+ Don't spam this method - it WILL get you banned from Scratch.
545
+ To prevent accidental spam, a rate limit (5 classes per minute) is implemented for this function.
546
+ """
547
+ enforce_ratelimit("create_scratch_class", "creating Scratch classes")
548
+
549
+ if not self.is_teacher:
550
+ raise exceptions.Unauthorized(f"{self.username} is not a teacher; can't create class")
551
+
552
+ data = requests.post("https://scratch.mit.edu/classes/create_classroom/",
553
+ json={"title": title, "description": desc},
554
+ headers=self._headers, cookies=self._cookies).json()
555
+
556
+ class_id = data[0]["id"]
557
+ return self.connect_classroom(class_id)
558
+
559
+ # --- My stuff page ---
560
+
561
+ def mystuff_projects(self, filter_arg: str = "all", *, page: int = 1, sort_by: str = '', descending: bool = True) \
562
+ -> list[project.Project]:
563
+ """
564
+ Gets the projects from the "My stuff" page.
565
+
566
+ Args:
567
+ filter_arg (str): Possible values for this parameter are "all", "shared", "unshared" and "trashed"
568
+
569
+ Keyword Arguments:
570
+ page (int): The page of the "My Stuff" projects that should be returned
571
+ sort_by (str): The key the projects should be sorted based on. Possible values for this parameter are "" (then the projects are sorted based on last modified), "view_count", love_count", "remixers_count" (then the projects are sorted based on remix count) and "title" (then the projects are sorted based on title)
572
+ descending (boolean): Determines if the element with the highest key value (the key is specified in the sort_by argument) should be returned first. Defaults to True.
573
+
574
+ Returns:
575
+ list<scratchattach.project.Project>: A list with the projects from the "My Stuff" page, each project is represented by a Project object.
576
+ """
577
+ if descending:
578
+ ascsort = ""
579
+ descsort = sort_by
580
+ else:
581
+ ascsort = sort_by
582
+ descsort = ""
583
+ try:
584
+ targets = requests.get(
585
+ f"https://scratch.mit.edu/site-api/projects/{filter_arg}/?page={page}&ascsort={ascsort}&descsort={descsort}",
586
+ headers=headers,
587
+ cookies=self._cookies,
588
+ timeout=10,
589
+ ).json()
590
+ projects = []
591
+ for target in targets:
592
+ projects.append(project.Project(
593
+ id=target["pk"], _session=self, author_name=self._username,
594
+ comments_allowed=None, instructions=None, notes=None,
595
+ created=target["fields"]["datetime_created"],
596
+ last_modified=target["fields"]["datetime_modified"],
597
+ share_date=target["fields"]["datetime_shared"],
598
+ thumbnail_url="https:" + target["fields"]["thumbnail_url"],
599
+ favorites=target["fields"]["favorite_count"],
600
+ loves=target["fields"]["love_count"],
601
+ remixes=target["fields"]["remixers_count"],
602
+ views=target["fields"]["view_count"],
603
+ title=target["fields"]["title"],
604
+ comment_count=target["fields"]["commenters_count"]
605
+ ))
606
+ return projects
607
+ except Exception:
608
+ raise exceptions.FetchError()
609
+
610
+ def mystuff_studios(self, filter_arg: str = "all", *, page: int = 1, sort_by: str = "", descending: bool = True) \
611
+ -> list[studio.Studio]:
612
+ if descending:
613
+ ascsort = ""
614
+ descsort = sort_by
615
+ else:
616
+ ascsort = sort_by
617
+ descsort = ""
618
+ try:
619
+ targets = requests.get(
620
+ f"https://scratch.mit.edu/site-api/galleries/{filter_arg}/",
621
+ params={"page": page, "ascsort": ascsort, "descsort": descsort},
622
+ headers=headers,
623
+ cookies=self._cookies,
624
+ timeout=10
625
+ ).json()
626
+ studios = []
627
+ for target in targets:
628
+ studios.append(studio.Studio(
629
+ id=target["pk"], _session=self,
630
+ title=target["fields"]["title"],
631
+ description=None,
632
+ host_id=target["fields"]["owner"]["pk"],
633
+ host_name=target["fields"]["owner"]["username"],
634
+ open_to_all=None, comments_allowed=None,
635
+ image_url="https:" + target["fields"]["thumbnail_url"],
636
+ created=target["fields"]["datetime_created"],
637
+ modified=target["fields"]["datetime_modified"],
638
+ follower_count=None, manager_count=None,
639
+ curator_count=target["fields"]["curators_count"],
640
+ project_count=target["fields"]["projecters_count"]
641
+ ))
642
+ return studios
643
+ except Exception:
644
+ raise exceptions.FetchError()
645
+
646
+ def mystuff_classes(self, mode: str = "Last created", page: Optional[int] = None) -> list[classroom.Classroom]:
647
+ if not self.is_teacher:
648
+ raise exceptions.Unauthorized(f"{self.username} is not a teacher; can't have classes")
649
+ ascsort, descsort = get_class_sort_mode(mode)
650
+
651
+ classes_data = requests.get("https://scratch.mit.edu/site-api/classrooms/all/",
652
+ params={"page": page, "ascsort": ascsort, "descsort": descsort},
653
+ headers=self._headers, cookies=self._cookies).json()
654
+ classes = []
655
+ for data in classes_data:
656
+ fields = data["fields"]
657
+ educator_pf = fields["educator_profile"]
658
+ classes.append(classroom.Classroom(
659
+ id=data["pk"],
660
+ title=fields["title"],
661
+ classtoken=fields["token"],
662
+ datetime=datetime.datetime.fromisoformat(fields["datetime_created"]),
663
+ author=user.User(
664
+ username=educator_pf["user"]["username"], id=educator_pf["user"]["pk"], _session=self),
665
+ _session=self))
666
+ return classes
667
+
668
+ def mystuff_ended_classes(self, mode: str = "Last created", page: Optional[int] = None) -> list[classroom.Classroom]:
669
+ if not self.is_teacher:
670
+ raise exceptions.Unauthorized(f"{self.username} is not a teacher; can't have (deleted) classes")
671
+ ascsort, descsort = get_class_sort_mode(mode)
672
+
673
+ classes_data = requests.get("https://scratch.mit.edu/site-api/classrooms/closed/",
674
+ params={"page": page, "ascsort": ascsort, "descsort": descsort},
675
+ headers=self._headers, cookies=self._cookies).json()
676
+ classes = []
677
+ for data in classes_data:
678
+ fields = data["fields"]
679
+ educator_pf = fields["educator_profile"]
680
+ classes.append(classroom.Classroom(
681
+ id=data["pk"],
682
+ title=fields["title"],
683
+ classtoken=fields["token"],
684
+ datetime=datetime.datetime.fromisoformat(fields["datetime_created"]),
685
+ author=user.User(
686
+ username=educator_pf["user"]["username"], id=educator_pf["user"]["pk"], _session=self),
687
+ _session=self))
688
+ return classes
689
+
690
+ def backpack(self, limit: int = 20, offset: int = 0) -> list[backpack_asset.BackpackAsset]:
691
+ """
692
+ Lists the assets that are in the backpack of the user associated with the session.
693
+
694
+ Returns:
695
+ list<backpack_asset.BackpackAsset>: List that contains the backpack items
696
+ """
697
+ data = commons.api_iterative(
698
+ f"https://backpack.scratch.mit.edu/{self._username}",
699
+ limit=limit, offset=offset, _headers=self._headers
700
+ )
701
+ return commons.parse_object_list(data, backpack_asset.BackpackAsset, self)
702
+
703
+ def delete_from_backpack(self, backpack_asset_id) -> backpack_asset.BackpackAsset:
704
+ """
705
+ Deletes an asset from the backpack.
706
+
707
+ Args:
708
+ backpack_asset_id: ID of the backpack asset that will be deleted
709
+ """
710
+ return backpack_asset.BackpackAsset(id=backpack_asset_id, _session=self).delete()
711
+
712
+ def become_scratcher_invite(self) -> dict:
713
+ """
714
+ If you are a new Scratcher and have been invited for becoming a Scratcher, this API endpoint will provide
715
+ more info on the invite.
716
+ """
717
+ return requests.get(f"https://api.scratch.mit.edu/users/{self.username}/invites", headers=self._headers,
718
+ cookies=self._cookies).json()
719
+
720
+ # --- Connect classes inheriting from BaseCloud ---
721
+
722
+
723
+ @overload
724
+ def connect_cloud(self, project_id, *, cloud_class: type[T]) -> T:
725
+ """
726
+ Connects to a cloud as logged-in user.
727
+
728
+ Args:
729
+ project_id:
730
+
731
+ Keyword arguments: CloudClass: The class that the returned object should be of. By default, this class is
732
+ scratchattach.cloud.ScratchCloud.
733
+
734
+ Returns: Type[scratchattach.cloud._base.BaseCloud]: An object representing the cloud of a project. Can be of any
735
+ class inheriting from BaseCloud.
736
+ """
737
+
738
+ @overload
739
+ def connect_cloud(self, project_id) -> cloud.ScratchCloud:
740
+ """
741
+ Connects to a cloud (by default Scratch's cloud) as logged-in user.
742
+
743
+ Args:
744
+ project_id:
745
+
746
+ Keyword arguments: CloudClass: The class that the returned object should be of. By default, this class is
747
+ scratchattach.cloud.ScratchCloud.
748
+
749
+ Returns: Type[scratchattach.cloud._base.BaseCloud]: An object representing the cloud of a project. Can be of any
750
+ class inheriting from BaseCloud.
751
+ """
752
+ # noinspection PyPep8Naming
753
+ def connect_cloud(self, project_id, *, cloud_class: Optional[type[_base.BaseCloud]] = None) \
754
+ -> _base.BaseCloud:
755
+ cloud_class = cloud_class or cloud.ScratchCloud
756
+ return cloud_class(project_id=project_id, _session=self)
757
+
758
+ def connect_scratch_cloud(self, project_id) -> cloud.ScratchCloud:
759
+ """
760
+ Returns:
761
+ scratchattach.cloud.ScratchCloud: An object representing the Scratch cloud of a project.
762
+ """
763
+ return cloud.ScratchCloud(project_id=project_id, _session=self)
764
+
765
+ def connect_tw_cloud(self, project_id, *, purpose="", contact="",
766
+ cloud_host="wss://clouddata.turbowarp.org") -> cloud.TwCloud:
767
+ """
768
+ Returns:
769
+ scratchattach.cloud.TwCloud: An object representing the TurboWarp cloud of a project.
770
+ """
771
+ return cloud.TwCloud(project_id=project_id, purpose=purpose, contact=contact, cloud_host=cloud_host,
772
+ _session=self)
773
+
774
+ # --- Connect classes inheriting from BaseSiteComponent ---
775
+
776
+ # noinspection PyPep8Naming
777
+ # Class is camelcase here
778
+ def _make_linked_object(self, identificator_name, identificator, __class: type[C],
779
+ NotFoundException: type[Exception]) -> C:
780
+ """
781
+ The Session class doesn't save the login in a ._session attribute, but IS the login ITSELF.
782
+
783
+ Therefore, the _make_linked_object method has to be adjusted
784
+ to get it to work for in the Session class.
785
+
786
+ Class must inherit from BaseSiteComponent
787
+ """
788
+ # noinspection PyProtectedMember
789
+ # _get_object is protected
790
+ return commons._get_object(identificator_name, identificator, __class, NotFoundException, self)
791
+
792
+ def connect_user(self, username: str) -> user.User:
793
+ """
794
+ Gets a user using this session, connects the session to the User object to allow authenticated actions
795
+
796
+ Args:
797
+ username (str): Username of the requested user
798
+
799
+ Returns:
800
+ scratchattach.user.User: An object that represents the requested user and allows you to perform actions on the user (like user.follow)
801
+ """
802
+ return self._make_linked_object("username", username, user.User, exceptions.UserNotFound)
803
+
804
+ @deprecated("Finding usernames by user ids has been fixed.")
805
+ def find_username_from_id(self, user_id: int) -> str:
806
+ """
807
+ Warning:
808
+ Every time this functions is run, a comment on your profile is posted and deleted. Therefore you shouldn't run this too often.
809
+
810
+ Returns:
811
+ str: The username that corresponds to the user id
812
+ """
813
+ you = user.User(username=self.username, _session=self)
814
+ try:
815
+ comment = you.post_comment("scratchattach", commentee_id=int(user_id))
816
+ except exceptions.CommentPostFailure:
817
+ raise exceptions.BadRequest(
818
+ "After posting a comment, you need to wait 10 seconds before you can connect users by id again.")
819
+ except exceptions.BadRequest:
820
+ raise exceptions.UserNotFound("Invalid user id")
821
+ except Exception as e:
822
+ raise e
823
+ you.delete_comment(comment_id=comment.id)
824
+ try:
825
+ username = comment.content.split('">@')[1]
826
+ username = username.split("</a>")[0]
827
+ except IndexError:
828
+ raise exceptions.UserNotFound()
829
+ return username
830
+
831
+ @deprecated("Finding usernames by user ids has been fixed.")
832
+ def connect_user_by_id(self, user_id: int) -> user.User:
833
+ """
834
+ Gets a user using this session, connects the session to the User object to allow authenticated actions
835
+
836
+ This method ...
837
+ 1) gets the username by posting a comment with the user_id as commentee_id.
838
+ 2) deletes the posted comment.
839
+ 3) fetches other information about the user using Scratch's api.scratch.mit.edu/users/username API.
840
+
841
+ Warning:
842
+ Every time this functions is run, a comment on your profile is posted and deleted. Therefore, you shouldn't run this too often.
843
+
844
+ Args:
845
+ user_id (int): User ID of the requested user
846
+
847
+ Returns:
848
+ scratchattach.user.User: An object that represents the requested user and allows you to perform actions on the user (like user.follow)
849
+ """
850
+ return self._make_linked_object("username", self.find_username_from_id(user_id), user.User,
851
+ exceptions.UserNotFound)
852
+
853
+ def connect_project(self, project_id) -> project.Project:
854
+ """
855
+ Gets a project using this session, connects the session to the Project object to allow authenticated actions
856
+ sess
857
+ Args:
858
+ project_id (int): ID of the requested project
859
+
860
+ Returns:
861
+ scratchattach.project.Project: An object that represents the requested project and allows you to perform actions on the project (like project.love)
862
+ """
863
+ return self._make_linked_object("id", int(project_id), project.Project, exceptions.ProjectNotFound)
864
+
865
+ def connect_studio(self, studio_id) -> studio.Studio:
866
+ """
867
+ Gets a studio using this session, connects the session to the Studio object to allow authenticated actions
868
+
869
+ Args:
870
+ studio_id (int): ID of the requested studio
871
+
872
+ Returns:
873
+ scratchattach.studio.Studio: An object that represents the requested studio and allows you to perform actions on the studio (like studio.follow)
874
+ """
875
+ return self._make_linked_object("id", int(studio_id), studio.Studio, exceptions.StudioNotFound)
876
+
877
+ def connect_classroom(self, class_id) -> classroom.Classroom:
878
+ """
879
+ Gets a class using this session.
880
+
881
+ Args:
882
+ class_id (str): class id of the requested class
883
+
884
+ Returns:
885
+ scratchattach.classroom.Classroom: An object representing the requested classroom
886
+ """
887
+ return self._make_linked_object("id", int(class_id), classroom.Classroom, exceptions.ClassroomNotFound)
888
+
889
+ def connect_classroom_from_token(self, class_token) -> classroom.Classroom:
890
+ """
891
+ Gets a class using this session.
892
+
893
+ Args:
894
+ class_token (str): class token of the requested class
895
+
896
+ Returns:
897
+ scratchattach.classroom.Classroom: An object representing the requested classroom
898
+ """
899
+ return self._make_linked_object("classtoken", int(class_token), classroom.Classroom,
900
+ exceptions.ClassroomNotFound)
901
+
902
+ def connect_topic(self, topic_id) -> forum.ForumTopic:
903
+ """
904
+ Gets a forum topic using this session, connects the session to the ForumTopic object to allow authenticated actions
905
+ Data is up-to-date. Data received from Scratch's RSS feed XML API.
906
+
907
+ Args:
908
+ topic_id (int): ID of the requested forum topic (can be found in the browser URL bar)
909
+
910
+ Returns:
911
+ scratchattach.forum.ForumTopic: An object that represents the requested forum topic
912
+ """
913
+ return self._make_linked_object("id", int(topic_id), forum.ForumTopic, exceptions.ForumContentNotFound)
914
+
915
+ def connect_topic_list(self, category_id, *, page=1):
916
+
917
+ """
918
+ Gets the topics from a forum category. Data web-scraped from Scratch's forums UI.
919
+ Data is up-to-date.
920
+
921
+ Args:
922
+ category_id (str): ID of the forum category
923
+
924
+ Keyword Arguments:
925
+ page (str): Page of the category topics that should be returned
926
+
927
+ Returns:
928
+ list<scratchattach.forum.ForumTopic>: A list containing the forum topics from the specified category
929
+ """
930
+
931
+ try:
932
+ response = requests.get(f"https://scratch.mit.edu/discuss/{category_id}/?page={page}",
933
+ headers=self._headers, cookies=self._cookies)
934
+ soup = BeautifulSoup(response.content, 'html.parser')
935
+ except Exception as e:
936
+ raise exceptions.FetchError(str(e))
937
+
938
+ try:
939
+ category_name = soup.find('h4').find("span").get_text()
940
+ except Exception:
941
+ raise exceptions.BadRequest("Invalid category id")
942
+
943
+ try:
944
+ topics = soup.find_all('tr')
945
+ topics.pop(0)
946
+ return_topics = []
947
+
948
+ for topic in topics:
949
+ title_link = topic.find('a')
950
+ title = title_link.text.strip()
951
+ topic_id = title_link['href'].split('/')[-2]
952
+
953
+ columns = topic.find_all('td')
954
+ columns = [column.text for column in columns]
955
+ if len(columns) == 1:
956
+ # This is a sticky topic -> Skip it
957
+ continue
958
+
959
+ last_updated = columns[3].split(" ")[0] + " " + columns[3].split(" ")[1]
960
+
961
+ return_topics.append(
962
+ forum.ForumTopic(_session=self, id=int(topic_id), title=title, category_name=category_name,
963
+ last_updated=last_updated, reply_count=int(columns[1]),
964
+ view_count=int(columns[2])))
965
+ return return_topics
966
+ except Exception as e:
967
+ raise exceptions.ScrapeError(str(e))
968
+
969
+ # --- Connect classes inheriting from BaseEventHandler ---
970
+
971
+ def connect_message_events(self, *, update_interval=2) -> message_events.MessageEvents:
972
+ # shortcut for connect_linked_user().message_events()
973
+ return message_events.MessageEvents(user.User(username=self.username, _session=self),
974
+ update_interval=update_interval)
975
+
976
+ def connect_filterbot(self, *, log_deletions=True) -> filterbot.Filterbot:
977
+ return filterbot.Filterbot(user.User(username=self.username, _session=self), log_deletions=log_deletions)
978
+
979
+
980
+ # ------ #
981
+
982
+ suppressed_login_warning = local()
983
+
984
+ @contextmanager
985
+ def suppress_login_warning():
986
+ """
987
+ Suppress the login warning.
988
+ """
989
+ suppressed_login_warning.suppressed = getattr(suppressed_login_warning, "suppressed", 0)
990
+ try:
991
+ suppressed_login_warning.suppressed += 1
992
+ yield
993
+ finally:
994
+ suppressed_login_warning.suppressed -= 1
995
+
996
+ def issue_login_warning() -> None:
997
+ """
998
+ Issue a login data warning.
999
+ """
1000
+ if getattr(suppressed_login_warning, "suppressed", 0):
1001
+ return
1002
+ warnings.warn(
1003
+ "IMPORTANT: If you included login credentials directly in your code (e.g. session_id, session_string, ...), \
1004
+ then make sure to EITHER instead load them from environment variables or files OR remember to remove them before \
1005
+ you share your code with anyone else. If you want to remove this warning, \
1006
+ use `warnings.filterwarnings('ignore', category=scratchattach.LoginDataWarning)`",
1007
+ exceptions.LoginDataWarning
1008
+ )
1009
+
1010
+ def login_by_id(session_id: str, *, username: Optional[str] = None, password: Optional[str] = None, xtoken=None) -> Session:
1011
+ """
1012
+ Creates a session / log in to the Scratch website with the specified session id.
1013
+ Structured similarly to Session._connect_object method.
1014
+
1015
+ Args:
1016
+ session_id (str)
1017
+
1018
+ Keyword arguments:
1019
+ username (str)
1020
+ password (str)
1021
+ xtoken (str)
1022
+
1023
+ Returns:
1024
+ scratchattach.session.Session: An object that represents the created login / session
1025
+ """
1026
+ # Removed this from docstring since it doesn't exist:
1027
+ # timeout (int): Optional, but recommended.
1028
+ # Specify this when the Python environment's IP address is blocked by Scratch's API,
1029
+ # but you still want to use cloud variables.
1030
+
1031
+ # Generate session_string (a scratchattach-specific authentication method)
1032
+ issue_login_warning()
1033
+ if password is not None:
1034
+ session_data = dict(session_id=session_id, username=username, password=password)
1035
+ session_string = base64.b64encode(json.dumps(session_data).encode())
1036
+ else:
1037
+ session_string = None
1038
+ _session = Session(id=session_id, username=username, session_string=session_string, xtoken=xtoken)
1039
+
1040
+ try:
1041
+ status = _session.update()
1042
+ except Exception as e:
1043
+ status = False
1044
+ warnings.warn(f"Key error at key {e} when reading scratch.mit.edu/session API response")
1045
+
1046
+ if status is not True:
1047
+ if _session.xtoken is None:
1048
+ if _session.username is None:
1049
+ warnings.warn("Warning: Logged in by id, but couldn't fetch XToken. "
1050
+ "Make sure the provided session id is valid. "
1051
+ "Setting cloud variables can still work if you provide a "
1052
+ "`username='username'` keyword argument to the sa.login_by_id function")
1053
+ else:
1054
+ warnings.warn("Warning: Logged in by id, but couldn't fetch XToken. "
1055
+ "Make sure the provided session id is valid.")
1056
+ else:
1057
+ warnings.warn("Warning: Logged in by id, but couldn't fetch session info. "
1058
+ "This won't affect any other features.")
1059
+ return _session
1060
+
1061
+
1062
+ def login(username, password, *, timeout=10) -> Session:
1063
+ """
1064
+ Creates a session / log in to the Scratch website with the specified username and password.
1065
+
1066
+ This method ...
1067
+ 1. creates a session id by posting a login request to Scratch's login API. (If this fails, scratchattach.exceptions.LoginFailure is raised)
1068
+ 2. fetches the xtoken and other information by posting a request to scratch.mit.edu/session. (If this fails, a warning is displayed)
1069
+
1070
+ Args:
1071
+ username (str)
1072
+ password (str)
1073
+
1074
+ Keyword arguments:
1075
+ timeout (int): Timeout for the request to Scratch's login API (in seconds). Defaults to 10.
1076
+
1077
+ Returns:
1078
+ scratchattach.session.Session: An object that represents the created login / session
1079
+ """
1080
+ issue_login_warning()
1081
+
1082
+ # Post request to login API:
1083
+ _headers = headers.copy()
1084
+ _headers["Cookie"] = "scratchcsrftoken=a;scratchlanguage=en;"
1085
+ request = requests.post(
1086
+ "https://scratch.mit.edu/login/", json={"username": username, "password": password}, headers=_headers,
1087
+
1088
+ timeout=timeout, errorhandling = False
1089
+ )
1090
+ try:
1091
+ session_id = str(re.search('"(.*)"', request.headers["Set-Cookie"]).group())
1092
+ except (AttributeError, Exception):
1093
+ raise exceptions.LoginFailure(
1094
+ "Either the provided authentication data is wrong or your network is banned from Scratch.\n\nIf you're using an online IDE (like replit.com) Scratch possibly banned its IP address. In this case, try logging in with your session id: https://github.com/TimMcCool/scratchattach/wiki#logging-in")
1095
+
1096
+ # Create session object:
1097
+ with suppress_login_warning():
1098
+ return login_by_id(session_id, username=username, password=password)
1099
+
1100
+ def login_by_session_string(session_string: str) -> Session:
1101
+ """
1102
+ Login using a session string.
1103
+ """
1104
+ issue_login_warning()
1105
+ session_string = base64.b64decode(session_string).decode() # unobfuscate
1106
+ session_data = json.loads(session_string)
1107
+ try:
1108
+ assert session_data.get("session_id")
1109
+ with suppress_login_warning():
1110
+ return login_by_id(session_data["session_id"], username=session_data.get("username"),
1111
+ password=session_data.get("password"))
1112
+ except Exception:
1113
+ pass
1114
+ try:
1115
+ assert session_data.get("username") and session_data.get("password")
1116
+ with suppress_login_warning():
1117
+ return login(username=session_data["username"], password=session_data["password"])
1118
+ except Exception:
1119
+ pass
1120
+ raise ValueError("Couldn't log in.")
1121
+
1122
+ def login_by_io(file: SupportsRead[str]) -> Session:
1123
+ """
1124
+ Login using a file object.
1125
+ """
1126
+ with suppress_login_warning():
1127
+ return login_by_session_string(file.read())
1128
+
1129
+ def login_by_file(file: FileDescriptorOrPath) -> Session:
1130
+ """
1131
+ Login using a path to a file.
1132
+ """
1133
+ with suppress_login_warning(), open(file, encoding="utf-8") as f:
1134
+ return login_by_io(f)