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