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,13 +1,16 @@
1
1
  """User class"""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import json
5
6
  import random
6
7
  import re
7
8
  import string
9
+ import warnings
10
+ from typing import Union, cast, Optional, TypedDict
11
+ from dataclasses import dataclass, field
8
12
  from datetime import datetime, timezone
9
13
  from enum import Enum
10
- import warnings
11
14
 
12
15
  from typing_extensions import deprecated
13
16
  from bs4 import BeautifulSoup, Tag
@@ -26,29 +29,62 @@ from . import forum
26
29
  from . import comment
27
30
  from . import activity
28
31
  from . import classroom
32
+ from . import typed_dicts
33
+ from . import session
34
+
29
35
 
30
36
  class Rank(Enum):
31
37
  """
32
38
  Possible ranks in scratch
33
39
  """
40
+
34
41
  NEW_SCRATCHER = 0
35
42
  SCRATCHER = 1
36
43
  SCRATCH_TEAM = 2
37
44
 
45
+
46
+ class _OcularStatusMeta(TypedDict):
47
+ updated: str
48
+ updatedBy: str
49
+
50
+
51
+ class _OcularStatus(TypedDict):
52
+ _id: str
53
+ name: str
54
+ status: str
55
+ color: str
56
+ meta: _OcularStatusMeta
57
+
58
+
38
59
  class Verificator:
39
60
 
40
61
  def __init__(self, user: User, project_id: int):
41
- self.project = user._make_linked_object("id", project_id, project.Project, exceptions.ProjectNotFound)
62
+ self.project = user._make_linked_object(
63
+ "id", project_id, project.Project, exceptions.ProjectNotFound
64
+ )
42
65
  self.projecturl = self.project.url
43
- self.code = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
66
+ self.code = "".join(random.choices(string.ascii_letters + string.digits, k=8))
44
67
  self.username = user.username
45
68
 
46
69
  def check(self) -> bool:
47
- return bool(list(filter(lambda x : x.author_name == self.username and (x.content == self.code or x.content.startswith(self.code) or x.content.endswith(self.code)), self.project.comments())))
70
+ return bool(
71
+ list(
72
+ filter(
73
+ lambda x: x.author_name == self.username
74
+ and (
75
+ x.content == self.code
76
+ or x.content.startswith(self.code)
77
+ or x.content.endswith(self.code)
78
+ ),
79
+ self.project.comments(),
80
+ )
81
+ )
82
+ )
48
83
 
49
- class User(BaseSiteComponent):
50
84
 
