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
@@ -3,21 +3,37 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import random
6
+ import re
6
7
  import string
8
+ from datetime import datetime, timezone
9
+ from enum import Enum
10
+ import warnings
11
+
12
+ from typing_extensions import deprecated
13
+ from bs4 import BeautifulSoup, Tag
14
+
15
+ from ._base import BaseSiteComponent
16
+ from scratchattach.eventhandlers import message_events
17
+
18
+ from scratchattach.utils import commons
19
+ from scratchattach.utils import exceptions
20
+ from scratchattach.utils.commons import headers
21
+ from scratchattach.utils.requests import requests
7
22
 
8
- from ..eventhandlers import message_events
9
23
  from . import project
10
- from ..utils import exceptions
11
24
  from . import studio
12
25
  from . import forum
13
- from bs4 import BeautifulSoup
14
- from ._base import BaseSiteComponent
15
- from ..utils.commons import headers
16
- from ..utils import commons
17
26
  from . import comment
18
27
  from . import activity
28
+ from . import classroom
19
29
 
20
- from ..utils.requests import Requests as requests
30
+ class Rank(Enum):
31
+ """
32
+ Possible ranks in scratch
33
+ """
34
+ NEW_SCRATCHER = 0
35
+ SCRATCHER = 1
36
+ SCRATCH_TEAM = 2
21
37
 
22
38
  class Verificator:
23
39
 
@@ -61,7 +77,7 @@ class User(BaseSiteComponent):
61
77
 
62
78
  # Info on how the .update method has to fetch the data:
63
79
  self.update_function = requests.get
64
- self.update_API = f"https://api.scratch.mit.edu/users/{entries['username']}"
80
+ self.update_api = f"https://api.scratch.mit.edu/users/{entries['username']}"
65
81
 
66
82
  # Set attributes every User object needs to have:
67
83
  self._session = None
@@ -69,6 +85,10 @@ class User(BaseSiteComponent):
69
85
  self.username = None
70
86
  self.name = None
71
87
 
88
+ # cache value for classroom getter method (using @property)
89
+ # first value is whether the cache has actually been set (because it can be None), second is the value itself
90
+ self._classroom: tuple[bool, classroom.Classroom | None] = False, None
91
+
72
92
  # Update attributes from entries dict:
73
93
  entries.setdefault("name", entries.get("username"))
74
94
  self.__dict__.update(entries)
@@ -119,27 +139,73 @@ class User(BaseSiteComponent):
119
139
  raise exceptions.Unauthorized(
120
140
  "You need to be authenticated as the profile owner to do this.")
121
141
 
142
+ @property
143
+ def classroom(self) -> classroom.Classroom | None:
144
+ """
145
+ Get a user's associated classroom, and return it as a `scratchattach.classroom.Classroom` object.
146
+ If there is no associated classroom, returns `None`
147
+ """
148
+ if not self._classroom[0]:
149
+ with requests.no_error_handling():
150
+ resp = requests.get(f"https://scratch.mit.edu/users/{self.username}/")
151
+ soup = BeautifulSoup(resp.text, "html.parser")
152
+
153
+ details = soup.find("p", {"class": "profile-details"})
154
+ assert isinstance(details, Tag)
155
+
156
+ class_name, class_id, is_closed = None, 0, False
157
+ for a in details.find_all("a"):
158
+ if not isinstance(a, Tag):
159
+ continue
160
+ href = str(a.get("href"))
161
+ if re.match(r"/classes/\d*/", href):
162
+ class_name = a.text.strip()[len("Student of: "):]
163
+ is_closed = class_name.endswith("\n (ended)") # as this has a \n, we can be sure
164
+ if is_closed:
165
+ class_name = class_name[:-7].strip()
166
+
167
+ class_id = int(href.split('/')[2])
168
+ break
169
+
170
+ if class_name:
171
+ self._classroom = True, classroom.Classroom(
172
+ _session=self._session,
173
+ id=class_id,
174
+ title=class_name,
175
+ is_closed=is_closed
176
+ )
177
+ else:
178
+ self._classroom = True, None
179
+
180
+ return self._classroom[1]
181
+
122
182
  def does_exist(self):
123
183
  """
124
184
  Returns:
125
185
  boolean : True if the user exists, False if the user is deleted, None if an error occured
126
186
  """
127
- status_code = requests.get(f"https://scratch.mit.edu/users/{self.username}/").status_code
187
+ with requests.no_error_handling():
188
+ status_code = requests.get(f"https://scratch.mit.edu/users/{self.username}/").status_code
128
189
  if status_code == 200:
129
190
  return True
130
191
  elif status_code == 404:
131
192
  return False
132
193
 
194
+ # Will maybe be deprecated later, but for now still has its own purpose.
195
+ #@deprecated("This function is partially deprecated. Use user.rank() instead.")
133
196
  def is_new_scratcher(self):
134
197
  """
135
198
  Returns:
136
199
  boolean : True if the user has the New Scratcher status, else False
137
200
  """
138
201
  try:
139
- res = requests.get(f"https://scratch.mit.edu/users/{self.username}/").text
140
- group=res[res.rindex('<span class="group">'):][:70]
141
- return "new scratcher" in group.lower()
142
- except Exception:
202
+ with requests.no_error_handling():
203
+ res = requests.get(f"https://scratch.mit.edu/users/{self.username}/").text
204
+ group = res[res.rindex('<span class="group">'):][:70]
205
+ return "new scratcher" in group.lower()
206
+
207
+ except Exception as e:
208
+ warnings.warn(f"Caught exception {e=}")
143
209
  return None
144
210
 
145
211
  def message_count(self):
@@ -168,19 +234,21 @@ class User(BaseSiteComponent):
168
234
 
169
235
  def follower_count(self):
170
236
  # follower count
171
- text = requests.get(
172
- f"https://scratch.mit.edu/users/{self.username}/followers/",
173
- headers = self._headers
174
- ).text
175
- return commons.webscrape_count(text, "Followers (", ")")
237
+ with requests.no_error_handling():
238
+ text = requests.get(
239
+ f"https://scratch.mit.edu/users/{self.username}/followers/",
240
+ headers = self._headers
241
+ ).text
242
+ return commons.webscrape_count(text, "Followers (", ")")
176
243
 
177
244
  def following_count(self):
178
245
  # following count
179
- text = requests.get(
180
- f"https://scratch.mit.edu/users/{self.username}/following/",
181
- headers = self._headers
182
- ).text
183
- return commons.webscrape_count(text, "Following (", ")")
246
+ with requests.no_error_handling():
247
+ text = requests.get(
248
+ f"https://scratch.mit.edu/users/{self.username}/following/",
249
+ headers = self._headers
250
+ ).text
251
+ return commons.webscrape_count(text, "Following (", ")")
184
252
 
185
253
  def followers(self, *, limit=40, offset=0):
186
254
  """
@@ -214,7 +282,7 @@ class User(BaseSiteComponent):
214
282
  """
215
283
  return [i.name for i in self.following(limit=limit, offset=offset)]
216
284
 
217
- def is_following(self, user):
285
+ def is_following(self, user: str):
218
286
  """
219
287
  Returns:
220
288
  boolean: Whether the user is following the user provided as argument
@@ -228,11 +296,11 @@ class User(BaseSiteComponent):
228
296
  if user in following_names:
229
297
  following = True
230
298
  break
231
- if following_names == []:
299
+ if not following_names:
232
300
  break
233
301
  offset += 20
234
- except Exception:
235
- print("Warning: API error when performing following check")
302
+ except Exception as e:
303
+ print(f"Warning: API error when performing following check: {e=}")
236
304
  return following
237
305
  return following
238
306
 
@@ -241,28 +309,46 @@ class User(BaseSiteComponent):
241
309
  Returns:
242
310
  boolean: Whether the user is followed by the user provided as argument
243
311
  """
244
- return User(username=user).is_following(self.username)
312
+ offset = 0
313
+ followed = False
314
+
315
+ while True:
316
+ try:
317
+ followed_names = self.follower_names(limit=20, offset=offset)
318
+ if user in followed_names:
319
+ followed = True
320
+ break
321
+ if not followed_names:
322
+ break
323
+ offset += 20
324
+ except Exception as e:
325
+ print(f"Warning: API error when performing following check: {e=}")
326
+ return followed
327
+ return followed
245
328
 
246
329
  def project_count(self):
247
- text = requests.get(
248
- f"https://scratch.mit.edu/users/{self.username}/projects/",
249
- headers = self._headers
250
- ).text
251
- return commons.webscrape_count(text, "Shared Projects (", ")")
330
+ with requests.no_error_handling():
331
+ text = requests.get(
332
+ f"https://scratch.mit.edu/users/{self.username}/projects/",
333
+ headers = self._headers
334
+ ).text
335
+ return commons.webscrape_count(text, "Shared Projects (", ")")
252
336
 
