scratchattach 2.1.15b0__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 (87) 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. {scratchattach/cloud → cloud}/_base.py +112 -87
  12. {scratchattach/cloud → cloud}/cloud.py +16 -16
  13. {scratchattach/editor → editor}/__init__.py +2 -1
  14. {scratchattach/editor → editor}/asset.py +26 -14
  15. {scratchattach/editor → editor}/backpack_json.py +3 -5
  16. {scratchattach/editor → editor}/base.py +2 -4
  17. {scratchattach/editor → editor}/block.py +27 -22
  18. {scratchattach/editor → editor}/blockshape.py +1 -1
  19. {scratchattach/editor → editor}/build_defaulting.py +2 -2
  20. editor/commons.py +145 -0
  21. {scratchattach/editor → editor}/field.py +1 -1
  22. {scratchattach/editor → editor}/inputs.py +6 -3
  23. {scratchattach/editor → editor}/meta.py +10 -7
  24. {scratchattach/editor → editor}/monitor.py +10 -8
  25. {scratchattach/editor → editor}/mutation.py +68 -11
  26. {scratchattach/editor → editor}/pallete.py +1 -3
  27. {scratchattach/editor → editor}/prim.py +4 -0
  28. {scratchattach/editor → editor}/project.py +118 -16
  29. {scratchattach/editor → editor}/sprite.py +25 -15
  30. {scratchattach/editor → editor}/vlb.py +2 -2
  31. {scratchattach/eventhandlers → eventhandlers}/_base.py +1 -0
  32. {scratchattach/eventhandlers → eventhandlers}/cloud_events.py +26 -6
  33. {scratchattach/eventhandlers → eventhandlers}/cloud_recorder.py +4 -4
  34. {scratchattach/eventhandlers → eventhandlers}/cloud_requests.py +139 -54
  35. {scratchattach/eventhandlers → eventhandlers}/cloud_server.py +6 -3
  36. {scratchattach/eventhandlers → eventhandlers}/cloud_storage.py +1 -2
  37. eventhandlers/filterbot.py +163 -0
  38. other/other_apis.py +598 -0
  39. {scratchattach-2.1.15b0.dist-info → scratchattach-3.0.0b1.dist-info}/METADATA +7 -11
  40. scratchattach-3.0.0b1.dist-info/RECORD +79 -0
  41. {scratchattach-2.1.15b0.dist-info → scratchattach-3.0.0b1.dist-info}/WHEEL +1 -1
  42. scratchattach-3.0.0b1.dist-info/entry_points.txt +2 -0
  43. scratchattach-3.0.0b1.dist-info/top_level.txt +7 -0
  44. {scratchattach/site → site}/_base.py +32 -5
  45. site/activity.py +426 -0
  46. {scratchattach/site → site}/alert.py +4 -5
  47. {scratchattach/site → site}/backpack_asset.py +2 -1
  48. {scratchattach/site → site}/classroom.py +80 -73
  49. {scratchattach/site → site}/cloud_activity.py +43 -29
  50. {scratchattach/site → site}/comment.py +86 -100
  51. {scratchattach/site → site}/forum.py +8 -4
  52. site/placeholder.py +132 -0
  53. {scratchattach/site → site}/project.py +228 -122
  54. {scratchattach/site → site}/session.py +156 -71
  55. {scratchattach/site → site}/studio.py +139 -46
  56. site/typed_dicts.py +151 -0
  57. {scratchattach/site → site}/user.py +511 -215
  58. {scratchattach/utils → utils}/commons.py +12 -4
  59. {scratchattach/utils → utils}/encoder.py +7 -4
  60. {scratchattach/utils → utils}/enums.py +1 -0
  61. {scratchattach/utils → utils}/exceptions.py +36 -2
  62. utils/optional_async.py +154 -0
  63. utils/requests.py +306 -0
  64. scratchattach/__init__.py +0 -29
  65. scratchattach/editor/commons.py +0 -273
  66. scratchattach/eventhandlers/filterbot.py +0 -161
  67. scratchattach/other/other_apis.py +0 -284
  68. scratchattach/site/activity.py +0 -382
  69. scratchattach/utils/requests.py +0 -93
  70. scratchattach-2.1.15b0.dist-info/RECORD +0 -66
  71. scratchattach-2.1.15b0.dist-info/top_level.txt +0 -1
  72. {scratchattach/cloud → cloud}/__init__.py +0 -0
  73. {scratchattach/editor → editor}/code_translation/__init__.py +0 -0
  74. {scratchattach/editor → editor}/code_translation/parse.py +0 -0
  75. {scratchattach/editor → editor}/comment.py +0 -0
  76. {scratchattach/editor → editor}/extension.py +0 -0
  77. {scratchattach/editor → editor}/twconfig.py +0 -0
  78. {scratchattach/eventhandlers → eventhandlers}/__init__.py +0 -0
  79. {scratchattach/eventhandlers → eventhandlers}/combine.py +0 -0
  80. {scratchattach/eventhandlers → eventhandlers}/message_events.py +0 -0
  81. {scratchattach/other → other}/__init__.py +0 -0
  82. {scratchattach/other → other}/project_json_capabilities.py +0 -0
  83. {scratchattach-2.1.15b0.dist-info → scratchattach-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
  84. {scratchattach/site → site}/__init__.py +0 -0
  85. {scratchattach/site → site}/browser_cookie3_stub.py +0 -0
  86. {scratchattach/site → site}/browser_cookies.py +0 -0
  87. {scratchattach/utils → utils}/__init__.py +0 -0
@@ -13,10 +13,12 @@ import warnings
13
13
  import zlib
14
14
 
15
15
  from dataclasses import dataclass, field
16
- from typing import Optional, TypeVar, TYPE_CHECKING, overload, Any, Union
16
+ from typing import Optional, TypeVar, TYPE_CHECKING, overload, Any, Union, cast
17
17
  from contextlib import contextmanager
18
18
  from threading import local
19
19
 
20
+ from scratchattach import editor
21
+
20
22
  Type = type
21
23
 
22
24
  if TYPE_CHECKING:
@@ -30,11 +32,12 @@ from bs4 import BeautifulSoup, Tag
30
32
  from typing_extensions import deprecated
31
33
 
32
34
  from . import activity, classroom, forum, studio, user, project, backpack_asset, alert
35
+ from . import typed_dicts
33
36
  # noinspection PyProtectedMember
34
37
  from ._base import BaseSiteComponent
35
38
  from scratchattach.cloud import cloud, _base
36
39
  from scratchattach.eventhandlers import message_events, filterbot
37
- from scratchattach.other import project_json_capabilities
40
+ from scratchattach.other import other_apis
38
41
  from scratchattach.utils import commons, exceptions
39
42
  from scratchattach.utils.commons import headers, empty_project_json, webscrape_count, get_class_sort_mode
40
43
  from scratchattach.utils.requests import requests
@@ -57,8 +60,8 @@ def enforce_ratelimit(__type: str, name: str, amount: int = 5, duration: int = 6
57
60
  "For security reasons, it cannot be turned off.\n\n"
58
61
  "Don't spam-create studios or similar, it WILL get you banned."
59
62
  )
60
-
61
- C = TypeVar("C", bound=BaseSiteComponent)
63
+
64
+ C = TypeVar("C", bound=BaseSiteComponent)
62
65
 
63
66
  @dataclass
64
67
  class Session(BaseSiteComponent):
@@ -74,33 +77,65 @@ class Session(BaseSiteComponent):
74
77
  mute_status: Information about commenting restrictions of the associated account
75
78
  banned: Returns True if the associated account is banned
76
79
  """
77
- username: str = None
78
- _user: user.User = field(repr=False, default=None)
80
+ username: str = field(repr=False, default="")
81
+ _user: Optional[user.User] = field(repr=False, default=None)
79
82
 
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)
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)
84
87
 
85
- new_scratcher: bool = field(repr=False, default=None)
88
+ new_scratcher: bool = field(repr=False, default=False)
86
89
  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)
90
+ banned: bool = field(repr=False, default=False)
89
91
 
90
- time_created: datetime.datetime = None
92
+ time_created: datetime.datetime = field(repr=False, default=datetime.datetime.fromtimestamp(0.0))
91
93
  language: str = field(repr=False, default="en")
92
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
+
93
101
  def __str__(self) -> str:
94
- return f"<Login for {self.username!r}>"
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
95
133
 
96
134
  def __post_init__(self):
97
135
  # Info on how the .update method has to fetch the data:
98
136
  self.update_function = requests.post
99
137
  self.update_api = "https://scratch.mit.edu/session"
100
138
 
101
- # Set alternative attributes:
102
- self._username = self.username # backwards compatibility with v1
103
-
104
139
  # Base headers and cookies of every session:
105
140
  self._headers = dict(headers)
106
141
  self._cookies = {
@@ -114,11 +149,15 @@ class Session(BaseSiteComponent):
114
149
  if self.id:
115
150
  self._process_session_id()
116
151
 
117
- def _update_from_dict(self, data: dict):
152
+ self._session = self
153
+
154
+ def _update_from_dict(self, data: Union[dict, typed_dicts.SessionDict]):
118
155
  # Note: there are a lot more things you can get from this data dict.
119
156
  # Maybe it would be a good idea to also store the dict itself?
120
157
  # self.data = data
121
158
 
159
+ data = cast(typed_dicts.SessionDict, data)
160
+
122
161
  self.xtoken = data['user']['token']
123
162
  self._headers["X-Token"] = self.xtoken
124
163
 
@@ -128,11 +167,11 @@ class Session(BaseSiteComponent):
128
167
 
129
168
  self.new_scratcher = data["permissions"]["new_scratcher"]
130
169
  self.is_teacher = data["permissions"]["educator"]
170
+ self.is_teacher_invitee = data["permissions"]["educator_invitee"]
131
171
 
132
- self.mute_status = data["permissions"]["mute_status"]
172
+ self.mute_status: dict = data["permissions"]["mute_status"]
133
173
 
134
174
  self.username = data["user"]["username"]
135
- self._username = data["user"]["username"]
136
175
  self.banned = data["user"]["banned"]
137
176
 
138
177
  if self.banned:
@@ -149,21 +188,27 @@ class Session(BaseSiteComponent):
149
188
  data, self.time_created = decode_session_id(self.id)
150
189
 
151
190
  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)
191
+ # if self._user:
192
+ # self._user.username = self.username
193
+ # else:
194
+ # self._user = user.User(_session=self, username=self.username)
157
195
 
158
- self._user.id = data["_auth_user_id"]
196
+ # self._user.id = data["_auth_user_id"]
159
197
  self.xtoken = data["token"]
160
198
  self._headers["X-Token"] = self.xtoken
161
199
 
162
200
  # not saving the login ip because it is a security issue, and is not very helpful
163
201
 
164
- self.language = data["_language"]
202
+ self.language = data.get("_language", "en")
165
203
  # self._cookies["scratchlanguage"] = self.language
166
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
+
167
212
  def connect_linked_user(self) -> user.User:
168
213
  """
169
214
  Gets the user associated with the login / session.
@@ -180,6 +225,7 @@ class Session(BaseSiteComponent):
180
225
 
181
226
  if not cached:
182
227
  self._user = self.connect_user(self._username)
228
+ assert self._user is not None
183
229
  return self._user
184
230
 
185
231
  def get_linked_user(self) -> user.User:
@@ -257,6 +303,35 @@ class Session(BaseSiteComponent):
257
303
  requests.post("https://scratch.mit.edu/accounts/logout/",
258
304
  headers=self._headers, cookies=self._cookies)
259
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
+
260
335
  def messages(self, *, limit: int = 40, offset: int = 0, date_limit=None, filter_by=None) -> list[activity.Activity]:
261
336
  """
262
337
  Returns the messages.
@@ -377,60 +452,61 @@ class Session(BaseSiteComponent):
377
452
  )
378
453
  return commons.parse_object_list(data, project.Project, self)
379
454
 
380
- """
381
- These methods are disabled because it is unclear if there is any case in which the response is not empty.
382
455
  def shared_by_followed_users(self, *, limit=40, offset=0) -> list[project.Project]:
383
- '''
456
+ """
384
457
  Returns the "Projects by Scratchers I'm following" section (frontpage).
385
- This section is only visible to old accounts (according to the Scratch wiki).
458
+ This section is only visible to old accounts (until ~2018).
386
459
  For newer users, this method will always return an empty list.
387
460
 
388
461
  Returns:
389
- list<scratchattach.project.Project>: List that contains all "Projects loved by Scratchers I'm following"
462
+ list<scratchattach.project.Project>: List that contains all "Projects by Scratchers I'm following"
390
463
  entries as Project objects
391
- '''
464
+ """
392
465
  data = commons.api_iterative(
393
466
  f"https://api.scratch.mit.edu/users/{self._username}/following/users/projects",
394
- limit = limit, offset = offset, headers = self._headers, cookies = self._cookies
467
+ limit = limit, offset = offset, _headers = self._headers, cookies = self._cookies
395
468
  )
396
- return commons.parse_object_list(data, project.Project, self)
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
397
474
 
398
475
  def in_followed_studios(self, *, limit=40, offset=0) -> list['project.Project']:
399
- '''
476
+ """
400
477
  Returns the "Projects in studios I'm following" section (frontpage).
401
- This section is only visible to old accounts (according to the Scratch wiki).
478
+ This section is only visible to old accounts (until ~2018)
402
479
  For newer users, this method will always return an empty list.
403
480
 
404
481
  Returns:
405
- list<scratchattach.project.Project>: List that contains all "Projects loved by Scratchers I'm following"
482
+ list<scratchattach.project.Project>: List that contains all "Projects in studios I'm following" section"
406
483
  entries as Project objects
407
- '''
484
+ """
408
485
  data = commons.api_iterative(
409
486
  f"https://api.scratch.mit.edu/users/{self._username}/following/studios/projects",
410
- limit = limit, offset = offset, headers = self._headers, cookies = self._cookies
487
+ limit = limit, offset = offset, _headers=self._headers, cookies = self._cookies
411
488
  )
412
- return commons.parse_object_list(data, project.Project, self)"""
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
413
494
 
414
495
  # -- Project JSON editing capabilities ---
415
496
  # 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)
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
420
500
  return pb
421
501
 
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)
502
+ def connect_pb_from_dict(self, project_json: dict) -> editor.Project:
503
+ pb = editor.Project.from_json(project_json)
504
+ pb._session = self
426
505
  return pb
427
506
 
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))
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
434
510
  return pb
435
511
 
436
512
  @staticmethod
@@ -488,9 +564,11 @@ class Session(BaseSiteComponent):
488
564
  Returns:
489
565
  list<scratchattach.project.Project>: List that contains the search results.
490
566
  """
567
+ query = f"&q={query}" if query else ""
568
+
491
569
  response = commons.api_iterative(
492
570
  f"https://api.scratch.mit.edu/search/projects", limit=limit, offset=offset,
493
- add_params=f"&language={language}&mode={mode}&q={query}")
571
+ add_params=f"&language={language}&mode={mode}{query}")
494
572
  return commons.parse_object_list(response, project.Project, self)
495
573
 
496
574
  def explore_projects(self, *, query: str = "*", mode: str = "trending", language: str = "en", limit: int = 40,
@@ -518,9 +596,8 @@ class Session(BaseSiteComponent):
518
596
 
519
597
  def search_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
520
598
  offset: int = 0) -> list[studio.Studio]:
521
- #if not query:
522
- # raise ValueError("The query can't be empty for search")
523
599
  query = f"&q={query}" if query else ""
600
+
524
601
  response = commons.api_iterative(
525
602
  f"https://api.scratch.mit.edu/search/studios", limit=limit, offset=offset,
526
603
  add_params=f"&language={language}&mode={mode}{query}")
@@ -528,8 +605,6 @@ class Session(BaseSiteComponent):
528
605
 
529
606
  def explore_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
530
607
  offset: int = 0) -> list[studio.Studio]:
531
- #if not query:
532
- # raise ValueError("The query can't be empty for explore")
533
608
  query = f"&q={query}" if query else ""
534
609
  response = commons.api_iterative(
535
610
  f"https://api.scratch.mit.edu/explore/studios", limit=limit, offset=offset,
@@ -571,7 +646,7 @@ class Session(BaseSiteComponent):
571
646
  To prevent accidental spam, a rate limit (5 studios per minute) is implemented for this function.
572
647
  """
573
648
  enforce_ratelimit("create_scratch_studio", "creating Scratch studios")
574
-
649
+
575
650
  if self.new_scratcher:
576
651
  raise exceptions.Unauthorized(f"\nNew scratchers (like {self.username}) cannot create studios.")
577
652
 
@@ -697,7 +772,7 @@ class Session(BaseSiteComponent):
697
772
  raise exceptions.FetchError()
698
773
 
699
774
  def mystuff_classes(self, mode: str = "Last created", page: Optional[int] = None) -> list[classroom.Classroom]:
700
- if self.is_teacher is None:
775
+ if not self.is_teacher:
701
776
  self.update()
702
777
 
703
778
  if not self.is_teacher:
@@ -775,7 +850,7 @@ class Session(BaseSiteComponent):
775
850
 
776
851
  # --- Connect classes inheriting from BaseCloud ---
777
852
 
778
-
853
+
779
854
  @overload
780
855
  def connect_cloud(self, project_id, *, cloud_class: type[T]) -> T:
781
856
  """
@@ -790,7 +865,7 @@ class Session(BaseSiteComponent):
790
865
  Returns: Type[scratchattach.cloud._base.BaseCloud]: An object representing the cloud of a project. Can be of any
791
866
  class inheriting from BaseCloud.
792
867
  """
793
-
868
+
794
869
  @overload
795
870
  def connect_cloud(self, project_id) -> cloud.ScratchCloud:
796
871
  """
@@ -1023,6 +1098,12 @@ sess
1023
1098
  except Exception as e:
1024
1099
  raise exceptions.ScrapeError(str(e))
1025
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
+
1026
1107
  # --- Connect classes inheriting from BaseEventHandler ---
1027
1108
 
1028
1109
  def connect_message_events(self, *, update_interval=2) -> message_events.MessageEvents:
@@ -1036,10 +1117,10 @@ sess
1036
1117
  def get_session_string(self) -> str:
1037
1118
  assert self.session_string
1038
1119
  return self.session_string
1039
-
1120
+
1040
1121
  def get_headers(self) -> dict[str, str]:
1041
1122
  return self._headers
1042
-
1123
+
1043
1124
  def get_cookies(self) -> dict[str, str]:
1044
1125
  return self._cookies
1045
1126
 
@@ -1052,7 +1133,7 @@ def decode_session_id(session_id: str) -> tuple[dict[str, str], datetime.datetim
1052
1133
  Session id is in the format:
1053
1134
  <p1: long base64 string>:<p2: short base64 string>:<p3: medium base64 string>
1054
1135
 
1055
- p1 contains a base64-zlib compressed JSON string
1136
+ p1 contains a base64 JSON string (if it starts with `.`, then it is zlib compressed)
1056
1137
  p2 is a base 62 encoded timestamp
1057
1138
  p3 might be a `synchronous signature` for the first 2 parts (might be useless for us)
1058
1139
 
@@ -1067,10 +1148,13 @@ def decode_session_id(session_id: str) -> tuple[dict[str, str], datetime.datetim
1067
1148
  - django_timezone
1068
1149
  - _auth_user_hash
1069
1150
  """
1070
- p1, p2, p3 = session_id.split(':')
1151
+ p1, p2, _ = session_id.split(':')
1152
+ p1_bytes = base64.urlsafe_b64decode(p1 + "==")
1153
+ if p1.startswith('".'):
1154
+ p1_bytes = zlib.decompress(p1_bytes)
1071
1155
 
1072
1156
  return (
1073
- json.loads(zlib.decompress(base64.urlsafe_b64decode(p1 + "=="))),
1157
+ json.loads(p1_bytes),
1074
1158
  datetime.datetime.fromtimestamp(commons.b62_decode(p2))
1075
1159
  )
1076
1160
 
@@ -1133,7 +1217,7 @@ def login_by_id(session_id: str, *, username: Optional[str] = None, password: Op
1133
1217
  else:
1134
1218
  session_string = None
1135
1219
 
1136
- _session = Session(id=session_id, username=username, session_string=session_string)
1220
+ _session = Session(id=session_id, username=username or "", session_string=session_string)
1137
1221
  if xtoken is not None:
1138
1222
  # xtoken is retrievable from session id, so the most we can do is assert equality
1139
1223
  assert xtoken == _session.xtoken
@@ -1169,6 +1253,7 @@ def login(username, password, *, timeout=10) -> Session:
1169
1253
  "https://scratch.mit.edu/login/", json={"username": username, "password": password}, headers=_headers,
1170
1254
  timeout=timeout
1171
1255
  )
1256
+
1172
1257
  try:
1173
1258
  result = re.search('"(.*)"', request.headers["Set-Cookie"])
1174
1259
  assert result is not None