51
- '''
85
+ @dataclass
86
+ class User(BaseSiteComponent[typed_dicts.UserDict]):
87
+ """
52
88
  Represents a Scratch user.
53
89
 
54
90
  Attributes:
@@ -68,76 +104,150 @@ class User(BaseSiteComponent):
68
104
  :.scratchteam: Retuns True if the user is in the Scratch team
69
105
 
70
106
  :.update(): Updates the attributes
71
- '''
107
+ """
108
+
109
+ username: str = field(kw_only=True, default="")
110
+ join_date: str = field(kw_only=True, default="")
111
+ about_me: str = field(kw_only=True, default="")
112
+ wiwo: str = field(kw_only=True, default="")
113
+ country: str = field(kw_only=True, default="")
114
+ icon_url: str = field(kw_only=True, default="")
115
+ id: int = field(kw_only=True, default=0)
116
+ scratchteam: bool = field(kw_only=True, repr=False, default=False)
117
+ is_member: bool = field(kw_only=True, repr=False, default=False)
118
+ has_ears: bool = field(kw_only=True, repr=False, default=False)
119
+ _classroom: tuple[bool, Optional[classroom.Classroom]] = field(
120
+ init=False, default=(False, None)
121
+ )
122
+ _headers: dict[str, str] = field(init=False, default_factory=headers.copy)
123
+ _cookies: dict[str, str] = field(init=False, default_factory=dict)
124
+ _json_headers: dict[str, str] = field(init=False, default_factory=dict)
125
+ _session: Optional[session.Session] = field(kw_only=True, default=None)
72
126
 
73
127
  def __str__(self):
74
- return str(self.username)
128
+ return f"-U {self.username}"
129
+
130
+ @property
131
+ def status(self) -> str:
132
+ return self.wiwo
133
+
134
+ @property
135
+ def bio(self) -> str:
136
+ return self.about_me
137
+
138
+ @property
139
+ def icon(self) -> bytes:
140
+ with requests.no_error_handling():
141
+ return requests.get(self.icon_url).content
142
+
143
+ @property
144
+ def name(self) -> str:
145
+ return self.username
75
146
 
76
- def __init__(self, **entries):
147
+ def __post_init__(self):
77
148
 
78
149
  # Info on how the .update method has to fetch the data:
79
150
  self.update_function = requests.get
80
- self.update_api = f"https://api.scratch.mit.edu/users/{entries['username']}"
81
-
82
- # Set attributes every User object needs to have:
83
- self._session = None
84
- self.id = None
85
- self.username = None
86
- self.name = None
151
+ self.update_api = f"https://api.scratch.mit.edu/users/{self.username}"
87
152
 
88
153
  # cache value for classroom getter method (using @property)
89
154
  # 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
-
92
- # Update attributes from entries dict:
93
- entries.setdefault("name", entries.get("username"))
94
- self.__dict__.update(entries)
95
-
96
- # Set alternative attributes:
97
- if hasattr(self, "bio"):
98
- self.about_me = self.bio
99
- if hasattr(self, "status"):
100
- self.wiwo = self.status
101
- if hasattr(self, "name"):
102
- self.username = self.name
155
+ # self._classroom
103
156
 
104
157
  # Headers and cookies:
105
- if self._session is None:
106
- self._headers :dict = headers
107
- self._cookies = {}
108
- else:
109
- self._headers :dict = self._session._headers
110
- self._cookies = self._session._cookies
158
+ if self._session is not None:
159
+ self._headers = self._session.get_headers()
160
+ self._cookies = self._session.get_cookies()
111
161
 
112
162
  # Headers for operations that require accept and Content-Type fields:
113
163
  self._json_headers = dict(self._headers)
114
164
  self._json_headers["accept"] = "application/json"
115
165
  self._json_headers["Content-Type"] = "application/json"
116
166
 
117
- def _update_from_dict(self, data):
118
- try: self.id = data["id"]
119
- except KeyError: pass
120
- try: self.username = data["username"]
121
- except KeyError: pass
122
- try: self.scratchteam = data["scratchteam"]
123
- except KeyError: pass
124
- try: self.join_date = data["history"]["joined"]
125
- except KeyError: pass
126
- try: self.about_me = data["profile"]["bio"]
127
- except KeyError: pass
128
- try: self.wiwo = data["profile"]["status"]
129
- except KeyError: pass
130
- try: self.country = data["profile"]["country"]
131
- except KeyError: pass
132
- try: self.icon_url = data["profile"]["images"]["90x90"]
133
- except KeyError: pass
167
+ def _update_from_dict(self, data: Union[dict, typed_dicts.UserDict]):
168
+ data = cast(typed_dicts.UserDict, data)
169
+
170
+ self.id = data.get("id", self.id)
171
+ self.username = data.get("username", self.username)
172
+ self.scratchteam = data.get("scratchteam", self.scratchteam)
173
+ if history := data.get("history"):
174
+ self.join_date = history["joined"]
175
+
176
+ if profile := data.get("profile"):
177
+ self.about_me = profile["bio"]
178
+ self.wiwo = profile["status"]
179
+ self.country = profile["country"]
180
+ self.icon_url = profile["images"]["90x90"]
181
+ self.is_member = bool(profile.get("membership_label", False))
182
+ self.has_ears = bool(profile.get("membership_avatar_badge", False))
134
183
  return True
135
184
 
136
185
  def _assert_permission(self):
137
186
  self._assert_auth()
138
- if self._session._username != self.username:
187
+ if self._session.username != self.username:
139
188
  raise exceptions.Unauthorized(
140
- "You need to be authenticated as the profile owner to do this.")
189
+ "You need to be authenticated as the profile owner to do this."
190
+ )
191
+
192
+ @property
193
+ def url(self):
194
+ return f"https://scratch.mit.edu/users/{self.username}"
195
+
196
+ def __rich__(self):
197
+ from rich.panel import Panel
198
+ from rich.table import Table
199
+ from rich import box
200
+ from rich.markup import escape
201
+
202
+ featured_data = self.featured_data() or {}
203
+ ocular_data = self.ocular_status()
204
+ ocular = "No ocular status"
205
+
206
+ if status := ocular_data.get("status"):
207
+ color_str = ""
208
+ color_data = ocular_data.get("color")
209
+ if color_data is not None:
210
+ color_str = f"[{color_data}] ⬤ [/]"
211
+
212
+ ocular = f"[i]{escape(status)}[/]{color_str}"
213
+
214
+ _classroom = self.classroom
215
+ url = f"[link={self.url}]{escape(self.username)}[/]"
216
+
217
+ info = Table(box=box.SIMPLE)
218
+ info.add_column(url, overflow="fold")
219
+ info.add_column(f"#{self.id}", overflow="fold")
220
+
221
+ info.add_row("Joined", escape(self.join_date))
222
+ info.add_row("Country", escape(self.country))
223
+ info.add_row("Messages", str(self.message_count()))
224
+ info.add_row(
225
+ "Class", str(_classroom.title if _classroom is not None else "None")
226
+ )
227
+
228
+ desc = Table("Profile", ocular, box=box.SIMPLE)
229
+ desc.add_row("About me", escape(self.about_me))
230
+ desc.add_row("Wiwo", escape(self.wiwo))
231
+ desc.add_row(
232
+ escape(featured_data.get("label", "Featured Project")),
233
+ escape(str(self.connect_featured_project())),
234
+ )
235
+
236
+ ret = Table.grid(expand=True)
237
+
238
+ ret.add_column(ratio=1)
239
+ ret.add_column(ratio=3)
240
+ ret.add_row(Panel(info, title=url), Panel(desc, title="Description"))
241
+
242
+ return ret
243
+
244
+ def connect_featured_project(self) -> Optional[project.Project]:
245
+ data = self.featured_data() or {}
246
+ if pid := data.get("id"):
247
+ return self._session.connect_project(int(pid))
248
+ if projs := self.projects(limit=1):
249
+ return projs[0]
250
+ return None
141
251
 
142
252
  @property
143
253
  def classroom(self) -> classroom.Classroom | None:
@@ -151,48 +261,58 @@ class User(BaseSiteComponent):
151
261
  soup = BeautifulSoup(resp.text, "html.parser")
152
262
 
153
263
  details = soup.find("p", {"class": "profile-details"})
264
+ if details is None:
265
+ # No details, e.g. if the user is banned
266
+ return None
267
+
154
268
  assert isinstance(details, Tag)
155
269
 
156
- class_name, class_id, is_closed = None, 0, False
270
+ class_name, class_id, is_closed = None, None, False
157
271
  for a in details.find_all("a"):
158
272
  if not isinstance(a, Tag):
159
273
  continue
160
274
  href = str(a.get("href"))
161
275
  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
276
+ class_name = a.text.strip()[len("Student of: ") :]
277
+ is_closed = bool(
278
+ re.search(r"\n *\(ended\)", class_name)
279
+ ) # as this has a \n, we can be sure
164
280
  if is_closed:
165
- class_name = class_name[:-7].strip()
281
+ class_name = re.sub(r"\n *\(ended\)", "", class_name).strip()
166
282
 
167
- class_id = int(href.split('/')[2])
283
+ class_id = int(href.split("/")[2])
168
284
  break
169
285
 
170
286
  if class_name:
171
287
  self._classroom = True, classroom.Classroom(
172
288
  _session=self._session,
173
- id=class_id,
289
+ id=class_id or 0,
174
290
  title=class_name,
175
- is_closed=is_closed
291
+ is_closed=is_closed,
176
292
  )
177
293
  else:
178
294
  self._classroom = True, None
179
295
 
180
296
  return self._classroom[1]
181
297
 
182
- def does_exist(self):
298
+ def does_exist(self) -> Optional[bool]:
183
299
  """
184
300
  Returns:
185
301
  boolean : True if the user exists, False if the user is deleted, None if an error occured
