scratchattach 2.1.12__py3-none-any.whl → 2.1.14__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 (56) hide show
  1. scratchattach/cloud/_base.py +12 -8
  2. scratchattach/cloud/cloud.py +19 -7
  3. scratchattach/editor/asset.py +59 -5
  4. scratchattach/editor/base.py +82 -31
  5. scratchattach/editor/block.py +87 -15
  6. scratchattach/editor/blockshape.py +8 -4
  7. scratchattach/editor/build_defaulting.py +6 -2
  8. scratchattach/editor/code_translation/__init__.py +0 -0
  9. scratchattach/editor/code_translation/parse.py +177 -0
  10. scratchattach/editor/comment.py +6 -0
  11. scratchattach/editor/commons.py +82 -19
  12. scratchattach/editor/extension.py +10 -3
  13. scratchattach/editor/field.py +9 -0
  14. scratchattach/editor/inputs.py +4 -1
  15. scratchattach/editor/meta.py +11 -3
  16. scratchattach/editor/monitor.py +46 -38
  17. scratchattach/editor/mutation.py +11 -4
  18. scratchattach/editor/pallete.py +24 -25
  19. scratchattach/editor/prim.py +2 -2
  20. scratchattach/editor/project.py +9 -3
  21. scratchattach/editor/sprite.py +19 -6
  22. scratchattach/editor/twconfig.py +2 -1
  23. scratchattach/editor/vlb.py +1 -1
  24. scratchattach/eventhandlers/_base.py +3 -3
  25. scratchattach/eventhandlers/cloud_events.py +2 -2
  26. scratchattach/eventhandlers/cloud_requests.py +4 -7
  27. scratchattach/eventhandlers/cloud_server.py +3 -3
  28. scratchattach/eventhandlers/combine.py +2 -2
  29. scratchattach/eventhandlers/message_events.py +1 -1
  30. scratchattach/other/other_apis.py +4 -4
  31. scratchattach/other/project_json_capabilities.py +3 -3
  32. scratchattach/site/_base.py +13 -12
  33. scratchattach/site/activity.py +11 -43
  34. scratchattach/site/alert.py +227 -0
  35. scratchattach/site/backpack_asset.py +2 -2
  36. scratchattach/site/browser_cookie3_stub.py +17 -0
  37. scratchattach/site/browser_cookies.py +27 -21
  38. scratchattach/site/classroom.py +51 -34
  39. scratchattach/site/cloud_activity.py +4 -4
  40. scratchattach/site/comment.py +30 -8
  41. scratchattach/site/forum.py +101 -69
  42. scratchattach/site/project.py +37 -17
  43. scratchattach/site/session.py +177 -83
  44. scratchattach/site/studio.py +4 -4
  45. scratchattach/site/user.py +184 -62
  46. scratchattach/utils/commons.py +35 -23
  47. scratchattach/utils/enums.py +44 -5
  48. scratchattach/utils/exceptions.py +10 -0
  49. scratchattach/utils/requests.py +57 -31
  50. {scratchattach-2.1.12.dist-info → scratchattach-2.1.14.dist-info}/METADATA +9 -3
  51. scratchattach-2.1.14.dist-info/RECORD +66 -0
  52. {scratchattach-2.1.12.dist-info → scratchattach-2.1.14.dist-info}/WHEEL +1 -1
  53. scratchattach/editor/sbuild.py +0 -2837
  54. scratchattach-2.1.12.dist-info/RECORD +0 -63
  55. {scratchattach-2.1.12.dist-info → scratchattach-2.1.14.dist-info}/licenses/LICENSE +0 -0
  56. {scratchattach-2.1.12.dist-info → scratchattach-2.1.14.dist-info}/top_level.txt +0 -0
@@ -5,15 +5,19 @@ import json
5
5
  import random
6
6
  import base64
7
7
  import time
8
+ import zipfile
9
+ from io import BytesIO
10
+
11
+ from typing import Any
8
12
  from . import user, comment, studio
9
- from ..utils import exceptions
10
- from ..utils import commons
11
- from ..utils.commons import empty_project_json, headers
13
+ from scratchattach.utils import exceptions
14
+ from scratchattach.utils import commons
15
+ from scratchattach.utils.commons import empty_project_json, headers
12
16
  from ._base import BaseSiteComponent
