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
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import datetime
4
3
  import json
5
4
  import warnings
6
5
  from dataclasses import dataclass, field
@@ -14,7 +13,7 @@ if TYPE_CHECKING:
14
13
  from scratchattach.site.session import Session
15
14
 
16
15
  from scratchattach.utils.commons import requests
17
- from . import user, activity
16
+ from . import user, activity, typed_dicts
18
17
  from ._base import BaseSiteComponent
19
18
  from scratchattach.utils import exceptions, commons
20
19
  from scratchattach.utils.commons import headers
@@ -22,16 +21,16 @@ from scratchattach.utils.commons import headers
22
21
 
23
22
  @dataclass
24
23
  class Classroom(BaseSiteComponent):
25
- title: str = None
26
- id: int = None
27
- classtoken: str = None
24
+ title: str = ""
25
+ id: int = 0
26
+ classtoken: str = ""
28
27
 
29
- author: user.User = None
30
- about_class: str = None
31
- working_on: str = None
28
+ author: Optional[user.User] = None
29
+ about_class: str = ""
30
+ working_on: str = ""
32
31
 
33
32
  is_closed: bool = False
34
- datetime: datetime = None
33
+ datetime: datetime = datetime.fromtimestamp(0.0)
35
34
 
36
35
 
37
36
  update_function: Callable = field(repr=False, default=requests.get)
@@ -92,47 +91,30 @@ class Classroom(BaseSiteComponent):
92
91
  if pfx in script.text:
93
92
  educator_username = commons.webscrape_count(script.text, pfx, sfx, str)
94
93
 
95
- ret = {"id": self.id,
96
- "title": title,
97
- "description": description,
98
- "status": status,
99
- "educator": {"username": educator_username},
100
- "is_closed": True
101
- }
94
+ ret: typed_dicts.ClassroomDict = {
95
+ "id": self.id,
96
+ "title": title,
97
+ "description": description,
98
+ "educator": {},
99
+ "status": status,
100
+ "is_closed": True
101
+ }
102
+
103
+ if educator_username:
104
+ ret["educator"]["username"] = educator_username
102
105
 
103
106
  return self._update_from_dict(ret)
104
107
  return success
105
108
 
106
- def _update_from_dict(self, classrooms):
107
- try:
108
- self.id = int(classrooms["id"])
109
- except Exception:
110
- pass
111
- try:
112
- self.title = classrooms["title"]
113
- except Exception:
114
- pass
115
- try:
116
- self.about_class = classrooms["description"]
117
- except Exception:
118
- pass
119
- try:
120
- self.working_on = classrooms["status"]
121
- except Exception:
122
- pass
123
- try:
124
- self.datetime = datetime.datetime.fromisoformat(classrooms["date_start"])
125
- except Exception:
126
- pass
127
- try:
128
- self.author = user.User(username=classrooms["educator"]["username"], _session=self._session)
129
- except Exception:
130
- pass
131
- try:
132
- self.author._update_from_dict(classrooms["educator"])
133
- except Exception:
134
- pass
135
- self.is_closed = classrooms.get("is_closed", False)
109
+ def _update_from_dict(self, data: typed_dicts.ClassroomDict):
110
+ self.id = int(data["id"])
111
+ self.title = data["title"]
112
+ self.about_class = data["description"]
113
+ self.working_on = data["status"]
114
+ self.datetime = datetime.fromisoformat(data["date_start"])
115
+ self.author = user.User(username=data["educator"]["username"], _session=self._session)
116
+ self.author.supply_data_dict(data["educator"])
117
+ self.is_closed = bool(data["date_end"])
136
118
  return True
137
119
 
138
120
  def student_count(self) -> int:
@@ -157,14 +139,24 @@ class Classroom(BaseSiteComponent):
157
139
  ret = []
158
140
  response = requests.get(f"https://scratch.mit.edu/classes/{self.id}/")
159
141
  soup = BeautifulSoup(response.text, "html.parser")