186
302
  """
187
303
  with requests.no_error_handling():
188
- status_code = requests.get(f"https://scratch.mit.edu/users/{self.username}/").status_code
189
- if status_code == 200:
190
- return True
191
- elif status_code == 404:
192
- return False
304
+ status_code = requests.get(
305
+ f"https://scratch.mit.edu/users/{self.username}/"
306
+ ).status_code
307
+ if status_code == 200:
308
+ return True
309
+ elif status_code == 404:
310
+ return False
311
+
312
+ return None
193
313
 
194
314
  # Will maybe be deprecated later, but for now still has its own purpose.
195
- #@deprecated("This function is partially deprecated. Use user.rank() instead.")
315
+ # @deprecated("This function is partially deprecated. Use user.rank() instead.")
196
316
  def is_new_scratcher(self):
197
317
  """
198
318
  Returns:
@@ -200,8 +320,10 @@ class User(BaseSiteComponent):
200
320
  """
201
321
  try:
202
322
  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]
323
+ res = requests.get(
324
+ f"https://scratch.mit.edu/users/{self.username}/"
325
+ ).text
326
+ group = res[res.rindex('<span class="group">') :][:70]
205
327
  return "new scratcher" in group.lower()
206
328
 
207
329
  except Exception as e:
@@ -209,8 +331,14 @@ class User(BaseSiteComponent):
209
331
  return None
210
332
 
211
333
  def message_count(self):
212
-
213
- return json.loads(requests.get(f"https://api.scratch.mit.edu/users/{self.username}/messages/count/?cachebust={random.randint(0,10000)}", headers = {'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.3c6 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36',}).text)["count"]
334
+ return json.loads(
335
+ requests.get(
336
+ f"https://api.scratch.mit.edu/users/{self.username}/messages/count/?cachebust={random.randint(0,10000)}",
337
+ headers={
338
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.3c6 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36",
339
+ },
340
+ ).text
341
+ )["count"]
214
342
 
215
343
  def featured_data(self):
216
344
  """
@@ -218,35 +346,69 @@ class User(BaseSiteComponent):
218
346
  dict: Gets info on the user's featured project and featured label (like "Featured project", "My favorite things", etc.)
219
347
  """
220
348
  try:
221
- response = json.loads(requests.get(f"https://scratch.mit.edu/site-api/users/all/{self.username}/").text)
349
+ response = requests.get(
350
+ f"https://scratch.mit.edu/site-api/users/all/{self.username}/"
351
+ ).json()
222
352
  return {
223
- "label":response["featured_project_label_name"],
224
- "project":
225
- dict(
226
- id=str(response["featured_project_data"]["id"]),
227
- author=response["featured_project_data"]["creator"],
228
- thumbnail_url="https://"+response["featured_project_data"]["thumbnail_url"][2:],
229
- title=response["featured_project_data"]["title"]
230
- )
231
- }
353
+ "label": response["featured_project_label_name"],
354
+ "project": dict(
355
+ id=str(response["featured_project_data"]["id"]),
356
+ author=response["featured_project_data"]["creator"],
357
+ thumbnail_url="https://"
358
+ + response["featured_project_data"]["thumbnail_url"][2:],
359
+ title=response["featured_project_data"]["title"],
360
+ ),
361
+ }
232
362
  except Exception:
233
363
  return None
234
364
 
365
+ def unfollowers(self) -> list[User]:
366
+ """
367
+ Get all unfollowers by comparing API response and HTML response.
368
+ NOTE: This method can take a long time to run.
369
+
370
+ Based on https://juegostrower.github.io/unfollowers/
371
+ """
372
+ follower_count = self.follower_count()
373
+
374
+ # regular followers
375
+ usernames = []
376
+ for i in range(1, 2 + follower_count // 60):
377
+ with requests.no_error_handling():
378
+ resp = requests.get(
379
+ f"https://scratch.mit.edu/users/{self.username}/followers/",
380
+ params={"page": i},
381
+ )
382
+ soup = BeautifulSoup(resp.text, "html.parser")
383
+ usernames.extend(span.text.strip() for span in soup.select("span.title"))
384
+
385
+ # api response contains all-time followers, including deleted and unfollowed
386
+ unfollowers = []
387
+ for offset in range(0, follower_count, 40):
388
+ unfollowers.extend(
389
+ user
390
+ for user in self.followers(offset=offset, limit=40)
391
+ if user.username not in usernames
392
+ )
393
+
394
+ return unfollowers
395
+
396
+ def unfollower_usernames(self) -> list[str]:
397
+ return [user.username for user in self.unfollowers()]
398
+
235
399
  def follower_count(self):
236
- # follower count
237
400
  with requests.no_error_handling():
238
401
  text = requests.get(
239
402
  f"https://scratch.mit.edu/users/{self.username}/followers/",
240
- headers = self._headers
403
+ headers=self._headers,
241
404
  ).text
242
405
  return commons.webscrape_count(text, "Followers (", ")")
243
406
 
244
407
  def following_count(self):
245
- # following count
246
408
  with requests.no_error_handling():
247
409
  text = requests.get(
248
410
  f"https://scratch.mit.edu/users/{self.username}/following/",
249
- headers = self._headers
411
+ headers=self._headers,
250
412
  ).text
251
413
  return commons.webscrape_count(text, "Following (", ")")
252
414
 
@@ -256,7 +418,10 @@ class User(BaseSiteComponent):
256
418
  list<scratchattach.user.User>: The user's followers as list of scratchattach.user.User objects
257
419
  """
258
420
  response = commons.api_iterative(
259
- f"https://api.scratch.mit.edu/users/{self.username}/followers/", limit=limit, offset=offset)
421
+ f"https://api.scratch.mit.edu/users/{self.username}/followers/",
422
+ limit=limit,
423
+ offset=offset,
424
+ )
260
425
  return commons.parse_object_list(response, User, self._session, "username")
261
426
 
262
427
  def follower_names(self, *, limit=40, offset=0):
@@ -272,7 +437,10 @@ class User(BaseSiteComponent):
272
437
  list<scratchattach.user.User>: The users that the user is following as list of scratchattach.user.User objects