13
- from ..other.project_json_capabilities import ProjectBody
14
- from ..utils.requests import Requests as requests
17
+ from scratchattach.other.project_json_capabilities import ProjectBody
18
+ from scratchattach.utils.requests import requests
15
19
 
16
- CREATE_PROJECT_USES = []
20
+ CREATE_PROJECT_USES: list[float] = []
17
21
 
18
22
  class PartialProject(BaseSiteComponent):
19
23
  """
@@ -27,7 +31,7 @@ class PartialProject(BaseSiteComponent):
27
31
 
28
32
  # Info on how the .update method has to fetch the data:
29
33
  self.update_function = requests.get
30
- self.update_API = f"https://api.scratch.mit.edu/projects/{entries['id']}"
34
+ self.update_api = f"https://api.scratch.mit.edu/projects/{entries['id']}"
31
35
 
32
36
  # Set attributes every Project object needs to have:
33
37
  self._session = None
@@ -126,6 +130,9 @@ class PartialProject(BaseSiteComponent):
126
130
  p = get_project(self.id)
127
131
  return isinstance(p, Project)
128
132
 
133
+ def raw_json_or_empty(self) -> dict[str, Any]:
134
+ return empty_project_json
135
+
129
136
  def create_remix(self, *, title=None, project_json=None): # not working
130
137
  """
131
138
  Creates a project on the Scratch website.
@@ -142,10 +149,7 @@ class PartialProject(BaseSiteComponent):
142
149
  else:
143
150
  title = " remix"
144
151
  if project_json is None:
145
- if "title" in self.__dict__:
146
- project_json = self.raw_json()
147
- else:
148
- project_json = empty_project_json
152
+ project_json = self.raw_json_or_empty()
149
153
 
150
154
  if len(CREATE_PROJECT_USES) < 5:
151
155
  CREATE_PROJECT_USES.insert(0, time.time())
@@ -306,16 +310,32 @@ class Project(PartialProject):
306
310
  """
307
311
  try:
308
312
  self.update()
309
- return requests.get(
310
- f"https://projects.scratch.mit.edu/{self.id}?token={self.project_token}",
311
- timeout=10,
312
- ).json()
313
- except Exception:
313
+
314
+ except Exception as e:
314
315
  raise (
315
316
  exceptions.FetchError(
316
- "Either the project was created with an old Scratch version, or you're not authorized for accessing it"
317
+ f"You're not authorized for accessing {self}.\nException: {e}"
317
318
  )
318
319
  )
320
+
321
+ with requests.no_error_handling():
322
+ resp = requests.get(
323
+ f"https://projects.scratch.mit.edu/{self.id}?token={self.project_token}",
324
+ timeout=10,
325
+ )
326
+
327
+ try:
328
+ return resp.json()
329
+ except json.JSONDecodeError:
330
+ # I am not aware of any cases where this will not be a zip file
331
+ # in the future, cache a projectbody object here and just return the json
332
+ # that is fetched from there to not waste existing asset data from this zip file
333
+
334
+ with zipfile.ZipFile(BytesIO(resp.content)) as zipf:
335
+ return json.load(zipf.open("project.json"))
336
+
337
+ def raw_json_or_empty(self):
338
+ return self.raw_json()
319
339
 
320
340
  def creator_agent(self):
321
341
  """
@@ -10,37 +10,34 @@ import random
10
10
  import re
11
11
  import time
12
12
  import warnings
13
- from typing import Optional, TypeVar, TYPE_CHECKING, overload
13
+ import zlib
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Optional, TypeVar, TYPE_CHECKING, overload, Any, Union
14
17
  from contextlib import contextmanager
15
18
  from threading import local
16
19
 
17
- # import secrets
18
- # import zipfile
19
- # from typing import Type
20
20
  Type = type
21
- try:
22
- from warnings import deprecated
23
- except ImportError:
24
- deprecated = lambda x: (lambda y: y)
21
+
25
22
  if TYPE_CHECKING:
26
23
  from _typeshed import FileDescriptorOrPath, SupportsRead
27
- from ..cloud._base import BaseCloud
24
+ from scratchattach.cloud._base import BaseCloud
28
25
  T = TypeVar("T", bound=BaseCloud)