160
-
161
- for scrollable in soup.find_all("ul", {"class": "scroll-content"}):
162
- for item in scrollable.contents:
163
- if not isinstance(item, bs4.NavigableString):
164
- if "user" in item.attrs["class"]:
165
- anchors = item.find_all("a")
166
- if len(anchors) == 2:
167
- ret.append(anchors[1].text.strip())
142
+ found = set("")
143
+
144
+ for result in soup.css.select("ul.scroll-content .user a"):
145
+ result_text = result.text.strip()
146
+ if result_text in found:
147
+ continue
148
+ found.add(result_text)
149
+ ret.append(result_text)
150
+
151
+ # for scrollable in soup.find_all("ul", {"class": "scroll-content"}):
152
+ # if not isinstance(scrollable, Tag):
153
+ # continue
154
+ # for item in scrollable.contents:
155
+ # if not isinstance(item, bs4.NavigableString):
156
+ # if "user" in item.attrs["class"]:
157
+ # anchors = item.find_all("a")
158
+ # if len(anchors) == 2:
159
+ # ret.append(anchors[1].text.strip())
168
160
 
169
161
  return ret
170
162
 
@@ -198,13 +190,19 @@ class Classroom(BaseSiteComponent):
198
190
  response = requests.get(f"https://scratch.mit.edu/classes/{self.id}/")
199
191
  soup = BeautifulSoup(response.text, "html.parser")
200
192
 
201
- for scrollable in soup.find_all("ul", {"class": "scroll-content"}):
202
- for item in scrollable.contents:
203
- if not isinstance(item, bs4.NavigableString):
204
- if "gallery" in item.attrs["class"]:
205
- anchor = item.find("a")
206
- if "href" in anchor.attrs:
207
- ret.append(commons.webscrape_count(anchor.attrs["href"], "/studios/", "/"))
193
+ for result in soup.css.select("ul.scroll-content .gallery a[href]:not([class])"):
194
+ value = result["href"]
195
+ if not isinstance(value, str):
196
+ value = value[0]
197
+ ret.append(commons.webscrape_count(value, "/studios/", "/"))
198
+
199
+ # for scrollable in soup.find_all("ul", {"class": "scroll-content"}):
200
+ # for item in scrollable.contents:
201
+ # if not isinstance(item, bs4.NavigableString):
202
+ # if "gallery" in item.attrs["class"]:
203
+ # anchor = item.find("a")
204
+ # if "href" in anchor.attrs:
205
+ # ret.append(commons.webscrape_count(anchor.attrs["href"], "/studios/", "/"))
208
206
  return ret
209
207
 