273
438
  """
274
439
  response = commons.api_iterative(
275
- f"https://api.scratch.mit.edu/users/{self.username}/following/", limit=limit, offset=offset)
440
+ f"https://api.scratch.mit.edu/users/{self.username}/following/",
441
+ limit=limit,
442
+ offset=offset,
443
+ )
276
444
  return commons.parse_object_list(response, User, self._session, "username")
277
445
 
278
446
  def following_names(self, *, limit=40, offset=0):
@@ -330,7 +498,7 @@ class User(BaseSiteComponent):
330
498
  with requests.no_error_handling():
331
499
  text = requests.get(
332
500
  f"https://scratch.mit.edu/users/{self.username}/projects/",
333
- headers = self._headers
501
+ headers=self._headers,
334
502
  ).text
335
503
  return commons.webscrape_count(text, "Shared Projects (", ")")
336
504
 
@@ -338,7 +506,7 @@ class User(BaseSiteComponent):
338
506
  with requests.no_error_handling():
339
507
  text = requests.get(
340
508
  f"https://scratch.mit.edu/users/{self.username}/studios/",
341
- headers = self._headers
509
+ headers=self._headers,
342
510
  ).text
343
511
  return commons.webscrape_count(text, "Studios I Curate (", ")")
344
512
 
@@ -346,16 +514,19 @@ class User(BaseSiteComponent):
346
514
  with requests.no_error_handling():
347
515
  text = requests.get(
348
516
  f"https://scratch.mit.edu/users/{self.username}/studios_following/",
349
- headers = self._headers
517
+ headers=self._headers,
350
518
  ).text
351
519
  return commons.webscrape_count(text, "Studios I Follow (", ")")
352
520
 
353
- def studios(self, *, limit=40, offset=0):
521
+ def studios(self, *, limit=40, offset=0) -> list[studio.Studio]:
354
522
  _studios = commons.api_iterative(
355
- f"https://api.scratch.mit.edu/users/{self.username}/studios/curate", limit=limit, offset=offset)
523
+ f"https://api.scratch.mit.edu/users/{self.username}/studios/curate",
524
+ limit=limit,
525
+ offset=offset,
526
+ )
356
527
  studios = []
357
528
  for studio_dict in _studios:
358
- _studio = studio.Studio(_session = self._session, id = studio_dict["id"])
529
+ _studio = studio.Studio(_session=self._session, id=studio_dict["id"])
359
530
  _studio._update_from_dict(studio_dict)
360
531
  studios.append(_studio)
361
532
  return studios
@@ -366,12 +537,18 @@ class User(BaseSiteComponent):
366
537
  list<projects.projects.Project>: The user's shared projects
367
538
  """
368
539
  _projects = commons.api_iterative(
369
- f"https://api.scratch.mit.edu/users/{self.username}/projects/", limit=limit, offset=offset, _headers= self._headers)
540
+ f"https://api.scratch.mit.edu/users/{self.username}/projects/",
541
+ limit=limit,
542
+ offset=offset,
543
+ _headers=self._headers,
544
+ )
370
545
  for p in _projects:
371
- p["author"] = {"username":self.username}
546
+ p["author"] = {"username": self.username}
372
547
  return commons.parse_object_list(_projects, project.Project, self._session)
373
548
 
374
- def loves(self, *, limit=40, offset=0, get_full_project: bool = False) -> list[project.Project]:
549
+ def loves(
550
+ self, *, limit=40, offset=0, get_full_project: bool = False
551
+ ) -> list[project.Project]:
375
552
  """
376
553
  Returns:
377
554
  list<projects.projects.Project>: The user's loved projects
@@ -391,8 +568,7 @@ class User(BaseSiteComponent):
391
568
  # So the page number for this is 1 + (offset + limit - 1) // 40
392
569
 
393
570
  # But this is a range so we have to add another 1 for the second argument
394
- pages = range(1 + offset // 40,
395
- 2 + (offset + limit - 1) // 40)
571
+ pages = range(1 + offset // 40, 2 + (offset + limit - 1) // 40)
396
572
  _projects = []
397
573
 
398
574
  for page in pages:
@@ -400,13 +576,13 @@ class User(BaseSiteComponent):
400
576
  first_idx = (page - 1) * 40
401
577
 
402
578
  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
579
+ page_content = requests.get(
580
+ f"https://scratch.mit.edu/projects/all/{self.username}/loves/"
581
+ f"?page={page}",
582
+ headers=self._headers,
583
+ ).content
405
584
 
406
- soup = BeautifulSoup(
407
- page_content,
408
- "html.parser"
409
- )
585
+ soup = BeautifulSoup(page_content, "html.parser")
410
586
 
411
587
  # We need to check if we are out of bounds
412
588
  # If we are, we can jump out early
@@ -424,7 +600,8 @@ class User(BaseSiteComponent):
424
600
 
425
601
  # Each project element is a list item with the class name 'project thumb item' so we can just use that
426
602
  for i, project_element in enumerate(
427
- soup.find_all("li", {"class": "project thumb item"})):
603
+ soup.find_all("li", {"class": "project thumb item"})
604
+ ):
428
605
  # Remember we only want certain projects:
429
606
  # The current project idx = first_idx + i
430
607
  # We want to start at {offset} and end at {offset + limit}
@@ -451,26 +628,27 @@ class User(BaseSiteComponent):
451
628
  assert isinstance(first_anchor, Tag)
452
629
  assert isinstance(second_anchor, Tag)
453
630
  assert isinstance(third_anchor, Tag)
454
- project_id = commons.webscrape_count(first_anchor.attrs["href"],
455
- "/projects/", "/")
631
+ project_id = commons.webscrape_count(
632
+ first_anchor.attrs["href"], "/projects/", "/"
633
+ )
456
634
  title = second_anchor.contents[0]
457
635
  author = third_anchor.contents[0]
458
636
 
459
637
  # Instantiating a project with the properties that we know
460
638
  # This may cause issues (see below)
461
- _project = project.Project(id=project_id,
462
- _session=self._session,
463
- title=title,
464
- author_name=author,
465
- url=f"https://scratch.mit.edu/projects/{project_id}/")
639
+ _project = project.Project(
640
+ id=project_id,
641
+ _session=self._session,
642
+ title=title,
643
+ author_name=author,
644
+ url=f"https://scratch.mit.edu/projects/{project_id}/",
645
+ )
466
646
  if get_full_project:
467
647
  # Put this under an if statement since making api requests for every single
468
648
  # project will cause the function to take a lot longer
469
649
  _project.update()
470
650
 
471
- _projects.append(
472
- _project
473
- )
651
+ _projects.append(_project)
474
652
 
475
653
  return _projects
476
654
 
@@ -478,7 +656,7 @@ class User(BaseSiteComponent):
478
656
  with requests.no_error_handling():
479
657
  text = requests.get(
480
658
  f"https://scratch.mit.edu/projects/all/{self.username}/loves/",
481
- headers=self._headers
659
+ headers=self._headers,
482
660
  ).text
483
661
 
484
662
  # If there are no loved projects, then Scratch doesn't actually display the number - so we have to catch this
@@ -496,25 +674,47 @@ class User(BaseSiteComponent):
496
674
  list<projects.projects.Project>: The user's favorite projects
497
675
  """
