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