210
208
  text = requests.get(
@@ -317,7 +315,7 @@ class Classroom(BaseSiteComponent):
317
315
  def register_student(self, username: str, password: str = '', birth_month: Optional[int] = None,
318
316
  birth_year: Optional[int] = None,
319
317
  gender: Optional[str] = None, country: Optional[str] = None, is_robot: bool = False) -> None:
320
- return register_by_token(self.id, self.classtoken, username, password, birth_month, birth_year, gender, country,
318
+ return register_by_token(self.id, self.classtoken, username, password, birth_month or 1, birth_year or 2000, gender or "(Prefer not to say)", country or "United+States",
321
319
  is_robot)
322
320
 
323
321
  def generate_signup_link(self):
@@ -356,8 +354,7 @@ class Classroom(BaseSiteComponent):
356
354
 
357
355
  return activities
358
356
 
359
- def activity(self, student: str = "all", mode: str = "Last created", page: Optional[int] = None) -> list[
360
- dict[str, Any]]:
357
+ def activity(self, student: str = "all", mode: str = "Last created", page: Optional[int] = None) -> list[activity.Activity]:
361
358
  """
362
359
  Get a list of private activity, only available to the class owner.
363
360
  Returns:
@@ -370,18 +367,16 @@ class Classroom(BaseSiteComponent):
370
367
 
371
368
  with requests.no_error_handling():
372
369
  try:
373
- data = requests.get(
374
- f"https://scratch.mit.edu/site-api/classrooms/activity/{self.id}/{student}/",
375
- params={"page": page, "ascsort": ascsort, "descsort": descsort},
376
- headers=self._headers, cookies=self._cookies
377
- ).json()
370
+ data = requests.get(f"https://scratch.mit.edu/site-api/classrooms/activity/{self.id}/{student}/",
371
+ params={"page": page, "ascsort": ascsort, "descsort": descsort},
372
+ headers=self._headers, cookies=self._cookies).json()
378
373
  except json.JSONDecodeError:
379
374
  return []
380
375
 
381
- _activity = []
376
+ _activity: list[activity.Activity] = []
382
377
  for activity_json in data:
383
378
  _activity.append(activity.Activity(_session=self._session))
384
- _activity[-1]._update_from_json(activity_json)
379
+ _activity[-1]._update_from_json(activity_json) # NOT the same as _update_from_dict
385
380
 
386
381
  return _activity
387
382
 
@@ -401,7 +396,13 @@ def get_classroom(class_id: str) -> Classroom:
401
396
 
402
397
  If you want to use these, get the user with :meth:`scratchattach.session.Session.connect_classroom` instead.
403
398
  """
404
- warnings.warn("For methods that require authentication, use session.connect_classroom instead of get_classroom")
399
+ warnings.warn(
400
+ "For methods that require authentication, use session.connect_classroom instead of get_classroom\n"
401
+ "If you want to remove this warning, use warnings.filterwarnings('ignore', category=scratchattach.ClassroomAuthenticationWarning)\n"
402
+ "To ignore all warnings of the type GetAuthenticationWarning, which includes this warning, use "
403
+ "`warnings.filterwarnings('ignore', category=scratchattach.GetAuthenticationWarning)`.",
404
+ exceptions.ClassroomAuthenticationWarning
405
+ )
405
406
  return commons._get_object("id", class_id, Classroom, exceptions.ClassroomNotFound)
406
407
 
407
408
 
@@ -420,7 +421,13 @@ def get_classroom_from_token(class_token) -> Classroom:
420
421
 
421
422
  If you want to use these, get the user with :meth:`scratchattach.session.Session.connect_classroom` instead.
422
423
  """
423
- warnings.warn("For methods that require authentication, use session.connect_classroom instead of get_classroom")
424
+ warnings.warn(
425
+ "For methods that require authentication, use session.connect_classroom instead of get_classroom. "
426
+ "If you want to remove this warning, use warnings.filterwarnings('ignore', category=ClassroomAuthenticationWarning). "
427
+ "To ignore all warnings of the type GetAuthenticationWarning, which includes this warning, use "
428
+ "warnings.filterwarnings('ignore', category=GetAuthenticationWarning).",
429
+ exceptions.ClassroomAuthenticationWarning
430
+ )
424
431
  return commons._get_object("classtoken", class_token, Classroom, exceptions.ClassroomNotFound)
425
432
 
426
433
 
@@ -1,11 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import time
4
- from ._base import BaseSiteComponent
5
-
4
+ from typing import Union, TypeGuard, Optional
5
+ from dataclasses import dataclass, field
6
+ import warnings
6
7
 
8
+ from scratchattach.cloud import _base
9
+ from scratchattach.utils import exceptions
10
+ from scratchattach.site import project, user
11
+ from ._base import BaseSiteComponent
12
+ from . import typed_dicts, session
7
13
 
8
- class CloudActivity(BaseSiteComponent):
14
+ @dataclass
15
+ class CloudActivity(BaseSiteComponent[Union[typed_dicts.CloudActivityDict, typed_dicts.CloudLogActivityDict]]):
9
16
  """
10
17
  Represents a cloud activity (a cloud variable set / creation / deletion).
11
18
 
@@ -25,6 +32,14 @@ class CloudActivity(BaseSiteComponent):
25
32
 
26
33
  :.cloud: The cloud (as object inheriting from scratchattach.Cloud.BaseCloud) that the cloud activity corresponds to
27
34
  """
35
+ username: str = field(kw_only=True, default="")
36
+ var: str = field(kw_only=True, default="")
37
+ name: str = field(kw_only=True, default="")
38
+ type: str = field(kw_only=True, default="set")
39
+ timestamp: float = field(kw_only=True, default=0.0)
40
+ value: Union[float, int, str] = field(kw_only=True, default="0.0")
41
+ cloud: _base.AnyCloud = field(kw_only=True, default_factory=lambda : _base.DummyCloud())
42
+ _session: Optional[session.Session] = field(kw_only=True, default=None)
28
43
 
29
44
  def __init__(self, **entries):
30
45
  # Set attributes every CloudActivity object needs to have:
@@ -39,32 +54,30 @@ class CloudActivity(BaseSiteComponent):
39
54
  self.__dict__.update(entries)
40
55
 
41
56
  def update(self):
42
- print("Warning: CloudActivity objects can't be updated")
57
+ warnings.warn("CloudActivity objects can't be updated", exceptions.InvalidUpdateWarning)
43
58
  return False # Objects of this type cannot be updated
44
59
 
45
60
  def __eq__(self, activity2):
46
61
  # CloudLogEvents needs to check if two activites are equal (to finde new ones), therefore CloudActivity objects need to be comparable
47
62
  return self.user == activity2.user and self.type == activity2.type and self.timestamp == activity2.timestamp and self.value == activity2.value and self.name == activity2.name
48
63
 
49
- def _update_from_dict(self, data) -> bool:
50
- try: self.name = data["name"]
51
- except Exception: pass
52
- try: self.var = data["name"]
53
- except Exception: pass
54
- try: self.value = data["value"]
55
- except Exception: pass
56
- try: self.user = data["user"]
57
- except Exception: pass
58
- try: self.username = data["user"]
59
- except Exception: pass
60
- try: self.timestamp = data["timestamp"]
61
- except Exception: pass
62
- try: self.type = data["verb"].replace("_var","")
63
- except Exception: pass
64
- try: self.type = data["method"]
65
- except Exception: pass
66
- try: self.cloud = data["cloud"]
67
- except Exception: pass
64
+ def _update_from_dict(self, data: Union[typed_dicts.CloudActivityDict, typed_dicts.CloudLogActivityDict]) -> bool:
65
+ def is_cloud_log_activity(activity: Union[typed_dicts.CloudActivityDict, typed_dicts.CloudLogActivityDict]) -> TypeGuard[typed_dicts.CloudLogActivityDict]:
66
+ return "verb" in activity
67
+ def is_cloud_activity(activity: Union[typed_dicts.CloudActivityDict, typed_dicts.CloudLogActivityDict]) -> TypeGuard[typed_dicts.CloudActivityDict]:
68
+ return "method" in activity
69
+ self.name = data["name"]
70
+ self.var = data["name"]
71
+ self.value = data["value"]
72
+ if is_cloud_log_activity(data):
73
+ self.user = data["user"]
74
+ self.username = data["user"]
75
+ self.timestamp = data["timestamp"]
76
+ self.type = data["verb"].removesuffix("_var")
77
+ elif is_cloud_activity(data):
78
+ self.type = data["method"]
79
+ if "cloud" in data:
80
+ self.cloud = data["cloud"]
68
81
  return True
69
82
 
70
83
  def load_log_data(self):
@@ -91,17 +104,18 @@ class CloudActivity(BaseSiteComponent):
91
104
  """
92
105
  if self.username is None:
93
106
  return None
94
- from scratchattach.site import user
95
- from scratchattach.utils import exceptions
96
107
  return self._make_linked_object("username", self.username, user.User, exceptions.UserNotFound)
97
108
 
98
- def project(self):
109
+ def project(self) -> Optional[project.Project]:
99
110
  """
100
111
  Returns the project where the cloud activity was performed as scratchattach.project.Project object
101
112
  """
113
+ def make_linked(cloud: _base.BaseCloud) -> project.Project:
114
+ return self._make_linked_object("id", cloud.project_id, project.Project, exceptions.ProjectNotFound)
102
115
  if self.cloud is None:
103
116
  return None
104
- from scratchattach.site import project
105
- from scratchattach.utils import exceptions
106
- return self._make_linked_object("id", self.cloud.project_id, project.Project, exceptions.ProjectNotFound)
117
+ cloud = self.cloud
118
+ if not isinstance(cloud, _base.BaseCloud):
119
+ return None
120
+ return make_linked(cloud)
107
121
 
@@ -1,103 +1,84 @@
1
1
  """Comment class"""
2
2
  from __future__ import annotations
3
3
 
4
+ import warnings
4
5
  import html
6
+
7
+ from dataclasses import dataclass
8
+ from typing_extensions import assert_never
5
9
  from typing import Union, Optional, Any
6
- from typing_extensions import assert_never # importing from typing caused me errors
7
10
  from enum import Enum, auto
8
11
 
9
- from . import user, project, studio
12
+ from . import user, project, studio, session
10
13
  from ._base import BaseSiteComponent
11
14
  from scratchattach.utils import exceptions
12
15
 
16
+ class CommentSource(Enum):
17
+ PROJECT = auto()
18
+ USER_PROFILE = auto()
19
+ STUDIO = auto()
20
+ UNKNOWN = auto()
21
+
22
+ @dataclass
13
23
  class Comment(BaseSiteComponent):
14
24
  """
15
25
  Represents a Scratch comment (on a profile, studio or project)
16
26
  """
17
- id: Union[int, str]
18
- source: str
19
- source_id: Union[int, str]
20
- cached_replies: Optional[list[Comment]]
21
- parent_id: Optional[Union[int, str]]
22
- cached_parent_comment: Optional[Comment]
23
- commentee_id: Optional[int]
24
- content: Any
27
+ id: Optional[int | str] = None
28
+ source: CommentSource = CommentSource.UNKNOWN
29
+ source_id: Optional[int | str] = None
30
+ cached_replies: Optional[list[Comment]] = None
31
+ parent_id: Optional[int | str] = None
32
+ cached_parent_comment: Optional[Comment] = None
33
+ commentee_id: Optional[int] = None
34
+ content: Optional[str] = None
35
+ reply_count: Optional[int] = None
36
+ written_by_scratchteam: Optional[bool] = None
37
+ author_id: Optional[int] = None
38
+ author_name: Optional[str] = None
39
+
40
+ _session: Optional[session.Session] = None
25
41
 
26
42
  def __str__(self):
27
- return str(self.content)
28
-
29
- def __init__(self, **entries):
30
-
31
- # Set attributes every Comment object needs to have:
32
- self.id = None
33
- self._session = None
34
- self.source = None
35
- self.source_id = None
36
- self.cached_replies = None
37
- self.parent_id = None
38
- self.cached_parent_comment = None
39
-
40
- # Update attributes from entries dict:
41
- self.__dict__.update(entries)
42
-
43
- if "source" not in entries:
44
- self.source = "Unknown"
43
+ return self.text
45
44
 
46
45
  def update(self):
47
- print("Warning: Comment objects can't be updated")
46
+ warnings.warn("Warning: Comment objects can't be updated")
48
47
  return False # Objects of this type cannot be updated
49
48
 
50
- def _update_from_dict(self, data):
51
- try:
52
- self.id = data["id"]
53
- except Exception:
54
- pass
55
- try:
56
- self.parent_id = data["parent_id"]
57
- except Exception:
58
- pass
59
- try:
60
- self.commentee_id = data["commentee_id"]
61
- except Exception:
62
- pass
63
- try:
64
- self.content = data["content"]
65
- except Exception:
66
- pass
67
- try:
68
- self.datetime_created = data["datetime_created"]
69
- except Exception:
70
- pass
71
- try:
72
- self.author_name = data["author"]["username"]
73
- except Exception:
74
- pass
75
- try:
76
- self.author_id = data["author"]["id"]
77
- except Exception:
78
- pass
79
- try:
80
- self.written_by_scratchteam = data["author"]["scratchteam"]
81
- except Exception:
82
- pass
83
- try:
84
- self.reply_count = data["reply_count"]
85
- except Exception:
86
- pass
87
- try:
88
- self.source = data["source"]
89
- except Exception:
90
- pass
91
- try:
92
- self.source_id = data["source_id"]
93
- except Exception:
94
- pass
95
- return True
49
+ def _update_from_dict(self, data: dict[str, str | dict | Any]):
50
+ self.id = data["id"]
51
+ self.parent_id = data.get("parent_id")
52
+ self.commentee_id = data.get("commentee_id")
53
+ self.content = str(data["content"])
54
+ self.datetime_created = data["datetime_created"]
55
+
56
+ author = data.get("author", {})
57
+ self.author_name = author.get("username", self.author_name)
58
+ self.author_id = author.get("id", self.author_id)
59
+ self.written_by_scratchteam = author.get("scratchteam", self.written_by_scratchteam)
60
+ self.reply_count = data.get("reply_count", self.reply_count)
61
+
62
+ source: str = data.get("source")
63
+ if self.source is CommentSource.UNKNOWN:
64
+ self.source = {
65
+ "project": CommentSource.PROJECT,
66
+ "studio": CommentSource.STUDIO,
67
+ "profile": CommentSource.USER_PROFILE,
68
+ None: CommentSource.UNKNOWN,
69
+ }[source]
70
+
71
+ self.source_id = data.get("source_id", self.source_id)
96
72
 
97
73
  @property
98
74
  def text(self) -> str:
99
- if self.source == "profile":
75
+ """
76
+ Parsed version of Comment.content. This removes any escape codes, e.g. ''' becomes ', an apostrophe
77
+ """
78
+ if self.source is CommentSource.USER_PROFILE:
79
+ # user profile comments do not seem to be escaped
100
80
  return self.content
81
+
101
82
  return str(html.unescape(self.content))
102
83
 
103
84
  # Methods for getting related entities
@@ -111,14 +92,14 @@ class Comment(BaseSiteComponent):
111
92
 
112
93
  If the place can't be traced back, None is returned.
113
94
  """
114
- if self.source == "profile":
95
+ if self.source == CommentSource.USER_PROFILE:
115
96
  return self._make_linked_object("username", self.source_id, user.User, exceptions.UserNotFound)
116
- elif self.source == "studio":
97
+ elif self.source == CommentSource.STUDIO:
117
98
  return self._make_linked_object("id", self.source_id, studio.Studio, exceptions.UserNotFound)
118
- elif self.source == "project":
99
+ elif self.source == CommentSource.PROJECT:
119
100
  return self._make_linked_object("id", self.source_id, project.Project, exceptions.UserNotFound)
120
101
  else:
121
- raise ValueError("Unknown source.")
102
+ assert_never(self.source)
122
103
 
123
104
  def parent_comment(self) -> Comment | None:
124
105
  if self.parent_id is None:
@@ -148,18 +129,16 @@ class Comment(BaseSiteComponent):
148
129
  use_cache (bool): Returns the replies cached on the first reply fetch. This makes it SIGNIFICANTLY faster for profile comments. Warning: For profile comments, the replies are retrieved and cached on object creation.
149
130
  """
150
131
  if (self.cached_replies is None) or (not use_cache):
151
- if self.source == "profile":
152
- _cached_replies = user.User(username=self.source_id, _session=self._session).comment_by_id(
153
- self.id).cached_replies
154
- if _cached_replies is not None:
155
- self.cached_replies = _cached_replies[offset:offset + limit]
132
+ if self.source == CommentSource.USER_PROFILE:
133
+ self.cached_replies = user.User(username=self.source_id, _session=self._session).comment_by_id(
134
+ self.id).cached_replies[offset:offset + limit]
156
135
 
157
- elif self.source == "project":
136
+ elif self.source == CommentSource.PROJECT:
158
137
  p = project.Project(id=self.source_id, _session=self._session)
159
138
  p.update()
160
139
  self.cached_replies = p.comment_replies(comment_id=self.id, limit=limit, offset=offset)
161
140
 
162
- elif self.source == "studio":
141
+ elif self.source == CommentSource.STUDIO:
163
142
  self.cached_replies = studio.Studio(id=self.source_id, _session=self._session).comment_replies(
164
143
  comment_id=self.id, limit=limit, offset=offset)
165
144
 
@@ -185,6 +164,8 @@ class Comment(BaseSiteComponent):
185
164
 
186
165
  Returns:
187
166
  scratchattach.Comment: The created comment.
167
+ :param content: Content of the comment to send
168
+ :param commentee_id: ID of user to reply to
188
169
  """
189
170
 
190
171
  self._assert_auth()
@@ -192,51 +173,56 @@ class Comment(BaseSiteComponent):
192
173
  if self.parent_id is not None:
193
174
  parent_id = str(self.parent_id)
194
175
  if commentee_id is None:
195
- if "author_id" in self.__dict__:
176
+ if self.author_id:
196
177
  commentee_id = self.author_id
197
178
  else:
198
179
  commentee_id = ""
199
- if self.source == "profile":
180
+
181
+ if self.source == CommentSource.USER_PROFILE:
200
182
  return user.User(username=self.source_id, _session=self._session).reply_comment(content,
201
183
  parent_id=str(parent_id),
202
184
  commentee_id=commentee_id)
203
- if self.source == "project":
185
+ if self.source == CommentSource.PROJECT:
204
186
  p = project.Project(id=self.source_id, _session=self._session)
205
187
  p.update()
206
188
  return p.reply_comment(content, parent_id=str(parent_id), commentee_id=commentee_id)
207
- if self.source == "studio":
189
+
190
+ if self.source == CommentSource.STUDIO:
208
191
  return studio.Studio(id=self.source_id, _session=self._session).reply_comment(content,
209
192
  parent_id=str(parent_id),
210
193
  commentee_id=commentee_id)
194
+ raise ValueError(f"Unknown source: {self.source}")
211
195
 
212
196
  def delete(self):
213
197
  """
214
198
  Deletes the comment.
215
199
  """
216
200
  self._assert_auth()
217
- if self.source == "profile":
218
- user.User(username=self.source_id, _session=self._session).delete_comment(comment_id=self.id)
201
+ if self.source == CommentSource.USER_PROFILE:
202
+ return user.User(username=self.source_id, _session=self._session).delete_comment(comment_id=self.id)
219
203
 
220
- elif self.source == "project":
204
+ elif self.source == CommentSource.PROJECT:
221
205
  p = project.Project(id=self.source_id, _session=self._session)
222
206
  p.update()
223
- p.delete_comment(comment_id=self.id)
207
+ return p.delete_comment(comment_id=self.id)
224
208
 
225
- elif self.source == "studio":
226
- studio.Studio(id=self.source_id, _session=self._session).delete_comment(comment_id=self.id)
209
+ elif self.source == CommentSource.STUDIO:
210
+ return studio.Studio(id=self.source_id, _session=self._session).delete_comment(comment_id=self.id)
211
+
212
+ return None # raise error?
227
213
 
228
214
  def report(self):
229
215
  """
230
216
  Reports the comment to the Scratch team.
231
217
  """
232
218
  self._assert_auth()
233
- if self.source == "profile":
219
+ if self.source == CommentSource.USER_PROFILE:
234
220
  user.User(username=self.source_id, _session=self._session).report_comment(comment_id=self.id)
235
221
 
236
- elif self.source == "project":
222
+ elif self.source == CommentSource.PROJECT:
237
223
  p = project.Project(id=self.source_id, _session=self._session)
238
224
  p.update()
239
225
  p.report_comment(comment_id=self.id)
240
226
 
241
- elif self.source == "studio":
227
+ elif self.source == CommentSource.STUDIO:
242
228
  studio.Studio(id=self.source_id, _session=self._session).report_comment(comment_id=self.id)