498
676
  _projects = commons.api_iterative(
499
- f"https://api.scratch.mit.edu/users/{self.username}/favorites/", limit=limit, offset=offset, _headers= self._headers)
677
+ f"https://api.scratch.mit.edu/users/{self.username}/favorites/",
678
+ limit=limit,
679
+ offset=offset,
680
+ _headers=self._headers,
681
+ )
500
682
  return commons.parse_object_list(_projects, project.Project, self._session)
501
683
 
502
684
  def favorites_count(self):
503
685
  with requests.no_error_handling():
504
686
  text = requests.get(
505
687
  f"https://scratch.mit.edu/users/{self.username}/favorites/",
506
- headers=self._headers
688
+ headers=self._headers,
507
689
  ).text
508
690
  return commons.webscrape_count(text, "Favorites (", ")")
509
691
 
692
+ def has_badge(self) -> bool:
693
+ """
694
+ Returns:
695
+ bool: Whether the user has a scratch membership badge on their profile (located next to the follow button)
696
+ """
697
+ with requests.no_error_handling():
698
+ resp = requests.get(self.url)
699
+ soup = BeautifulSoup(resp.text, "html.parser")
700
+ head = soup.find("div", {"class": "box-head"})
701
+ if not head:
702
+ return False
703
+ for child in head.children:
704
+ if child.name == "img":
705
+ if "membership-badge.svg" in child["src"]:
706
+ return True
707
+ return False
708
+
510
709
  def toggle_commenting(self):
511
710
  """
512
711
  You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
513
712
  """
514
713
  self._assert_permission()
515
- requests.post(f"https://scratch.mit.edu/site-api/comments/user/{self.username}/toggle-comments/",
516
- headers = headers,
517
- cookies = self._cookies
714
+ requests.post(
715
+ f"https://scratch.mit.edu/site-api/comments/user/{self.username}/toggle-comments/",
716
+ headers=headers,
717
+ cookies=self._cookies,
518
718
  )
519
719
 
520
720
  def viewed_projects(self, limit=24, offset=0):
@@ -526,7 +726,11 @@ class User(BaseSiteComponent):
526
726
  """
527
727
  self._assert_permission()
528
728
  _projects = commons.api_iterative(
529
- f"https://api.scratch.mit.edu/users/{self.username}/projects/recentlyviewed", limit=limit, offset=offset, _headers= self._headers)
729
+ f"https://api.scratch.mit.edu/users/{self.username}/projects/recentlyviewed",
730
+ limit=limit,
731
+ offset=offset,
732
+ _headers=self._headers,
733
+ )
530
734
  return commons.parse_object_list(_projects, project.Project, self._session)
531
735
 
532
736
  def set_pfp(self, image: bytes):
@@ -539,7 +743,8 @@ class User(BaseSiteComponent):
539
743
  f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
540
744
  headers=self._headers,
541
745
  cookies=self._cookies,
542
- files={"file": image})
746
+ files={"file": image},
747
+ )
543
748
 
544
749
  def set_bio(self, text):
545
750
  """
@@ -551,7 +756,8 @@ class User(BaseSiteComponent):
551
756
  f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
552
757
  headers=self._json_headers,
553
758
  cookies=self._cookies,
554
- json={"bio": text})
759
+ json={"bio": text},
760
+ )
555
761
 
556
762
  def set_wiwo(self, text):
557
763
  """
@@ -563,7 +769,8 @@ class User(BaseSiteComponent):
563
769
  f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
564
770
  headers=self._json_headers,
565
771
  cookies=self._cookies,
566
- json={"status": text})
772
+ json={"status": text},
773
+ )
567
774
 
568
775
  def set_featured(self, project_id, *, label=""):
569
776
  """
@@ -580,7 +787,7 @@ class User(BaseSiteComponent):
580
787
  f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
581
788
  headers=self._json_headers,
582
789
  cookies=self._cookies,
583
- json={"featured_project": int(project_id), "featured_project_label": label}
790
+ json={"featured_project": int(project_id), "featured_project_label": label},
584
791
  )
585
792
 
586
793
  def set_forum_signature(self, text):
@@ -589,38 +796,43 @@ class User(BaseSiteComponent):
589
796
  """
590
797
  self._assert_permission()
591
798
  headers = {
592
- 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
593
- 'content-type': 'application/x-www-form-urlencoded',
594
- 'origin': 'https://scratch.mit.edu',
595
- 'referer': 'https://scratch.mit.edu/discuss/settings/TimMcCool/',
596
- 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
799
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
800
+ "content-type": "application/x-www-form-urlencoded",
801
+ "origin": "https://scratch.mit.edu",
802
+ "referer": "https://scratch.mit.edu/discuss/settings/TimMcCool/",
803
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
597
804
  }
598
805
  data = {
599
- 'csrfmiddlewaretoken': 'a',
600
- 'signature': text,
601
- 'update': '',
806
+ "csrfmiddlewaretoken": "a",
807
+ "signature": text,
808
+ "update": "",
602
809
  }
603
- response = requests.post(f'https://scratch.mit.edu/discuss/settings/{self.username}/', cookies=self._cookies, headers=headers, data=data)
810
+ response = requests.post(
811
+ f"https://scratch.mit.edu/discuss/settings/{self.username}/",
812
+ cookies=self._cookies,
813
+ headers=headers,
814
+ data=data,
815
+ )
604
816
 
605
817
  def post_comment(self, content, *, parent_id="", commentee_id=""):
606
818
  """
607
819
  Posts a comment on the user's profile. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
608
820
 
609
821
  Args:
610
- content: Content of the comment that should be posted
822
+ :param content: Content of the comment that should be posted
611
823
 
612
824
  Keyword Arguments:
613
- parent_id: ID of the comment you want to reply to. If you don't want to mention a user, don't put the argument.
614
- commentee_id: ID of the user that will be mentioned in your comment and will receive a message about your comment. If you don't want to mention a user, don't put the argument.
825
+ :param commentee_id: ID of the comment you want to reply to. If you don't want to mention a user, don't put the argument.
826
+ :param parent_id: ID of the user that will be mentioned in your comment and will receive a message about your comment. If you don't want to mention a user, don't put the argument.
615
827
 
616
828
  Returns:
617
829
  scratchattach.comment.Comment: An object representing the created comment.
618
830
  """
619
831
  self._assert_auth()
620
832
  data = {
621
- "commentee_id": commentee_id,
622
- "content": str(content),
623
- "parent_id": parent_id,
833
+ "commentee_id": commentee_id,
834
+ "content": str(content),
835
+ "parent_id": parent_id,
624
836
  }
625
837
  r = requests.post(
626
838
  f"https://scratch.mit.edu/site-api/comments/user/{self.username}/add/",
@@ -634,30 +846,55 @@ class User(BaseSiteComponent):
634
846
  else:
635
847
  raise exceptions.CommentPostFailure(r.text)
636
848
 
849
+ text = r.text
637
850
  try:
638
- text = r.text
639
851
  data = {
640
- 'id': text.split('<div id="comments-')[1].split('" class="comment')[0],
641
- 'author': {"username": text.split('" data-comment-user="')[1].split('"><img class')[0]},
642
- 'content': text.split('<div class="content">')[1].split('</div>')[0].strip(),
643
- 'reply_count': 0,
644
- 'cached_replies': []
852
+ "id": text.split('<div id="comments-')[1].split('" class="comment')[0],
853
+ "author": {
854
+ "username": text.split('" data-comment-user="')[1].split(
855
+ '"><img class'
856
+ )[0]
857
+ },
858
+ "content": text.split('<div class="content">')[1]
859
+ .split("</div>")[0]
860
+ .strip(),
861
+ "reply_count": 0,
862
+ "cached_replies": [],
645
863
  }
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())
864
+ _comment = comment.Comment(
865
+ source=comment.CommentSource.USER_PROFILE,
866
+ parent_id=None if parent_id == "" else parent_id,
867
+ commentee_id=commentee_id,
868
+ source_id=self.username,
869
+ id=data["id"],
870
+ _session=self._session,
871
+ datetime=datetime.now(),
872
+ )
647
873
  _comment._update_from_dict(data)
648
874
  return _comment
649
- except Exception:
875
+ except Exception as e:
650
876
  if '{"error": "isFlood"}' in text:
651
- raise(exceptions.CommentPostFailure(
652
- "You are being rate-limited for running this operation too often. Implement a cooldown of about 10 seconds."))
877
+ raise (
878
+ exceptions.CommentPostFailure(
879
+ "You are being rate-limited for running this operation too often. Implement a cooldown of about 10 seconds."
880
+ )
881
+ ) from e
653
882
  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]
883
+ raw_error_data = text.split(
884
+ '<script id="error-data" type="application/json">'
885
+ )[1].split("</script>")[0]
655
886
  error_data = json.loads(raw_error_data)
656
- expires = error_data['mute_status']['muteExpiresAt']
887
+ expires = error_data["mute_status"]["muteExpiresAt"]
657
888
  expires = datetime.fromtimestamp(expires, timezone.utc)
658
- raise(exceptions.CommentPostFailure(f"You have been muted. Mute expires on {expires}"))
889
+ raise (
890
+ exceptions.CommentPostFailure(
891
+ f"You have been muted. Mute expires on {expires}"
892
+ )
893
+ ) from e
659
894
  else:
660
- raise(exceptions.FetchError(f"Couldn't parse API response: {r.text!r}"))
895
+ raise (
896
+ exceptions.FetchError(f"Couldn't parse API response: {r.text!r}")
897
+ ) from e
661
898
 
662
899
  def reply_comment(self, content, *, parent_id, commentee_id=""):
663
900
  """
@@ -669,13 +906,15 @@ class User(BaseSiteComponent):
669
906
  Therefore, parent_id should be the comment id of a top level comment.
670
907
 
671
908
  Args:
672
- content: Content of the comment that should be posted
909
+ :param content: Content of the comment that should be posted
673
910
 
674
911
  Keyword Arguments:
675
- parent_id: ID of the comment you want to reply to
676
- commentee_id: ID of the user that will be mentioned in your comment and will receive a message about your comment. If you don't want to mention a user, don't put the argument.
912
+ :param parent_id: ID of the comment you want to reply to
913
+ :param commentee_id: ID of the user that will be mentioned in your comment and will receive a message about your comment. If you don't want to mention a user, don't put the argument.
677
914
  """
678
- return self.post_comment(content, parent_id=parent_id, commentee_id=commentee_id)
915
+ return self.post_comment(
916
+ content, parent_id=parent_id, commentee_id=commentee_id
917
+ )
679
918
 
680
919
  def activity(self, *, limit=1000):
681
920
  """
@@ -683,27 +922,32 @@ class User(BaseSiteComponent):
683
922
  list<scratchattach.Activity>: The user's activity data as parsed list of scratchattach.activity.Activity objects
684
923
  """
685
924
  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')
925
+ soup = BeautifulSoup(
926
+ requests.get(
927
+ f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}"
928
+ ).text,
929
+ "html.parser",
930
+ )
687
931
 
688
932
  activities = []
689
933
  source = soup.find_all("li")
690
934
 
691
935
  for data in source:
692
- _activity = activity.Activity(_session = self._session, raw=data)
936
+ _activity = activity.Activity(_session=self._session, raw=data)
693
937
  _activity._update_from_html(data)
694
938
  activities.append(_activity)
695
939
 
696
940
  return activities
697
941
 
698
-
699
942
  def activity_html(self, *, limit=1000):
700
943
  """
701
944
  Returns:
702
945
  str: The raw user activity HTML data
703
946
  """
704
947
  with requests.no_error_handling():
705
- return requests.get(f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}").text
706
-
948
+ return requests.get(
949
+ f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}"
950
+ ).text
707
951
 
708
952
  def follow(self):
709
953
  """
@@ -712,8 +956,8 @@ class User(BaseSiteComponent):
712
956
  self._assert_auth()
