scratchattach 3.0.0b0__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 (83) 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. cloud/__init__.py +2 -0
  12. cloud/_base.py +483 -0
  13. cloud/cloud.py +183 -0
  14. editor/__init__.py +22 -0
  15. editor/asset.py +265 -0
  16. editor/backpack_json.py +115 -0
  17. editor/base.py +191 -0
  18. editor/block.py +584 -0
  19. editor/blockshape.py +357 -0
  20. editor/build_defaulting.py +51 -0
  21. editor/code_translation/__init__.py +0 -0
  22. editor/code_translation/parse.py +177 -0
  23. editor/comment.py +80 -0
  24. editor/commons.py +145 -0
  25. editor/extension.py +50 -0
  26. editor/field.py +99 -0
  27. editor/inputs.py +138 -0
  28. editor/meta.py +117 -0
  29. editor/monitor.py +185 -0
  30. editor/mutation.py +381 -0
  31. editor/pallete.py +88 -0
  32. editor/prim.py +174 -0
  33. editor/project.py +381 -0
  34. editor/sprite.py +609 -0
  35. editor/twconfig.py +114 -0
  36. editor/vlb.py +134 -0
  37. eventhandlers/__init__.py +0 -0
  38. eventhandlers/_base.py +101 -0
  39. eventhandlers/cloud_events.py +130 -0
  40. eventhandlers/cloud_recorder.py +26 -0
  41. eventhandlers/cloud_requests.py +544 -0
  42. eventhandlers/cloud_server.py +249 -0
  43. eventhandlers/cloud_storage.py +135 -0
  44. eventhandlers/combine.py +30 -0
  45. eventhandlers/filterbot.py +163 -0
  46. eventhandlers/message_events.py +42 -0
  47. other/__init__.py +0 -0
  48. other/other_apis.py +598 -0
  49. other/project_json_capabilities.py +475 -0
  50. {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/METADATA +1 -1
  51. scratchattach-3.0.0b1.dist-info/RECORD +79 -0
  52. scratchattach-3.0.0b1.dist-info/top_level.txt +7 -0
  53. site/__init__.py +0 -0
  54. site/_base.py +93 -0
  55. site/activity.py +426 -0
  56. site/alert.py +226 -0
  57. site/backpack_asset.py +119 -0
  58. site/browser_cookie3_stub.py +17 -0
  59. site/browser_cookies.py +61 -0
  60. site/classroom.py +454 -0
  61. site/cloud_activity.py +121 -0
  62. site/comment.py +228 -0
  63. site/forum.py +436 -0
  64. site/placeholder.py +132 -0
  65. site/project.py +932 -0
  66. site/session.py +1323 -0
  67. site/studio.py +704 -0
  68. site/typed_dicts.py +151 -0
  69. site/user.py +1252 -0
  70. utils/__init__.py +0 -0
  71. utils/commons.py +263 -0
  72. utils/encoder.py +161 -0
  73. utils/enums.py +237 -0
  74. utils/exceptions.py +277 -0
  75. utils/optional_async.py +154 -0
  76. utils/requests.py +306 -0
  77. scratchattach/__init__.py +0 -37
  78. scratchattach/__main__.py +0 -93
  79. scratchattach-3.0.0b0.dist-info/RECORD +0 -8
  80. scratchattach-3.0.0b0.dist-info/top_level.txt +0 -1
  81. {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/WHEEL +0 -0
  82. {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/entry_points.txt +0 -0
  83. {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
site/user.py ADDED
@@ -0,0 +1,1252 @@
1
+ """User class"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import random
7
+ import re
8
+ import string
9
+ import warnings
10
+ from typing import Union, cast, Optional, TypedDict
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from enum import Enum
14
+
15
+ from typing_extensions import deprecated
16
+ from bs4 import BeautifulSoup, Tag
17
+
18
+ from ._base import BaseSiteComponent
19
+ from scratchattach.eventhandlers import message_events
20
+
21
+ from scratchattach.utils import commons
22
+ from scratchattach.utils import exceptions
23
+ from scratchattach.utils.commons import headers
24
+ from scratchattach.utils.requests import requests
25
+
26
+ from . import project
27
+ from . import studio
28
+ from . import forum
29
+ from . import comment
30
+ from . import activity
31
+ from . import classroom
32
+ from . import typed_dicts
33
+ from . import session
34
+
35
+
36
+ class Rank(Enum):
37
+ """
38
+ Possible ranks in scratch
39
+ """
40
+
41
+ NEW_SCRATCHER = 0
42
+ SCRATCHER = 1
43
+ SCRATCH_TEAM = 2
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
+
59
+ class Verificator:
60
+
61
+ def __init__(self, user: User, project_id: int):
62
+ self.project = user._make_linked_object(
63
+ "id", project_id, project.Project, exceptions.ProjectNotFound
64
+ )
65
+ self.projecturl = self.project.url
66
+ self.code = "".join(random.choices(string.ascii_letters + string.digits, k=8))
67
+ self.username = user.username
68
+
69
+ def check(self) -> bool:
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
+ )
83
+
84
+
85
+ @dataclass
86
+ class User(BaseSiteComponent[typed_dicts.UserDict]):
87
+ """
88
+ Represents a Scratch user.
89
+
90
+ Attributes:
91
+
92
+ :.join_date:
93
+
94
+ :.about_me:
95
+
96
+ :.wiwo: Returns the user's 'What I'm working on' section
97
+
98
+ :.country: Returns the country from the user profile
99
+
100
+ :.icon_url: Returns the link to the user's pfp (90x90)
101
+
102
+ :.id: Returns the id of the user
103
+
104
+ :.scratchteam: Retuns True if the user is in the Scratch team
105
+
106
+ :.update(): Updates the attributes
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)
126
+
127
+ def __str__(self):
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
146
+
147
+ def __post_init__(self):
148
+
149
+ # Info on how the .update method has to fetch the data:
150
+ self.update_function = requests.get
151
+ self.update_api = f"https://api.scratch.mit.edu/users/{self.username}"
152
+
153
+ # cache value for classroom getter method (using @property)
154
+ # first value is whether the cache has actually been set (because it can be None), second is the value itself
155
+ # self._classroom
156
+
157
+ # Headers and cookies:
158
+ if self._session is not None:
159
+ self._headers = self._session.get_headers()
160
+ self._cookies = self._session.get_cookies()
161
+
162
+ # Headers for operations that require accept and Content-Type fields:
163
+ self._json_headers = dict(self._headers)
164
+ self._json_headers["accept"] = "application/json"
165
+ self._json_headers["Content-Type"] = "application/json"
166
+
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))
183
+ return True
184
+
185
+ def _assert_permission(self):
186
+ self._assert_auth()
187
+ if self._session.username != self.username:
188
+ raise exceptions.Unauthorized(
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
251
+
252
+ @property
253
+ def classroom(self) -> classroom.Classroom | None:
254
+ """
255
+ Get a user's associated classroom, and return it as a `scratchattach.classroom.Classroom` object.
256
+ If there is no associated classroom, returns `None`
257
+ """
258
+ if not self._classroom[0]:
259
+ with requests.no_error_handling():
260
+ resp = requests.get(f"https://scratch.mit.edu/users/{self.username}/")
261
+ soup = BeautifulSoup(resp.text, "html.parser")
262
+
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
+
268
+ assert isinstance(details, Tag)
269
+
270
+ class_name, class_id, is_closed = None, None, False
271
+ for a in details.find_all("a"):
272
+ if not isinstance(a, Tag):
273
+ continue
274
+ href = str(a.get("href"))
275
+ if re.match(r"/classes/\d*/", href):
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
280
+ if is_closed:
281
+ class_name = re.sub(r"\n *\(ended\)", "", class_name).strip()
282
+
283
+ class_id = int(href.split("/")[2])
284
+ break
285
+
286
+ if class_name:
287
+ self._classroom = True, classroom.Classroom(
288
+ _session=self._session,
289
+ id=class_id or 0,
290
+ title=class_name,
291
+ is_closed=is_closed,
292
+ )
293
+ else:
294
+ self._classroom = True, None
295
+
296
+ return self._classroom[1]
297
+
298
+ def does_exist(self) -> Optional[bool]:
299
+ """
300
+ Returns:
301
+ boolean : True if the user exists, False if the user is deleted, None if an error occured
302
+ """
303
+ with requests.no_error_handling():
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
313
+
314
+ # Will maybe be deprecated later, but for now still has its own purpose.
315
+ # @deprecated("This function is partially deprecated. Use user.rank() instead.")
316
+ def is_new_scratcher(self):
317
+ """
318
+ Returns:
319
+ boolean : True if the user has the New Scratcher status, else False
320
+ """
321
+ try:
322
+ with requests.no_error_handling():
323
+ res = requests.get(
324
+ f"https://scratch.mit.edu/users/{self.username}/"
325
+ ).text
326
+ group = res[res.rindex('<span class="group">') :][:70]
327
+ return "new scratcher" in group.lower()
328
+
329
+ except Exception as e:
330
+ warnings.warn(f"Caught exception {e=}")
331
+ return None
332
+
333
+ def message_count(self):
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"]
342
+
343
+ def featured_data(self):
344
+ """
345
+ Returns:
346
+ dict: Gets info on the user's featured project and featured label (like "Featured project", "My favorite things", etc.)
347
+ """
348
+ try:
349
+ response = requests.get(
350
+ f"https://scratch.mit.edu/site-api/users/all/{self.username}/"
351
+ ).json()
352
+ return {
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
+ }
362
+ except Exception:
363
+ return None
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
+
399
+ def follower_count(self):
400
+ with requests.no_error_handling():
401
+ text = requests.get(
402
+ f"https://scratch.mit.edu/users/{self.username}/followers/",
403
+ headers=self._headers,
404
+ ).text
405
+ return commons.webscrape_count(text, "Followers (", ")")
406
+
407
+ def following_count(self):
408
+ with requests.no_error_handling():
409
+ text = requests.get(
410
+ f"https://scratch.mit.edu/users/{self.username}/following/",
411
+ headers=self._headers,
412
+ ).text
413
+ return commons.webscrape_count(text, "Following (", ")")
414
+
415
+ def followers(self, *, limit=40, offset=0):
416
+ """
417
+ Returns:
418
+ list<scratchattach.user.User>: The user's followers as list of scratchattach.user.User objects
419
+ """
420
+ response = commons.api_iterative(
421
+ f"https://api.scratch.mit.edu/users/{self.username}/followers/",
422
+ limit=limit,
423
+ offset=offset,
424
+ )
425
+ return commons.parse_object_list(response, User, self._session, "username")
426
+
427
+ def follower_names(self, *, limit=40, offset=0):
428
+ """
429
+ Returns:
430
+ list<str>: The usernames of the user's followers
431
+ """
432
+ return [i.name for i in self.followers(limit=limit, offset=offset)]
433
+
434
+ def following(self, *, limit=40, offset=0):
435
+ """
436
+ Returns:
437
+ list<scratchattach.user.User>: The users that the user is following as list of scratchattach.user.User objects
438
+ """
439
+ response = commons.api_iterative(
440
+ f"https://api.scratch.mit.edu/users/{self.username}/following/",
441
+ limit=limit,
442
+ offset=offset,
443
+ )
444
+ return commons.parse_object_list(response, User, self._session, "username")
445
+
446
+ def following_names(self, *, limit=40, offset=0):
447
+ """
448
+ Returns:
449
+ list<str>: The usernames of the users the user is following
450
+ """
451
+ return [i.name for i in self.following(limit=limit, offset=offset)]
452
+
453
+ def is_following(self, user: str):
454
+ """
455
+ Returns:
456
+ boolean: Whether the user is following the user provided as argument
457
+ """
458
+ offset = 0
459
+ following = False
460
+
461
+ while True:
462
+ try:
463
+ following_names = self.following_names(limit=20, offset=offset)
464
+ if user in following_names:
465
+ following = True
466
+ break
467
+ if not following_names:
468
+ break
469
+ offset += 20
470
+ except Exception as e:
471
+ print(f"Warning: API error when performing following check: {e=}")
472
+ return following
473
+ return following
474
+
475
+ def is_followed_by(self, user):
476
+ """
477
+ Returns:
478
+ boolean: Whether the user is followed by the user provided as argument
479
+ """
480
+ offset = 0
481
+ followed = False
482
+
483
+ while True:
484
+ try:
485
+ followed_names = self.follower_names(limit=20, offset=offset)
486
+ if user in followed_names:
487
+ followed = True
488
+ break
489
+ if not followed_names:
490
+ break
491
+ offset += 20
492
+ except Exception as e:
493
+ print(f"Warning: API error when performing following check: {e=}")
494
+ return followed
495
+ return followed
496
+
497
+ def project_count(self):
498
+ with requests.no_error_handling():
499
+ text = requests.get(
500
+ f"https://scratch.mit.edu/users/{self.username}/projects/",
501
+ headers=self._headers,
502
+ ).text
503
+ return commons.webscrape_count(text, "Shared Projects (", ")")
504
+
505
+ def studio_count(self):
506
+ with requests.no_error_handling():
507
+ text = requests.get(
508
+ f"https://scratch.mit.edu/users/{self.username}/studios/",
509
+ headers=self._headers,
510
+ ).text
511
+ return commons.webscrape_count(text, "Studios I Curate (", ")")
512
+
513
+ def studios_following_count(self):
514
+ with requests.no_error_handling():
515
+ text = requests.get(
516
+ f"https://scratch.mit.edu/users/{self.username}/studios_following/",
517
+ headers=self._headers,
518
+ ).text
519
+ return commons.webscrape_count(text, "Studios I Follow (", ")")
520
+
521
+ def studios(self, *, limit=40, offset=0) -> list[studio.Studio]:
522
+ _studios = commons.api_iterative(
523
+ f"https://api.scratch.mit.edu/users/{self.username}/studios/curate",
524
+ limit=limit,
525
+ offset=offset,
526
+ )
527
+ studios = []
528
+ for studio_dict in _studios:
529
+ _studio = studio.Studio(_session=self._session, id=studio_dict["id"])
530
+ _studio._update_from_dict(studio_dict)
531
+ studios.append(_studio)
532
+ return studios
533
+
534
+ def projects(self, *, limit=40, offset=0) -> list[project.Project]:
535
+ """
536
+ Returns:
537
+ list<projects.projects.Project>: The user's shared projects
538
+ """
539
+ _projects = commons.api_iterative(
540
+ f"https://api.scratch.mit.edu/users/{self.username}/projects/",
541
+ limit=limit,
542
+ offset=offset,
543
+ _headers=self._headers,
544
+ )
545
+ for p in _projects:
546
+ p["author"] = {"username": self.username}
547
+ return commons.parse_object_list(_projects, project.Project, self._session)
548
+
549
+ def loves(
550
+ self, *, limit=40, offset=0, get_full_project: bool = False
551
+ ) -> list[project.Project]:
552
+ """
553
+ Returns:
554
+ list<projects.projects.Project>: The user's loved projects
555
+ """
556
+ # We need to use beautifulsoup webscraping so we cant use the api_iterative function
557
+ if offset < 0:
558
+ raise exceptions.BadRequest("offset parameter must be >= 0")
559
+ if limit < 0:
560
+ raise exceptions.BadRequest("limit parameter must be >= 0")
561
+
562
+ # There are 40 projects on display per page
563
+ # So the first page you need to view is 1 + offset // 40
564
+ # (You have to add one because the first page is idx 1 instead of 0)
565
+
566
+ # The final project to view is at idx offset + limit - 1
567
+ # (You have to -1 because the index starts at 0)
568
+ # So the page number for this is 1 + (offset + limit - 1) // 40
569
+
570
+ # But this is a range so we have to add another 1 for the second argument
571
+ pages = range(1 + offset // 40, 2 + (offset + limit - 1) // 40)
572
+ _projects = []
573
+
574
+ for page in pages:
575
+ # The index of the first project on page #n is just (n-1) * 40
576
+ first_idx = (page - 1) * 40
577
+
578
+ with requests.no_error_handling():
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
584
+
585
+ soup = BeautifulSoup(page_content, "html.parser")
586
+
587
+ # We need to check if we are out of bounds
588
+ # If we are, we can jump out early
589
+ # This is detectable if Scratch gives you a '404'
590
+
591
+ # We can't just detect if the 404 text is within the whole of the page content
592
+ # because it would break if someone made a project with that name
593
+
594
+ # This page only uses <h1> tags for the 404 text, so we can just use a soup for those
595
+ h1_tag = soup.find("h1")
596
+ if h1_tag is not None:
597
+ # Just to confirm that it's a 404, in case I am wrong. It can't hurt
598
+ if "Whoops! Our server is Scratch'ing its head" in h1_tag.text:
599
+ break
600
+
601
+ # Each project element is a list item with the class name 'project thumb item' so we can just use that
602
+ for i, project_element in enumerate(
603
+ soup.find_all("li", {"class": "project thumb item"})
604
+ ):
605
+ # Remember we only want certain projects:
606
+ # The current project idx = first_idx + i
607
+ # We want to start at {offset} and end at {offset + limit}
608
+
609
+ # So the offset <= current project idx <= offset + limit
610
+ if offset <= first_idx + i <= offset + limit:
611
+ # Each of these elements provides:
612
+ # A project id
613
+ # A thumbnail link (no need to webscrape this)
614
+ # A title
615
+ # An Author (called an owner for some reason)
616
+ assert isinstance(project_element, Tag)
617
+ project_anchors = project_element.find_all("a")
618
+ # Each list item has three <a> tags, the first two linking the project
619
+ # 1st contains <img> tag
620
+ # 2nd contains project title
621
+ # 3rd links to the author & contains their username
622
+
623
+ # This function is pretty handy!
624
+ # I'll use it for an id from a string like: /projects/1070616180/
625
+ first_anchor = project_anchors[0]
626
+ second_anchor = project_anchors[1]
627
+ third_anchor = project_anchors[2]
628
+ assert isinstance(first_anchor, Tag)
629
+ assert isinstance(second_anchor, Tag)
630
+ assert isinstance(third_anchor, Tag)
631
+ project_id = commons.webscrape_count(
632
+ first_anchor.attrs["href"], "/projects/", "/"
633
+ )
634
+ title = second_anchor.contents[0]
635
+ author = third_anchor.contents[0]
636
+
637
+ # Instantiating a project with the properties that we know
638
+ # This may cause issues (see below)
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
+ )
646
+ if get_full_project:
647
+ # Put this under an if statement since making api requests for every single
648
+ # project will cause the function to take a lot longer
649
+ _project.update()
650
+
651
+ _projects.append(_project)
652
+
653
+ return _projects
654
+
655
+ def loves_count(self):
656
+ with requests.no_error_handling():
657
+ text = requests.get(
658
+ f"https://scratch.mit.edu/projects/all/{self.username}/loves/",
659
+ headers=self._headers,
660
+ ).text
661
+
662
+ # If there are no loved projects, then Scratch doesn't actually display the number - so we have to catch this
663
+ soup = BeautifulSoup(text, "html.parser")
664
+
665
+ if not soup.find("li", {"class": "project thumb item"}):
666
+ # There are no projects, so there are no projects loved
667
+ return 0
668
+
669
+ return commons.webscrape_count(text, "&raquo;\n\n (", ")")
670
+
671
+ def favorites(self, *, limit=40, offset=0):
672
+ """
673
+ Returns:
674
+ list<projects.projects.Project>: The user's favorite projects
675
+ """
676
+ _projects = commons.api_iterative(
677
+ f"https://api.scratch.mit.edu/users/{self.username}/favorites/",
678
+ limit=limit,
679
+ offset=offset,
680
+ _headers=self._headers,
681
+ )
682
+ return commons.parse_object_list(_projects, project.Project, self._session)
683
+
684
+ def favorites_count(self):
685
+ with requests.no_error_handling():
686
+ text = requests.get(
687
+ f"https://scratch.mit.edu/users/{self.username}/favorites/",
688
+ headers=self._headers,
689
+ ).text
690
+ return commons.webscrape_count(text, "Favorites (", ")")
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
+
709
+ def toggle_commenting(self):
710
+ """
711
+ You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
712
+ """
713
+ self._assert_permission()
714
+ requests.post(
715
+ f"https://scratch.mit.edu/site-api/comments/user/{self.username}/toggle-comments/",
716
+ headers=headers,
717
+ cookies=self._cookies,
718
+ )
719
+
720
+ def viewed_projects(self, limit=24, offset=0):
721
+ """
722
+ Returns:
723
+ list<projects.projects.Project>: The user's recently viewed projects
724
+
725
+ You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
726
+ """
727
+ self._assert_permission()
728
+ _projects = commons.api_iterative(
729
+ f"https://api.scratch.mit.edu/users/{self.username}/projects/recentlyviewed",
730
+ limit=limit,
731
+ offset=offset,
732
+ _headers=self._headers,
733
+ )
734
+ return commons.parse_object_list(_projects, project.Project, self._session)
735
+
736
+ def set_pfp(self, image: bytes):
737
+ """
738
+ Sets the user's profile picture. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
739
+ """
740
+ # Teachers can set pfp! - Should update this method to check for that
741
+ # self._assert_permission()
742
+ requests.post(
743
+ f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
744
+ headers=self._headers,
745
+ cookies=self._cookies,
746
+ files={"file": image},
747
+ )
748
+
749
+ def set_bio(self, text):
750
+ """
751
+ Sets the user's "About me" section. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
752
+ """
753
+ # Teachers can set bio! - Should update this method to check for that
754
+ # self._assert_permission()
755
+ requests.put(
756
+ f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
757
+ headers=self._json_headers,
758
+ cookies=self._cookies,
759
+ json={"bio": text},
760
+ )
761
+
762
+ def set_wiwo(self, text):
763
+ """
764
+ Sets the user's "What I'm working on" section. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
765
+ """
766
+ # Teachers can also change your wiwo
767
+ # self._assert_permission()
768
+ requests.put(
769
+ f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
770
+ headers=self._json_headers,
771
+ cookies=self._cookies,
772
+ json={"status": text},
773
+ )
774
+
775
+ def set_featured(self, project_id, *, label=""):
776
+ """
777
+ Sets the user's featured project. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
778
+
779
+ Args:
780
+ project_id: Project id of the project that should be set as featured
781
+
782
+ Keyword Args:
783
+ label: The label that should appear above the featured project on the user's profile (Like "Featured project", "Featured tutorial", "My favorite things", etc.)
784
+ """
785
+ self._assert_permission()
786
+ requests.put(
787
+ f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
788
+ headers=self._json_headers,
789
+ cookies=self._cookies,
790
+ json={"featured_project": int(project_id), "featured_project_label": label},
791
+ )
792
+
793
+ def set_forum_signature(self, text):
794
+ """
795
+ Sets the user's discuss forum signature. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
796
+ """
797
+ self._assert_permission()
798
+ headers = {
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",
804
+ }
805
+ data = {
806
+ "csrfmiddlewaretoken": "a",
807
+ "signature": text,
808
+ "update": "",
809
+ }
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
+ )
816
+
817
+ def post_comment(self, content, *, parent_id="", commentee_id=""):
818
+ """
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`
820
+
821
+ Args:
822
+ :param content: Content of the comment that should be posted
823
+
824
+ Keyword Arguments:
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.
827
+
828
+ Returns:
829
+ scratchattach.comment.Comment: An object representing the created comment.
830
+ """
831
+ self._assert_auth()
832
+ data = {
833
+ "commentee_id": commentee_id,
834
+ "content": str(content),
835
+ "parent_id": parent_id,
836
+ }
837
+ r = requests.post(
838
+ f"https://scratch.mit.edu/site-api/comments/user/{self.username}/add/",
839
+ headers=headers,
840
+ cookies=self._cookies,
841
+ data=json.dumps(data),
842
+ )
843
+ if r.status_code != 200:
844
+ if "Looks like we are having issues with our servers!" in r.text:
845
+ raise exceptions.BadRequest("Invalid arguments passed")
846
+ else:
847
+ raise exceptions.CommentPostFailure(r.text)
848
+
849
+ text = r.text
850
+ try:
851
+ data = {
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": [],
863
+ }
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
+ )
873
+ _comment._update_from_dict(data)
874
+ return _comment
875
+ except Exception as e:
876
+ if '{"error": "isFlood"}' in text:
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
882
+ elif '<script id="error-data" type="application/json">' in text:
883
+ raw_error_data = text.split(
884
+ '<script id="error-data" type="application/json">'
885
+ )[1].split("</script>")[0]
886
+ error_data = json.loads(raw_error_data)
887
+ expires = error_data["mute_status"]["muteExpiresAt"]
888
+ expires = datetime.fromtimestamp(expires, timezone.utc)
889
+ raise (
890
+ exceptions.CommentPostFailure(
891
+ f"You have been muted. Mute expires on {expires}"
892
+ )
893
+ ) from e
894
+ else:
895
+ raise (
896
+ exceptions.FetchError(f"Couldn't parse API response: {r.text!r}")
897
+ ) from e
898
+
899
+ def reply_comment(self, content, *, parent_id, commentee_id=""):
900
+ """
901
+ Replies to a comment given by its id
902
+
903
+ Warning:
904
+ Only replies to top-level comments are shown on the Scratch website. Replies to replies are actually replies to the corresponding top-level comment in the API.
905
+
906
+ Therefore, parent_id should be the comment id of a top level comment.
907
+
908
+ Args:
909
+ :param content: Content of the comment that should be posted
910
+
911
+ Keyword Arguments:
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.
914
+ """
915
+ return self.post_comment(
916
+ content, parent_id=parent_id, commentee_id=commentee_id
917
+ )
918
+
919
+ def activity(self, *, limit=1000):
920
+ """
921
+ Returns:
922
+ list<scratchattach.Activity>: The user's activity data as parsed list of scratchattach.activity.Activity objects
923
+ """
924
+ with requests.no_error_handling():
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
+ )
931
+
932
+ activities = []
933
+ source = soup.find_all("li")
934
+
935
+ for data in source:
936
+ _activity = activity.Activity(_session=self._session, raw=data)
937
+ _activity._update_from_html(data)
938
+ activities.append(_activity)
939
+
940
+ return activities
941
+
942
+ def activity_html(self, *, limit=1000):
943
+ """
944
+ Returns:
945
+ str: The raw user activity HTML data
946
+ """
947
+ with requests.no_error_handling():
948
+ return requests.get(
949
+ f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}"
950
+ ).text
951
+
952
+ def follow(self):
953
+ """
954
+ Follows the user represented by the User object. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
955
+ """
956
+ self._assert_auth()
957
+ requests.put(
958
+ f"https://scratch.mit.edu/site-api/users/followers/{self.username}/add/?usernames={self._session._username}",
959
+ headers=headers,
960
+ cookies=self._cookies,
961
+ )
962
+
963
+ def unfollow(self):
964
+ """
965
+ Unfollows the user represented by the User object. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
966
+ """
967
+ self._assert_auth()
968
+ requests.put(
969
+ f"https://scratch.mit.edu/site-api/users/followers/{self.username}/remove/?usernames={self._session._username}",
970
+ headers=headers,
971
+ cookies=self._cookies,
972
+ )
973
+
974
+ def delete_comment(self, *, comment_id):
975
+ """
976
+ Deletes a comment by its ID. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
977
+
978
+ Args:
979
+ comment_id: The id of the comment that should be deleted
980
+ """
981
+ self._assert_permission()
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
+ )
989
+
990
+ def report_comment(self, *, comment_id):
991
+ """
992
+ Reports a comment by its ID to the Scratch team. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
993
+
994
+ Args:
995
+ comment_id: The id of the comment that should be reported
996
+ """
997
+ self._assert_auth()
998
+ return requests.post(
999
+ f"https://scratch.mit.edu/site-api/comments/user/{self.username}/rep/",
1000
+ headers=headers,
1001
+ cookies=self._cookies,
1002
+ data=json.dumps({"id": str(comment_id)}),
1003
+ )
1004
+
1005
+ def comments(self, *, page=1, limit=None) -> list[comment.Comment]:
1006
+ """
1007
+ Returns the comments posted on the user's profile (with replies).
1008
+
1009
+ Keyword Arguments:
1010
+ page: The page of the comments that should be returned.
1011
+ limit: Max. amount of returned comments.
1012
+
1013
+ Returns:
1014
+ list<scratchattach.comment.Comment>: A list containing the requested comments as Comment objects.
1015
+ """
1016
+ URL = f"https://scratch.mit.edu/site-api/comments/user/{self.username}/?page={page}"
1017
+ DATA = []
1018
+
1019
+ with requests.no_error_handling():
1020
+ page_contents = requests.get(URL).content
1021
+
1022
+ soup = BeautifulSoup(page_contents, "html.parser")
1023
+
1024
+ _comments = soup.find_all("li", {"class": "top-level-reply"})
1025
+
1026
+ if len(_comments) == 0:
1027
+ return []
1028
+
1029
+ for entity in _comments:
1030
+ comment_id = entity.find("div", {"class": "comment"})["data-comment-id"]
1031
+ user = entity.find("a", {"id": "comment-user"})["data-comment-user"]
1032
+ content = str(entity.find("div", {"class": "content"}).text).strip()
1033
+ time = entity.find("span", {"class": "time"})["title"]
1034
+
1035
+ main_comment = {
1036
+ "id": comment_id,
1037
+ "author": {"username": user},
1038
+ "content": content,
1039
+ "datetime_created": time,
1040
+ }
1041
+ _comment = comment.Comment(
1042
+ source=comment.CommentSource.USER_PROFILE,
1043
+ source_id=self.username,
1044
+ _session=self._session,
1045
+ )
1046
+ _comment._update_from_dict(main_comment)
1047
+
1048
+ ALL_REPLIES = []
1049
+ replies = entity.find_all("li", {"class": "reply"})
1050
+ if len(replies) > 0:
1051
+ hasReplies = True
1052
+ else:
1053
+ hasReplies = False
1054
+ for reply in replies:
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"]
1066
+ reply_data = {
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,
1073
+ }
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
+ )
1080
+ _r_comment._update_from_dict(reply_data)
1081
+ ALL_REPLIES.append(_r_comment)
1082
+
1083
+ _comment.reply_count = len(ALL_REPLIES)
1084
+ _comment.cached_replies = list(ALL_REPLIES)
1085
+
1086
+ DATA.append(_comment)
1087
+ return DATA
1088
+
1089
+ def comment_by_id(self, comment_id) -> comment.Comment:
1090
+ """
1091
+ Gets a comment on this user's profile by id.
1092
+
1093
+ Warning:
1094
+ For comments very far down on the user's profile, this method will take a while to find the comment. Very old comment are deleted from Scratch's database and may not appear.
1095
+
1096
+ Returns:
1097
+ scratchattach.comments.Comment: The request comment.
1098
+ """
1099
+
1100
+ page = 1
1101
+ page_content = self.comments(page=page)
1102
+ while page_content != []:
1103
+ results = list(filter(lambda x: str(x.id) == str(comment_id), page_content))
1104
+ if results == []:
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
+ )
1111
+ if results != []:
1112
+ return results[0]
1113
+ else:
1114
+ return results[0]
1115
+ page += 1
1116
+ page_content = self.comments(page=page)
1117
+ raise exceptions.CommentNotFound()
1118
+
1119
+ def message_events(self):
1120
+ return message_events.MessageEvents(self)
1121
+
1122
+ @deprecated("This method is deprecated because ScratchDB is down indefinitely.")
1123
+ def stats(self):
1124
+ """
1125
+ Gets information about the user's stats. Fetched from ScratchDB.
1126
+
1127
+ Warning:
1128
+ ScratchDB is down indefinitely, therefore this method is deprecated.
1129
+
1130
+ Returns:
1131
+ dict: A dict containing the user's stats. If the stats aren't available, all values will be -1.
1132
+ """
1133
+ try:
1134
+ stats = requests.get(
1135
+ f"https://scratchdb.lefty.one/v3/user/info/{self.username}"
1136
+ ).json()["statistics"]
1137
+ stats.pop("ranks")
1138
+ except Exception:
1139
+ stats = {
1140
+ "loves": -1,
1141
+ "favorites": -1,
1142
+ "comments": -1,
1143
+ "views": -1,
1144
+ "followers": -1,
1145
+ "following": -1,
1146
+ }
1147
+ return stats
1148
+
1149
+ @deprecated(
1150
+ "Warning: ScratchDB is down indefinitely, therefore this method is deprecated."
1151
+ )
1152
+ def ranks(self):
1153
+ """
1154
+ Gets information about the user's ranks. Fetched from ScratchDB.
1155
+
1156
+ Warning:
1157
+ ScratchDB is down indefinitely, therefore this method is deprecated.
1158
+
1159
+ Returns:
1160
+ dict: A dict containing the user's ranks. If the ranks aren't available, all values will be -1.
1161
+ """
1162
+ try:
1163
+ return requests.get(
1164
+ f"https://scratchdb.lefty.one/v3/user/info/{self.username}"
1165
+ ).json()["statistics"]["ranks"]
1166
+ except Exception:
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
+ }
1183
+
1184
+ def ocular_status(self) -> _OcularStatus:
1185
+ """
1186
+ Gets information about the user's ocular status. Ocular is a website developed by jeffalo: https://ocular.jeffalo.net/
1187
+
1188
+ Returns:
1189
+ dict
1190
+ """
1191
+ return requests.get(
1192
+ f"https://my-ocular.jeffalo.net/api/user/{self.username}"
1193
+ ).json()
1194
+
1195
+ def verify_identity(self, *, verification_project_id=395330233):
1196
+ """
1197
+ Can be used in applications to verify a user's identity.
1198
+
1199
+ This function returns a Verifactor object. Attributs of this object:
1200
+ :.projecturl: The link to the project where the user has to go to verify
1201
+ :.project: The project where the user has to go to verify as scratchattach.Project object
1202
+ :.code: The code that the user has to comment
1203
+
1204
+ To check if the user verified successfully, call the .check() function on the returned object.
1205
+ It will return True if the user commented the code.
1206
+ """
1207
+
1208
+ v = Verificator(self, verification_project_id)
1209
+ return v
1210
+
1211
+ def rank(self) -> Rank:
1212
+ """
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.
1215
+ May replace user.scratchteam and user.is_new_scratcher in the future.
1216
+ """
1217
+
1218
+ if self.is_new_scratcher():
1219
+ return Rank.NEW_SCRATCHER
1220
+
1221
+ if not self.scratchteam:
1222
+ return Rank.SCRATCHER
1223
+
1224
+ return Rank.SCRATCH_TEAM
1225
+
1226
+
1227
+ # ------ #
1228
+
1229
+
1230
+ def get_user(username) -> User:
1231
+ """
1232
+ Gets a user without logging in.
1233
+
1234
+ Args:
1235
+ username (str): Username of the requested user
1236
+
1237
+ Returns:
1238
+ scratchattach.user.User: An object representing the requested user
1239
+
1240
+ Warning:
1241
+ Any methods that require authentication (like user.follow) will not work on the returned object.
1242
+
1243
+ If you want to use these, get the user with :meth:`scratchattach.session.Session.connect_user` instead.
1244
+ """
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
+ )
1252
+ return commons._get_object("username", username, User, exceptions.UserNotFound)