253
337
  def studio_count(self):
254
- text = requests.get(
255
- f"https://scratch.mit.edu/users/{self.username}/studios/",
256
- headers = self._headers
257
- ).text
258
- return commons.webscrape_count(text, "Studios I Curate (", ")")
338
+ with requests.no_error_handling():
339
+ text = requests.get(
340
+ f"https://scratch.mit.edu/users/{self.username}/studios/",
341
+ headers = self._headers
342
+ ).text
343
+ return commons.webscrape_count(text, "Studios I Curate (", ")")
259
344
 
260
345
  def studios_following_count(self):
261
- text = requests.get(
262
- f"https://scratch.mit.edu/users/{self.username}/studios/",
263
- headers = self._headers
264
- ).text
265
- return commons.webscrape_count(text, "Studios I Follow (", ")")
346
+ with requests.no_error_handling():
347
+ text = requests.get(
348
+ f"https://scratch.mit.edu/users/{self.username}/studios_following/",
349
+ headers = self._headers
350
+ ).text
351
+ return commons.webscrape_count(text, "Studios I Follow (", ")")
266
352
 
267
353
  def studios(self, *, limit=40, offset=0):
268
354
  _studios = commons.api_iterative(
@@ -313,8 +399,9 @@ class User(BaseSiteComponent):
313
399
  # The index of the first project on page #n is just (n-1) * 40
314
400
  first_idx = (page - 1) * 40
315
401
 
316
- page_content = requests.get(f"https://scratch.mit.edu/projects/all/{self.username}/loves/"
317
- f"?page={page}", headers=self._headers).content
402
+ with requests.no_error_handling():
403
+ page_content = requests.get(f"https://scratch.mit.edu/projects/all/{self.username}/loves/"
404
+ f"?page={page}", headers=self._headers).content
318
405
 
319
406
  soup = BeautifulSoup(
320
407
  page_content,
@@ -349,7 +436,7 @@ class User(BaseSiteComponent):
349
436
  # A thumbnail link (no need to webscrape this)
350
437
  # A title
351
438
  # An Author (called an owner for some reason)
352
-
439
+ assert isinstance(project_element, Tag)
353
440
  project_anchors = project_element.find_all("a")
354
441
  # Each list item has three <a> tags, the first two linking the project
355
442
  # 1st contains <img> tag
@@ -358,10 +445,16 @@ class User(BaseSiteComponent):
358
445
 
359
446
  # This function is pretty handy!
360
447
  # I'll use it for an id from a string like: /projects/1070616180/
361
- project_id = commons.webscrape_count(project_anchors[0].attrs["href"],
448
+ first_anchor = project_anchors[0]
449
+ second_anchor = project_anchors[1]
450
+ third_anchor = project_anchors[2]
451
+ assert isinstance(first_anchor, Tag)
452
+ assert isinstance(second_anchor, Tag)
453
+ assert isinstance(third_anchor, Tag)
454
+ project_id = commons.webscrape_count(first_anchor.attrs["href"],
362
455
  "/projects/", "/")
363
- title = project_anchors[1].contents[0]
364
- author = project_anchors[2].contents[0]
456
+ title = second_anchor.contents[0]
457
+ author = third_anchor.contents[0]
365
458
 
366
459
  # Instantiating a project with the properties that we know
367
460
  # This may cause issues (see below)
@@ -382,10 +475,11 @@ class User(BaseSiteComponent):
382
475
  return _projects
383
476
 
384
477
  def loves_count(self):
385
- text = requests.get(
386
- f"https://scratch.mit.edu/projects/all/{self.username}/loves/",
387
- headers=self._headers
388
- ).text
478
+ with requests.no_error_handling():
479
+ text = requests.get(
480
+ f"https://scratch.mit.edu/projects/all/{self.username}/loves/",
481
+ headers=self._headers
482
+ ).text
389
483
 
390
484
  # If there are no loved projects, then Scratch doesn't actually display the number - so we have to catch this
391
485
  soup = BeautifulSoup(text, "html.parser")
@@ -406,10 +500,11 @@ class User(BaseSiteComponent):
406
500
  return commons.parse_object_list(_projects, project.Project, self._session)
407
501
 
408
502
  def favorites_count(self):
409
- text = requests.get(
410
- f"https://scratch.mit.edu/users/{self.username}/favorites/",
411
- headers = self._headers
412
- ).text
503
+ with requests.no_error_handling():
504
+ text = requests.get(
505
+ f"https://scratch.mit.edu/users/{self.username}/favorites/",
506
+ headers=self._headers
507
+ ).text
413
508
  return commons.webscrape_count(text, "Favorites (", ")")
414
509
 
415
510
  def toggle_commenting(self):
@@ -544,17 +639,23 @@ class User(BaseSiteComponent):
544
639
  data = {
545
640
  'id': text.split('<div id="comments-')[1].split('" class="comment')[0],
546
641
  'author': {"username": text.split('" data-comment-user="')[1].split('"><img class')[0]},
547
- 'content': text.split('<div class="content">')[1].split('"</div>')[0],
642
+ 'content': text.split('<div class="content">')[1].split('</div>')[0].strip(),
548
643
  'reply_count': 0,
549
644
  'cached_replies': []
550
645
  }
551
- _comment = comment.Comment(source="profile", parent_id=None if parent_id=="" else parent_id, commentee_id=commentee_id, source_id=self.username, id=data["id"], _session = self._session)
646
+ _comment = comment.Comment(source="profile", parent_id=None if parent_id=="" else parent_id, commentee_id=commentee_id, source_id=self.username, id=data["id"], _session = self._session, datetime = datetime.now())
552
647
  _comment._update_from_dict(data)
553
648
  return _comment
554
649
  except Exception:
555
650
  if '{"error": "isFlood"}' in text:
556
651
  raise(exceptions.CommentPostFailure(
557
652
  "You are being rate-limited for running this operation too often. Implement a cooldown of about 10 seconds."))
653
+ elif '<script id="error-data" type="application/json">' in text:
654
+ raw_error_data = text.split('<script id="error-data" type="application/json">')[1].split('</script>')[0]
655
+ error_data = json.loads(raw_error_data)
656
+ expires = error_data['mute_status']['muteExpiresAt']
657
+ expires = datetime.fromtimestamp(expires, timezone.utc)
658
+ raise(exceptions.CommentPostFailure(f"You have been muted. Mute expires on {expires}"))
558
659
  else:
559
660
  raise(exceptions.FetchError(f"Couldn't parse API response: {r.text!r}"))
560
661
 
@@ -581,7 +682,8 @@ class User(BaseSiteComponent):
581
682
  Returns:
582
683
  list<scratchattach.Activity>: The user's activity data as parsed list of scratchattach.activity.Activity objects
583
684
  """
584
- soup = BeautifulSoup(requests.get(f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}").text, 'html.parser')
685
+ with requests.no_error_handling():
686
+ soup = BeautifulSoup(requests.get(f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}").text, 'html.parser')
585
687
 
586
688
  activities = []
587
689
  source = soup.find_all("li")
@@ -599,7 +701,8 @@ class User(BaseSiteComponent):
599
701
  Returns:
600
702
  str: The raw user activity HTML data
601
703
  """
602
- return requests.get(f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}").text
704
+ with requests.no_error_handling():
705
+ return requests.get(f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}").text
603
706
 
604
707
 
605
708
  def follow(self):
@@ -668,7 +771,8 @@ class User(BaseSiteComponent):
668
771
  URL = f"https://scratch.mit.edu/site-api/comments/user/{self.username}/?page={page}"
669
772
  DATA = []
670
773
 
671
- page_contents = requests.get(URL).content
774
+ with requests.no_error_handling():
775
+ page_contents = requests.get(URL).content
672
776
 
673
777
  soup = BeautifulSoup(page_contents, "html.parser")
674
778
 
@@ -813,6 +917,24 @@ class User(BaseSiteComponent):
813
917
  v = Verificator(self, verification_project_id)
814
918
  return v
815
919
 
920
+ def rank(self):
921
+ """
922
+ Finds the rank of the user.
923
+ May replace user.scratchteam and user.is_new_scratcher in the future.
924
+ """
925
+
926
+ if self.is_new_scratcher():
927
+ return Rank.NEW_SCRATCHER
928
+ # Is New Scratcher
929
+
930
+ if not self.scratchteam:
931
+ return Rank.SCRATCHER
932
+ # Is Scratcher
933
+
934
+ return Rank.SCRATCH_TEAM
935
+ # Is Scratch Team member
936
+
937
+
816
938
  # ------ #
817
939
 
818
940
  def get_user(username) -> User:
@@ -1,13 +1,17 @@
1
1
  """v2 ready: Common functions used by various internal modules"""
2
2
  from __future__ import annotations
3
3
 
4
+ import string
5
+
4
6
  from typing import Optional, Final, Any, TypeVar, Callable, TYPE_CHECKING, Union
7
+ from threading import Event as ManualResetEvent
5
8
  from threading import Lock
6
9
 
7
10
  from . import exceptions
8
- from .requests import Requests as requests
11
+ from .requests import requests
12
+
13
+ from scratchattach.site import _base
9
14
 
10
- from ..site import _base
11
15
 
12
16
  headers: Final = {
13
17
  "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
@@ -130,7 +134,7 @@ def _get_object(identificator_name, identificator, __class: type[C], NotFoundExc
130
134
  # Internal function: Generalization of the process ran by get_user, get_studio etc.
131
135
  # Builds an object of class that is inheriting from BaseSiteComponent
132
136
  # # Class must inherit from BaseSiteComponent
133
- from ..site import project
137
+ from scratchattach.site import project
134
138
  try:
135
139
  use_class: type = __class
136
140
  if __class is project.PartialProject:
@@ -182,44 +186,41 @@ class LockEvent:
182
186
  """
183
187
  Can be waited on and triggered. Not to be confused with threading.Event, which has to be reset.
184
188
  """
185
- locks: list[Lock]
189
+ _event: ManualResetEvent
190
+ _locks: list[Lock]
191
+ _access_locks: Lock
186
192
  def __init__(self):
187
- self.locks = []
188
- self.use_locks = Lock()
193
+ self._event = ManualResetEvent()
194
+ self._locks = []
195
+ self._access_locks = Lock()
189
196
 
190
197
  def wait(self, blocking: bool = True, timeout: Optional[Union[int, float]] = None) -> bool:
191
198
  """
192
199
  Wait for the event.
193
200
  """
194
- timeout = -1 if timeout is None else timeout
195
- if not blocking:
196
- timeout = 0
197
- return self.on().acquire(timeout=timeout)
201
+ return self._event.wait(timeout if blocking else 0)
198
202
 
199
203
  def trigger(self):
200
204
  """
201
205
  Trigger all threads waiting on this event to continue.
202
206
  """
203
- with self.use_locks:
204
- for lock in self.locks:
207
+ with self._access_locks:
208
+ for lock in self._locks:
205
209
  try:
206
- lock.release() # Unlock the lock once to trigger the event.
210
+ lock.release()
207
211
  except RuntimeError:
208
- lock.acquire(timeout=0) # Lock the lock again.
209
- for lock in self.locks.copy():
210
- try:
211
- lock.release() # Unlock the lock once more to make sure it was waited on.
212
- self.locks.remove(lock)
213
- except RuntimeError:
214
- lock.acquire(timeout=0) # Lock the lock again.
212
+ pass
213
+ self._locks.clear()
214
+ self._event.set()
215
+ self._event = ManualResetEvent()
215
216
 
216
217
  def on(self) -> Lock:
217
218
  """
218
- Return a lock that will unlock once the event takes place.
219
+ Return a lock that will unlock once the event takes place. Return value has to be waited on to wait for the event.
219
220
  """
220
221
  lock = Lock()
221
- with self.use_locks:
222
- self.locks.append(lock)
222
+ with self._access_locks:
223
+ self._locks.append(lock)
223
224
  lock.acquire(timeout=0)
224
225
  return lock
225
226
 
@@ -241,3 +242,14 @@ def get_class_sort_mode(mode: str) -> tuple[str, str]:
241
242
  descsort = "title"
242
243
 
243
244
  return ascsort, descsort
245
+
246
+
247
+ def b62_decode(s: str):
248
+ chars = string.digits + string.ascii_uppercase + string.ascii_lowercase
249
+
250
+ ret = 0
251
+ for char in s:
252
+ ret = ret * 62 + chars.index(char)
253
+
254
+ return ret
255
+
@@ -4,13 +4,12 @@ Adapted from https://translate-service.scratch.mit.edu/supported?language=en
4
4
  """
5
5
  from __future__ import annotations
6
6
 
7
- from enum import Enum
8
7
  from dataclasses import dataclass
9
-
8
+ from enum import Enum
10
9
  from typing import Optional, Callable, Iterable
11
10
 
12
11
 
13
- @dataclass(init=True, repr=True)
12
+ @dataclass
14
13
  class Language:
15
14
  name: str = None
16
15
  code: str = None
@@ -44,7 +43,7 @@ class _EnumWrapper(Enum):
44
43
 
45
44
  if apply_func(_val) == value:
46
45
  return item_obj
47
-
46
+
48
47
  except TypeError:
49
48
  pass
50
49
 
@@ -167,7 +166,7 @@ class Languages(_EnumWrapper):
167
166
  return super().all_of(attr_name, apply_func)
168
167
 
169
168
 
170
- @dataclass(init=True, repr=True)
169
+ @dataclass
171
170
  class TTSVoice:
172
171
  name: str
173
172
  gender: str
@@ -195,3 +194,43 @@ class TTSVoices(_EnumWrapper):
195
194
  def all_of(cls, attr_name: str = "name", apply_func: Optional[Callable] = None) -> Iterable:
196
195
  return super().all_of(attr_name, apply_func)
197
196
 
197
+
198
+ @dataclass
199
+ class AlertType:
200
+ id: int
201
+ message: str
202
+
203
+
204
+ class AlertTypes(_EnumWrapper):
205
+ """
206
+ Enum for associating alert type indecies with their messages, for use with the str.format() method.
207
+ """
208
+ # Reference: https://github.com/TimMcCool/scratchattach/issues/304#issuecomment-2800110811
209
+ # NOTE: THE TEXT WITHIN THE BRACES HERE MATTERS! IF YOU WANT TO CHANGE IT, MAKE SURE TO EDIT `site.alert.EducatorAlert`!
210
+ ban = AlertType(0, "{username} was banned.")
211
+ unban = AlertType(1, "{username} was unbanned.")
212
+ excluded_from_homepage = AlertType(2, "{username} was excluded from homepage")
213
+ excluded_from_homepage2 = AlertType(3, "{username} was excluded from homepage") # for some reason there are duplicates
214
+ notified = AlertType(4, "{username} was notified by a Scratch Administrator. Notification type: {notification_type}") # not sure what notification type is
215
+ autoban = AlertType(5, "{username} was banned automatically")
216
+ autoremoved = AlertType(6, "{project} by {username} was removed automatically")
217
+ project_censored2 = AlertType(7, "{project} by {username} was censored.") # <empty #7>
218
+ project_censored = AlertType(20, "{project} by {username} was censored.")
219
+ project_uncensored = AlertType(8, "{project} by {username} was uncensored.")
220
+ project_reviewed2 = AlertType(9, "{project} by {username} was reviewed by a Scratch Administrator.") # <empty #9>
221
+ project_reviewed = AlertType(10, "{project} by {username} was reviewed by a Scratch Administrator.")
222
+ project_deleted = AlertType(11, "{project} by {username} was deleted by a Scratch Administrator.")
223
+ user_deleted2 = AlertType(12, "{username} was deleted by a Scratch Administrator") # <empty #12>
224
+ user_deleted = AlertType(17, "{username} was deleted by a Scratch Administrator")
225
+ studio_reviewed2 = AlertType(13, "{studio} was reviewed by a Scratch Administrator.") # <empty #13>
226
+ studio_reviewed = AlertType(14, "{studio} was reviewed by a Scratch Administrator.")
227
+ studio_deleted = AlertType(15, "{studio} was deleted by a Scratch Administrator.")
228
+ email_confirm2 = AlertType(16, "The email address of {username} was confirmed by a Scratch Administrator") # <empty #16>
229
+ email_confirm = AlertType(18, "The email address of {username} was confirmed by a Scratch Administrator") # no '.' in HTML
230
+ email_unconfirm = AlertType(19, "The email address of {username} was set as not confirmed by a Scratch Administrator")
231
+ automute = AlertType(22, "{username} was automatically muted by our comment filters. The comment they tried to post was: {comment}")
232
+ default = AlertType(-1, "{username} had an admin action performed.") # default case
233
+
234
+ @classmethod
235
+ def find(cls, value, by: str = "id", apply_func: Optional[Callable] = None) -> AlertType:
236
+ return super().find(value, by, apply_func)
@@ -231,3 +231,13 @@ class LoginDataWarning(UserWarning):
231
231
  """
232
232
  Warns you not to accidentally share your login data.
233
233
  """
234
+
235
+ class AnonymousSiteComponentWarning(UserWarning):
236
+ """
237
+ Warns about a usage of an anonymous site component.
238
+ """
239
+
240
+ class UnexpectedWebsocketEventWarning(RuntimeWarning):
241
+ """
242
+ Warns about an unexpected occurrence with a websocket.
243
+ """