713
957
  requests.put(
714
958
  f"https://scratch.mit.edu/site-api/users/followers/{self.username}/add/?usernames={self._session._username}",
715
- headers = headers,
716
- cookies = self._cookies,
959
+ headers=headers,
960
+ cookies=self._cookies,
717
961
  )
718
962
 
719
963
  def unfollow(self):
@@ -723,8 +967,8 @@ class User(BaseSiteComponent):
723
967
  self._assert_auth()
724
968
  requests.put(
725
969
  f"https://scratch.mit.edu/site-api/users/followers/{self.username}/remove/?usernames={self._session._username}",
726
- headers = headers,
727
- cookies = self._cookies,
970
+ headers=headers,
971
+ cookies=self._cookies,
728
972
  )
729
973
 
730
974
  def delete_comment(self, *, comment_id):
@@ -735,12 +979,13 @@ class User(BaseSiteComponent):
735
979
  comment_id: The id of the comment that should be deleted
736
980
  """
737
981
  self._assert_permission()
738
- return requests.post(
739
- f"https://scratch.mit.edu/site-api/comments/user/{self.username}/del/",
740
- headers = headers,
741
- cookies = self._cookies,
742
- data = json.dumps({"id":str(comment_id)})
743
- )
982
+ with requests.no_error_handling():
983
+ return requests.post(
984
+ f"https://scratch.mit.edu/site-api/comments/user/{self.username}/del/",
985
+ headers=headers,
986
+ cookies=self._cookies,
987
+ data=json.dumps({"id": str(comment_id)}),
988
+ )
744
989
 
745
990
  def report_comment(self, *, comment_id):
746
991
  """
@@ -752,12 +997,12 @@ class User(BaseSiteComponent):
752
997
  self._assert_auth()
753
998
  return requests.post(
754
999
  f"https://scratch.mit.edu/site-api/comments/user/{self.username}/rep/",
755
- headers = headers,
756
- cookies = self._cookies,
757
- data = json.dumps({"id":str(comment_id)})
1000
+ headers=headers,
1001
+ cookies=self._cookies,
1002
+ data=json.dumps({"id": str(comment_id)}),
758
1003
  )
759
1004
 
760
- def comments(self, *, page=1, limit=None):
1005
+ def comments(self, *, page=1, limit=None) -> list[comment.Comment]:
761
1006
  """
762
1007
  Returns the comments posted on the user's profile (with replies).
763
1008
 
@@ -779,21 +1024,25 @@ class User(BaseSiteComponent):
779
1024
  _comments = soup.find_all("li", {"class": "top-level-reply"})
780
1025
 
781
1026
  if len(_comments) == 0:
782
- return None
1027
+ return []
783
1028
 
784
1029
  for entity in _comments:
785
- comment_id = entity.find("div", {"class": "comment"})['data-comment-id']
786
- user = entity.find("a", {"id": "comment-user"})['data-comment-user']
1030
+ comment_id = entity.find("div", {"class": "comment"})["data-comment-id"]
1031
+ user = entity.find("a", {"id": "comment-user"})["data-comment-user"]
787
1032
  content = str(entity.find("div", {"class": "content"}).text).strip()
788
- time = entity.find("span", {"class": "time"})['title']
1033
+ time = entity.find("span", {"class": "time"})["title"]
789
1034
 
790
1035
  main_comment = {
791
- 'id': comment_id,
792
- 'author': {"username":user},
793
- 'content': content,
794
- 'datetime_created': time,
1036
+ "id": comment_id,
1037
+ "author": {"username": user},
1038
+ "content": content,
1039
+ "datetime_created": time,
795
1040
  }
796
- _comment = comment.Comment(source="profile", source_id=self.username, _session = self._session)
1041
+ _comment = comment.Comment(
1042
+ source=comment.CommentSource.USER_PROFILE,
1043
+ source_id=self.username,
1044
+ _session=self._session,
1045
+ )
797
1046
  _comment._update_from_dict(main_comment)
798
1047
 
799
1048
  ALL_REPLIES = []
@@ -803,20 +1052,31 @@ class User(BaseSiteComponent):
803
1052
  else:
804
1053
  hasReplies = False
805
1054
  for reply in replies:
806
- r_comment_id = reply.find("div", {"class": "comment"})['data-comment-id']
807
- r_user = reply.find("a", {"id": "comment-user"})['data-comment-user']
808
- r_content = str(reply.find("div", {"class": "content"}).text).strip().replace("\n", "").replace(
809
- " ", " ")
810
- r_time = reply.find("span", {"class": "time"})['title']
1055
+ r_comment_id = reply.find("div", {"class": "comment"})[
1056
+ "data-comment-id"
1057
+ ]
1058
+ r_user = reply.find("a", {"id": "comment-user"})["data-comment-user"]
1059
+ r_content = (
1060
+ str(reply.find("div", {"class": "content"}).text)
1061
+ .strip()
1062
+ .replace("\n", "")
1063
+ .replace(" ", " ")
1064
+ )
1065
+ r_time = reply.find("span", {"class": "time"})["title"]
811
1066
  reply_data = {
812
- 'id': r_comment_id,
813
- 'author':{'username': r_user},
814
- 'content': r_content,
815
- 'datetime_created': r_time,
816
- "parent_id" : comment_id,
817
- "cached_parent_comment" : _comment,
1067
+ "id": r_comment_id,
1068
+ "author": {"username": r_user},
1069
+ "content": r_content,
1070
+ "datetime_created": r_time,
1071
+ "parent_id": comment_id,
1072
+ "cached_parent_comment": _comment,
818
1073
  }
819
- _r_comment = comment.Comment(source="profile", source_id=self.username, _session = self._session, cached_parent_comment=_comment)
1074
+ _r_comment = comment.Comment(
1075
+ source=comment.CommentSource.USER_PROFILE,
1076
+ source_id=self.username,
1077
+ _session=self._session,
1078
+ cached_parent_comment=_comment,
1079
+ )
820
1080
  _r_comment._update_from_dict(reply_data)
821
1081
  ALL_REPLIES.append(_r_comment)
822
1082
 
@@ -840,9 +1100,14 @@ class User(BaseSiteComponent):
840
1100
  page = 1
841
1101
  page_content = self.comments(page=page)
842
1102
  while page_content != []:
843
- results = list(filter(lambda x : str(x.id) == str(comment_id), page_content))
1103
+ results = list(filter(lambda x: str(x.id) == str(comment_id), page_content))
844
1104
  if results == []:
845
- results = list(filter(lambda x : str(x.id) == str(comment_id), [item for x in page_content for item in x.cached_replies]))
1105
+ results = list(
1106
+ filter(
1107
+ lambda x: str(x.id) == str(comment_id),
1108
+ [item for x in page_content for item in x.cached_replies],
1109
+ )
1110
+ )
846
1111
  if results != []:
847
1112
  return results[0]
848
1113
  else:
@@ -854,6 +1119,7 @@ class User(BaseSiteComponent):
854
1119
  def message_events(self):
855
1120
  return message_events.MessageEvents(self)
856
1121
 
1122
+ @deprecated("This method is deprecated because ScratchDB is down indefinitely.")
857
1123
  def stats(self):
858
1124
  """