29
26
  else:
30
27
  T = TypeVar("T")
31
28
 
32
- from bs4 import BeautifulSoup
29
+ from bs4 import BeautifulSoup, Tag
30
+ from typing_extensions import deprecated
33
31
 
34
- from . import activity, classroom, forum, studio, user, project, backpack_asset
32
+ from . import activity, classroom, forum, studio, user, project, backpack_asset, alert
35
33
  # noinspection PyProtectedMember
36
34
  from ._base import BaseSiteComponent
37
- from ..cloud import cloud, _base
38
- from ..eventhandlers import message_events, filterbot
39
- from ..other import project_json_capabilities
40
- from ..utils import commons
41
- from ..utils import exceptions
42
- from ..utils.commons import headers, empty_project_json, webscrape_count, get_class_sort_mode
43
- from ..utils.requests import Requests as requests
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
44
41
  from .browser_cookies import Browser, ANY, cookies_from_browser
45
42
 
46
43
  ratelimit_cache: dict[str, list[float]] = {}
@@ -63,6 +60,7 @@ def enforce_ratelimit(__type: str, name: str, amount: int = 5, duration: int = 6
63
60
 
64
61
  C = TypeVar("C", bound=BaseSiteComponent)
65
62
 
63
+ @dataclass
66
64
  class Session(BaseSiteComponent):
67
65
  """
68
66
  Represents a Scratch log in / session. Stores authentication data (session id and xtoken).
@@ -76,26 +74,29 @@ class Session(BaseSiteComponent):
76
74
  mute_status: Information about commenting restrictions of the associated account
77
75
  banned: Returns True if the associated account is banned
78
76
  """
77
+ username: str = None
78
+ _user: user.User = field(repr=False, default=None)
79
79
 
80
- def __str__(self) -> str:
81
- return f"Login for account {self.username!r}"
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)
82
84
 
83
- def __init__(self, **entries):
84
- # Info on how the .update method has to fetch the data:
85
- self.update_function = requests.post
86
- self.update_API = "https://scratch.mit.edu/session"
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)
87
89
 
88
- # Set attributes every Session object needs to have:
89
- self.id = None
90
- self.username = None
91
- self.xtoken = None
92
- self.new_scratcher = None
90
+ time_created: datetime.datetime = None
91
+ language: str = field(repr=False, default="en")
93
92
 
94
- # Set attributes that Session object may get
95
- self._user: user.User = None
93
+ def __str__(self) -> str:
94
+ return f"<Login for {self.username!r}>"
96
95
 
97
- # Update attributes from entries dict:
98
- self.__dict__.update(entries)
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"
99
100
 
100
101
  # Set alternative attributes:
101
102
  self._username = self.username # backwards compatibility with v1
@@ -110,6 +111,9 @@ class Session(BaseSiteComponent):
110
111
  "Content-Type": "application/json",
111
112
  }
112
113
 
114
+ if self.id:
115
+ self._process_session_id()
116
+
113
117
  def _update_from_dict(self, data: dict):
114
118
  # Note: there are a lot more things you can get from this data dict.
115
119
  # Maybe it would be a good idea to also store the dict itself?
@@ -132,13 +136,34 @@ class Session(BaseSiteComponent):
132
136
  self.banned = data["user"]["banned"]
133
137
 
134
138
  if self.banned:
135
- warnings.warn(f"Warning: The account {self._username} you logged in to is BANNED. "
139
+ warnings.warn(f"Warning: The account {self.username} you logged in to is BANNED. "
136
140
  f"Some features may not work properly.")
137
141
  if self.has_outstanding_email_confirmation:
138
- warnings.warn(f"Warning: The account {self._username} you logged is not email confirmed. "
142
+ warnings.warn(f"Warning: The account {self.username} you logged is not email confirmed. "
139
143
  f"Some features may not work properly.")
140
144
  return True
141
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
+
142
167
  def connect_linked_user(self) -> user.User:
143
168
  """
144
169
  Gets the user associated with the login / session.
@@ -157,7 +182,7 @@ class Session(BaseSiteComponent):
157
182
  self._user = self.connect_user(self._username)
158
183
  return self._user
159
184
 
160
- def get_linked_user(self) -> 'user.User':
185
+ def get_linked_user(self) -> user.User:
161
186
  # backwards compatibility with v1
162
187
 
163
188
  # To avoid inconsistencies with "connect" and "get", this function was renamed
@@ -182,16 +207,27 @@ class Session(BaseSiteComponent):
182
207
  password (str): Password associated with the session (not stored)
183
208
  """
184
209
  requests.post("https://scratch.mit.edu/accounts/email_change/",
185
- data={"email_address": self.new_email_address,
210
+ data={"email_address": self.get_new_email_address(),
186
211
  "password": password},
187
212
  headers=self._headers, cookies=self._cookies)
188
213
 
189
214
  @property
215
+ @deprecated("Use get_new_email_address instead.")
190
216
  def new_email_address(self) -> str:
191
217
  """
192
218
  Gets the (unconfirmed) email address that this session has requested to transfer to, if any,
193
219
  otherwise the current address.
194
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
+
195
231
  Returns:
196
232
  str: The email that this session wants to switch to
197
233
  """
@@ -202,6 +238,10 @@ class Session(BaseSiteComponent):
202
238
 
203
239
  email = None
204
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
205
245
  if label_span.contents[0] == "New Email Address":
206
246
  return label_span.parent.contents[-1].text.strip("\n ")
207
247
 
@@ -251,6 +291,13 @@ class Session(BaseSiteComponent):
251
291
 
252
292
  def classroom_alerts(self, _classroom: Optional[classroom.Classroom | int] = None, mode: str = "Last created",
253
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
+
254
301
  if isinstance(_classroom, classroom.Classroom):
255
302
  _classroom = _classroom.id
256
303
 
@@ -265,7 +312,9 @@ class Session(BaseSiteComponent):
265
312
  params={"page": page, "ascsort": ascsort, "descsort": descsort},
266
313
  headers=self._headers, cookies=self._cookies).json()
267
314
 
268
- return data
315
+ alerts = [alert.EducatorAlert.from_json(alert_data, self) for alert_data in data]
316
+
317
+ return alerts
269
318
 
270
319
  def clear_messages(self):
271
320
  """
@@ -469,20 +518,22 @@ class Session(BaseSiteComponent):
469
518
 
470
519
  def search_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
471
520
  offset: int = 0) -> list[studio.Studio]:
472
- if not query:
473
- raise ValueError("The query can't be empty for search")
521
+ #if not query:
522
+ # raise ValueError("The query can't be empty for search")
523
+ query = f"&q={query}" if query else ""
474
524
  response = commons.api_iterative(
475
525
  f"https://api.scratch.mit.edu/search/studios", limit=limit, offset=offset,
476
- add_params=f"&language={language}&mode={mode}&q={query}")
526
+ add_params=f"&language={language}&mode={mode}{query}")
477
527
  return commons.parse_object_list(response, studio.Studio, self)
478
528
 
479
529
  def explore_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
480
530
  offset: int = 0) -> list[studio.Studio]:
481
- if not query:
482
- raise ValueError("The query can't be empty for explore")
531
+ #if not query:
532
+ # raise ValueError("The query can't be empty for explore")
533
+ query = f"&q={query}" if query else ""
483
534
  response = commons.api_iterative(
484
535
  f"https://api.scratch.mit.edu/explore/studios", limit=limit, offset=offset,
485
- add_params=f"&language={language}&mode={mode}&q={query}")
536
+ add_params=f"&language={language}&mode={mode}{query}")
486
537
  return commons.parse_object_list(response, studio.Studio, self)
487
538
 
488
539
  # --- Create project API ---
@@ -617,9 +668,10 @@ class Session(BaseSiteComponent):
617
668
  ascsort = sort_by
618
669
  descsort = ""
619
670
  try:
671
+ params: dict[str, Union[str, int]] = {"page": page, "ascsort": ascsort, "descsort": descsort}
620
672
  targets = requests.get(
621
673
  f"https://scratch.mit.edu/site-api/galleries/{filter_arg}/",
622
- params={"page": page, "ascsort": ascsort, "descsort": descsort},
674
+ params=params,
623
675
  headers=headers,
624
676
  cookies=self._cookies,
625
677
  timeout=10
@@ -645,6 +697,9 @@ class Session(BaseSiteComponent):
645
697
  raise exceptions.FetchError()
646
698
 
647
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
+
648
703
  if not self.is_teacher:
649
704
  raise exceptions.Unauthorized(f"{self.username} is not a teacher; can't have classes")
650
705
  ascsort, descsort = get_class_sort_mode(mode)
@@ -848,6 +903,7 @@ class Session(BaseSiteComponent):
848
903
  Returns:
849
904
  scratchattach.user.User: An object that represents the requested user and allows you to perform actions on the user (like user.follow)
850
905
  """
906
+ # noinspection PyDeprecation
851
907
  return self._make_linked_object("username", self.find_username_from_id(user_id), user.User,
852
908
  exceptions.UserNotFound)
853
909
 
@@ -977,11 +1033,53 @@ sess
977
1033
  def connect_filterbot(self, *, log_deletions=True) -> filterbot.Filterbot:
978
1034
  return filterbot.Filterbot(user.User(username=self.username, _session=self), log_deletions=log_deletions)
979
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
+
980
1077
 
981
1078
  # ------ #
982
1079
 
983
1080
  suppressed_login_warning = local()
984
1081
 
1082
+
985
1083
  @contextmanager
986
1084
  def suppress_login_warning():
987
1085
  """
@@ -994,6 +1092,7 @@ def suppress_login_warning():
994
1092
  finally:
995
1093
  suppressed_login_warning.suppressed -= 1
996
1094
 
1095
+
997
1096
  def issue_login_warning() -> None:
998
1097
  """
999
1098
  Issue a login data warning.
@@ -1001,13 +1100,14 @@ def issue_login_warning() -> None:
1001
1100
  if getattr(suppressed_login_warning, "suppressed", 0):
1002
1101
  return
1003
1102
  warnings.warn(
1004
- "IMPORTANT: If you included login credentials directly in your code (e.g. session_id, session_string, ...), \
1005
- then make sure to EITHER instead load them from environment variables or files OR remember to remove them before \
1006
- you share your code with anyone else. If you want to remove this warning, \
1007
- use `warnings.filterwarnings('ignore', category=scratchattach.LoginDataWarning)`",
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)`",
1008
1107
  exceptions.LoginDataWarning
1009
1108
  )
1010
1109
 
1110
+
1011
1111
  def login_by_id(session_id: str, *, username: Optional[str] = None, password: Optional[str] = None, xtoken=None) -> Session:
1012
1112
  """
1013
1113
  Creates a session / log in to the Scratch website with the specified session id.
@@ -1024,39 +1124,20 @@ def login_by_id(session_id: str, *, username: Optional[str] = None, password: Op
1024
1124
  Returns:
1025
1125
  scratchattach.session.Session: An object that represents the created login / session
1026
1126
  """
1027
- # Removed this from docstring since it doesn't exist:
1028
- # timeout (int): Optional, but recommended.
1029
- # Specify this when the Python environment's IP address is blocked by Scratch's API,
1030
- # but you still want to use cloud variables.
1031
-
1032
1127
  # Generate session_string (a scratchattach-specific authentication method)
1128
+ # should this be changed to a @property?
1033
1129
  issue_login_warning()
1034
1130
  if password is not None:
1035
- session_data = dict(session_id=session_id, username=username, password=password)
1036
- session_string = base64.b64encode(json.dumps(session_data).encode())
1131
+ session_data = dict(id=session_id, username=username, password=password)
1132
+ session_string = base64.b64encode(json.dumps(session_data).encode()).decode()
1037
1133
  else:
1038
1134
  session_string = None
1039
- _session = Session(id=session_id, username=username, session_string=session_string, xtoken=xtoken)
1040
1135
 
1041
- try:
1042
- status = _session.update()
1043
- except Exception as e:
1044
- status = False
1045
- warnings.warn(f"Key error at key {e} when reading scratch.mit.edu/session API response")
1046
-
1047
- if status is not True:
1048
- if _session.xtoken is None:
1049
- if _session.username is None:
1050
- warnings.warn("Warning: Logged in by id, but couldn't fetch XToken. "
1051
- "Make sure the provided session id is valid. "
1052
- "Setting cloud variables can still work if you provide a "
1053
- "`username='username'` keyword argument to the sa.login_by_id function")
1054
- else:
1055
- warnings.warn("Warning: Logged in by id, but couldn't fetch XToken. "
1056
- "Make sure the provided session id is valid.")
1057
- else:
1058
- warnings.warn("Warning: Logged in by id, but couldn't fetch session info. "
1059
- "This won't affect any other features.")
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
+
1060
1141
  return _session
1061
1142
 
1062
1143
 
@@ -1083,14 +1164,16 @@ def login(username, password, *, timeout=10) -> Session:
1083
1164
  # Post request to login API:
1084
1165
  _headers = headers.copy()
1085
1166
  _headers["Cookie"] = "scratchcsrftoken=a;scratchlanguage=en;"
1086
- request = requests.post(
1087
- "https://scratch.mit.edu/login/", json={"username": username, "password": password}, headers=_headers,
1088
-
1089
- timeout=timeout, errorhandling = False
1090
- )
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
+ )
1091
1172
  try:
1092
- session_id = str(re.search('"(.*)"', request.headers["Set-Cookie"]).group())
1093
- except (AttributeError, Exception):
1173
+ result = re.search('"(.*)"', request.headers["Set-Cookie"])
1174
+ assert result is not None
1175
+ session_id = str(result.group())
1176
+ except Exception:
1094
1177
  raise exceptions.LoginFailure(
1095
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")
1096
1179
 
@@ -1098,6 +1181,7 @@ def login(username, password, *, timeout=10) -> Session:
1098
1181
  with suppress_login_warning():
1099
1182
  return login_by_id(session_id, username=username, password=password)
1100
1183
 
1184
+
1101
1185
  def login_by_session_string(session_string: str) -> Session:
1102
1186
  """
1103
1187
  Login using a session string.
@@ -1105,6 +1189,13 @@ def login_by_session_string(session_string: str) -> Session:
1105
1189
  issue_login_warning()
1106
1190
  session_string = base64.b64decode(session_string).decode() # unobfuscate
1107
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
1108
1199
  try:
1109
1200
  assert session_data.get("session_id")
1110
1201
  with suppress_login_warning():
@@ -1120,6 +1211,7 @@ def login_by_session_string(session_string: str) -> Session:
1120
1211
  pass
1121
1212
  raise ValueError("Couldn't log in.")
1122
1213
 
1214
+
1123
1215
  def login_by_io(file: SupportsRead[str]) -> Session:
1124
1216
  """
1125
1217
  Login using a file object.
@@ -1127,6 +1219,7 @@ def login_by_io(file: SupportsRead[str]) -> Session:
1127
1219
  with suppress_login_warning():
1128
1220
  return login_by_session_string(file.read())
1129
1221
 
1222
+
1130
1223
  def login_by_file(file: FileDescriptorOrPath) -> Session:
1131
1224
  """
1132
1225
  Login using a path to a file.
@@ -1134,6 +1227,7 @@ def login_by_file(file: FileDescriptorOrPath) -> Session:
1134
1227
  with suppress_login_warning(), open(file, encoding="utf-8") as f:
1135
1228
  return login_by_io(f)
1136
1229
 
1230
+
1137
1231
  def login_from_browser(browser: Browser = ANY):
1138
1232
  """
1139
1233
  Login from a browser
@@ -4,11 +4,11 @@ from __future__ import annotations
4
4
  import json
5
5
  import random
6
6
  from . import user, comment, project, activity
7
- from ..utils import exceptions, commons
8
- from ..utils.commons import api_iterative, headers
7
+ from scratchattach.utils import exceptions, commons
8
+ from scratchattach.utils.commons import api_iterative, headers
9
9
  from ._base import BaseSiteComponent
10
10
 
11
- from ..utils.requests import Requests as requests
11
+ from scratchattach.utils.requests import requests
12
12
 
13
13
 
14
14
  class Studio(BaseSiteComponent):
@@ -49,7 +49,7 @@ class Studio(BaseSiteComponent):
49
49
 
50
50
  # Info on how the .update method has to fetch the data:
51
51
  self.update_function = requests.get
52
- self.update_API = f"https://api.scratch.mit.edu/studios/{entries['id']}"
52
+ self.update_api = f"https://api.scratch.mit.edu/studios/{entries['id']}"
53
53
 
54
54
  # Set attributes every Project object needs to have:
55
55
  self._session = None