859
1125
  Gets information about the user's stats. Fetched from ScratchDB.
@@ -864,16 +1130,25 @@ class User(BaseSiteComponent):
864
1130
  Returns:
865
1131
  dict: A dict containing the user's stats. If the stats aren't available, all values will be -1.
866
1132
  """
867
- print("Warning: ScratchDB is down indefinitely, therefore this method is deprecated.")
868
1133
  try:
869
- stats= requests.get(
1134
+ stats = requests.get(
870
1135
  f"https://scratchdb.lefty.one/v3/user/info/{self.username}"
871
1136
  ).json()["statistics"]
872
1137
  stats.pop("ranks")
873
1138
  except Exception:
874
- stats = {"loves":-1,"favorites":-1,"comments":-1,"views":-1,"followers":-1,"following":-1}
1139
+ stats = {
1140
+ "loves": -1,
1141
+ "favorites": -1,
1142
+ "comments": -1,
1143
+ "views": -1,
1144
+ "followers": -1,
1145
+ "following": -1,
1146
+ }
875
1147
  return stats
876
1148
 
1149
+ @deprecated(
1150
+ "Warning: ScratchDB is down indefinitely, therefore this method is deprecated."
1151
+ )
877
1152
  def ranks(self):
878
1153
  """
879
1154
  Gets information about the user's ranks. Fetched from ScratchDB.
@@ -884,22 +1159,38 @@ class User(BaseSiteComponent):
884
1159
  Returns:
885
1160
  dict: A dict containing the user's ranks. If the ranks aren't available, all values will be -1.
886
1161
  """
887
- print("Warning: ScratchDB is down indefinitely, therefore this method is deprecated.")
888
1162
  try:
889
1163
  return requests.get(
890
1164
  f"https://scratchdb.lefty.one/v3/user/info/{self.username}"
891
1165
  ).json()["statistics"]["ranks"]
892
1166
  except Exception:
893
- return {"country":{"loves":0,"favorites":0,"comments":0,"views":0,"followers":0,"following":0},"loves":0,"favorites":0,"comments":0,"views":0,"followers":0,"following":0}
1167
+ return {
1168
+ "country": {
1169
+ "loves": 0,
1170
+ "favorites": 0,
1171
+ "comments": 0,
1172
+ "views": 0,
1173
+ "followers": 0,
1174
+ "following": 0,
1175
+ },
1176
+ "loves": 0,
1177
+ "favorites": 0,
1178
+ "comments": 0,
1179
+ "views": 0,
1180
+ "followers": 0,
1181
+ "following": 0,
1182
+ }
894
1183
 
895
- def ocular_status(self):
1184
+ def ocular_status(self) -> _OcularStatus:
896
1185
  """
897
1186
  Gets information about the user's ocular status. Ocular is a website developed by jeffalo: https://ocular.jeffalo.net/
898
1187
 
899
1188
  Returns:
900
1189
  dict
901
1190
  """
902
- return requests.get(f"https://my-ocular.jeffalo.net/api/user/{self.username}").json()
1191
+ return requests.get(
1192
+ f"https://my-ocular.jeffalo.net/api/user/{self.username}"
1193
+ ).json()
903
1194
 
904
1195
  def verify_identity(self, *, verification_project_id=395330233):
905
1196
  """
@@ -917,26 +1208,25 @@ class User(BaseSiteComponent):
917
1208
  v = Verificator(self, verification_project_id)
918
1209
  return v
919
1210
 
920
- def rank(self):
1211
+ def rank(self) -> Rank:
921
1212
  """
922
1213
  Finds the rank of the user.
1214
+ Returns a member of the Rank enum: either Rank.NEW_SCRATCHER, Rank.SCRATCHER, or Rank.SCRATCH_TEAM.
923
1215
  May replace user.scratchteam and user.is_new_scratcher in the future.
924
1216
  """
925
1217
 
926
1218
  if self.is_new_scratcher():
927
1219
  return Rank.NEW_SCRATCHER
928
- # Is New Scratcher
929
-
1220
+
930
1221
  if not self.scratchteam:
931
1222
  return Rank.SCRATCHER
932
- # Is Scratcher
933
1223
 
934
1224
  return Rank.SCRATCH_TEAM
935
- # Is Scratch Team member
936
1225
 
937
1226
 
938
1227
  # ------ #
939
1228
 
1229
+
940
1230
  def get_user(username) -> User:
941
1231
  """
942
1232
  Gets a user without logging in.
@@ -952,5 +1242,11 @@ def get_user(username) -> User:
952
1242
 
953
1243
  If you want to use these, get the user with :meth:`scratchattach.session.Session.connect_user` instead.
954
1244
  """
955
- print("Warning: For methods that require authentication, use session.connect_user instead of get_user")
1245
+ warnings.warn(
1246
+ "Warning: For methods that require authentication, use session.connect_user instead of get_user.\n"
1247
+ "To ignore this warning, use warnings.filterwarnings('ignore', category=scratchattach.UserAuthenticationWarning).\n"
1248
+ "To ignore all warnings of the type GetAuthenticationWarning, which includes this warning, use "
1249
+ "`warnings.filterwarnings('ignore', category=scratchattach.GetAuthenticationWarning)`.",
1250
+ exceptions.UserAuthenticationWarning,
1251
+ )
956
1252
  return commons._get_object("username", username, User, exceptions.UserNotFound)