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/project.py ADDED
@@ -0,0 +1,932 @@
1
+ """Project and PartialProject classes"""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import pprint
6
+ import random
7
+ import base64
8
+ import time
9
+ import warnings
10
+ import zipfile
11
+ from io import BytesIO
12
+ from typing import Callable
13
+
14
+ from typing import Any, Optional
15
+ from typing_extensions import deprecated
16
+ from . import user, comment, studio
17
+ from scratchattach.utils import exceptions
18
+ from scratchattach.utils import commons
19
+ from scratchattach.utils.commons import empty_project_json, headers
20
+ from ._base import BaseSiteComponent
21
+ # from scratchattach.other.project_json_capabilities import ProjectBody
22
+ from scratchattach import editor
23
+ from scratchattach.utils.requests import requests
24
+
25
+ CREATE_PROJECT_USES: list[float] = []
26
+
27
+
28
+ class PartialProject(BaseSiteComponent):
29
+ """
30
+ Represents an unshared Scratch project that can't be accessed.
31
+ """
32
+
33
+ def __str__(self):
34
+ return f"Unshared project with id {self.id}"
35
+
36
+ def __init__(self, **entries) -> None:
37
+
38
+ # Info on how the .update method has to fetch the data:
39
+ self.update_function: Callable = requests.get
40
+ self.update_api = f"https://api.scratch.mit.edu/projects/{entries['id']}"
41
+
42
+ # Set attributes every Project object needs to have:
43
+ self._session = None
44
+ self.project_token = None
45
+ self.id = 0
46
+ self.instructions = None
47
+ self.parent_title = None
48
+ self._moderation_status = None
49
+
50
+ # Update attributes from entries dict:
51
+ self.__dict__.update(entries)
52
+
53
+ # Headers and cookies:
54
+ if self._session is None:
55
+ self._headers = headers
56
+ self._cookies = {}
57
+ else:
58
+ self._headers = self._session._headers
59
+ self._cookies = self._session._cookies
60
+
61
+ # Headers for operations that require accept and Content-Type fields:
62
+ self._json_headers = dict(self._headers)
63
+ self._json_headers["accept"] = "application/json"
64
+ self._json_headers["Content-Type"] = "application/json"
65
+
66
+ def _update_from_dict(self, data):
67
+ try:
68
+ self.id = int(data["id"])
69
+ except KeyError:
70
+ pass
71
+ try:
72
+ self.url = "https://scratch.mit.edu/projects/" + str(self.id)
73
+ except Exception:
74
+ pass
75
+ try:
76
+ self.author_name = data["author"]["username"]
77
+ except Exception:
78
+ pass
79
+ try:
80
+ self.author_name = data["username"]
81
+ except Exception:
82
+ pass
83
+ try:
84
+ self.comments_allowed = data["comments_allowed"]
85
+ except Exception:
86
+ pass
87
+ try:
88
+ self.instructions = data["instructions"]
89
+ except Exception:
90
+ pass
91
+ try:
92
+ self.notes = data["description"]
93
+ except Exception:
94
+ pass
95
+ try:
96
+ self.created = data["history"]["created"]
97
+ except Exception:
98
+ pass
99
+ try:
100
+ self.last_modified = data["history"]["modified"]
101
+ except Exception:
102
+ pass
103
+ try:
104
+ self.share_date = data["history"]["shared"]
105
+ except Exception:
106
+ pass
107
+ try:
108
+ self.thumbnail_url = data["image"]
109
+ except Exception:
110
+ pass
111
+ try:
112
+ self.remix_parent = data["remix"]["parent"]
113
+ self.remix_root = data["remix"]["root"]
114
+ except Exception:
115
+ self.remix_parent = None
116
+ self.remix_root = None
117
+ try:
118
+ self.favorites = data["stats"]["favorites"]
119
+ except Exception:
120
+ pass
121
+ try:
122
+ self.loves = data["stats"]["loves"]
123
+ except Exception:
124
+ pass
125
+ try:
126
+ self.remix_count = data["stats"]["remixes"]
127
+ except Exception:
128
+ pass
129
+ try:
130
+ self.views = data["stats"]["views"]
131
+ except Exception:
132
+ pass
133
+ try:
134
+ self.title = data["title"]
135
+ except Exception:
136
+ pass
137
+ try:
138
+ self.project_token = data["project_token"]
139
+ except Exception:
140
+ self.project_token = None
141
+ if "code" in data: # Project is unshared -> return false
142
+ return False
143
+ return True
144
+
145
+ def __rich__(self):
146
+ from rich.panel import Panel
147
+ from rich.table import Table
148
+ from rich import box
149
+ from rich.markup import escape
150
+
151
+ url = f"[link={self.url}]{self.title}[/]"
152
+
153
+ ret = Table.grid(expand=True)
154
+ ret.add_column(ratio=1)
155
+ ret.add_column(ratio=3)
156
+
157
+ info = Table(box=box.SIMPLE)
158
+ info.add_column(url, overflow="fold")
159
+ info.add_column(f"#{self.id}", overflow="fold")
160
+
161
+ info.add_row("By", self.author_name)
162
+ info.add_row("Created", escape(self.created))
163
+ info.add_row("Shared", escape(self.share_date))
164
+ info.add_row("Modified", escape(self.last_modified))
165
+ info.add_row("Comments allowed", escape(str(self.comments_allowed)))
166
+ info.add_row("Loves", str(self.loves))
167
+ info.add_row("Faves", str(self.favorites))
168
+ info.add_row("Remixes", str(self.remix_count))
169
+ info.add_row("Views", str(self.views))
170
+
171
+ desc = Table(box=box.SIMPLE)
172
+ desc.add_row("Instructions", escape(self.instructions))
173
+ desc.add_row("Notes & Credits", escape(self.notes))
174
+
175
+ ret.add_row(Panel(info, title=url), Panel(desc, title="Description"))
176
+
177
+ return ret
178
+
179
+ @property
180
+ def embed_url(self):
181
+ """
182
+ Returns:
183
+ the url of the embed of the project
184
+ """
185
+ return f"{self.url}/embed"
186
+
187
+ def remixes(self, *, limit=40, offset=0) -> list[Project]:
188
+ """
189
+ Returns:
190
+ list<scratchattach.project.Project>: A list containing the remixes of the project, each project is represented by a Project object.
191
+ """
192
+ response = commons.api_iterative(
193
+ f"https://api.scratch.mit.edu/projects/{self.id}/remixes", limit=limit, offset=offset)
194
+ return commons.parse_object_list(response, Project, self._session)
195
+
196
+ def is_shared(self):
197
+ """
198
+ Returns:
199
+ boolean: Returns whether the project is currently shared
200
+ """
201
+ p = get_project(self.id)
202
+ return isinstance(p, Project)
203
+
204
+ def raw_json_or_empty(self) -> dict[str, Any]:
205
+ return empty_project_json
206
+
207
+ def create_remix(self, *, title=None, project_json=None): # not working
208
+ """
209
+ Creates a project on the Scratch website.
210
+
211
+ Warning:
212
+ Don't spam this method - it WILL get you banned from Scratch.
213
+ To prevent accidental spam, a rate limit (5 projects per minute) is implemented for this function.
214
+ """
215
+ self._assert_auth()
216
+
217
+ if title is None:
218
+ if "title" in self.__dict__:
219
+ title = self.title + " remix"
220
+ else:
221
+ title = " remix"
222
+ if project_json is None:
223
+ project_json = self.raw_json_or_empty()
224
+
225
+ if len(CREATE_PROJECT_USES) < 5:
226
+ CREATE_PROJECT_USES.insert(0, time.time())
227
+ else:
228
+ if CREATE_PROJECT_USES[-1] < time.time() - 300:
229
+ CREATE_PROJECT_USES.pop()
230
+ else:
231
+ raise exceptions.BadRequest(
232
+ "Rate limit for remixing Scratch projects exceeded.\nThis rate limit is enforced by scratchattach, not by the Scratch API.\nFor security reasons, it cannot be turned off.\n\nDon't spam-create projects, it WILL get you banned.")
233
+ return
234
+ CREATE_PROJECT_USES.insert(0, time.time())
235
+
236
+ params = {
237
+ 'is_remix': '1',
238
+ 'original_id': self.id,
239
+ 'title': title,
240
+ }
241
+
242
+ response = requests.post('https://projects.scratch.mit.edu/', params=params, cookies=self._cookies,
243
+ headers=self._headers, json=project_json).json()
244
+ _project = self._session.connect_project(response["content-name"])
245
+ _project.parent_title = base64.b64decode(response['content-title']).decode('utf-8').split(' remix')[0]
246
+ return _project
247
+
248
+ def load_description(self):
249
+ """
250
+ Gets the instructions of the unshared project. Requires authentication.
251
+
252
+ Warning:
253
+ It's unclear if Scratch allows using this method. This method will create a remix of the unshared project using your account.
254
+ """
255
+ self._assert_auth()
256
+ new_project = self.create_remix(project_json=empty_project_json)
257
+ self.instructions = new_project.instructions
258
+ self.title = new_project.parent_title
259
+
260
+
261
+ class Project(PartialProject):
262
+ """
263
+ Represents a Scratch project.
264
+
265
+ Attributes:
266
+
267
+ :.id: The project id
268
+
269
+ :.url: The project url
270
+
271
+ :.title:
272
+
273
+ :.author_name: The username of the author
274
+
275
+ :.comments_allowed: boolean that is True if comments are enabled
276
+
277
+ :.instructions:
278
+
279
+ :.notes: The 'Notes and Credits' section
280
+
281
+ :.created: The date of the project creation
282
+
283
+ :.last_modified: The date when the project was modified the last time
284
+
285
+ :.share_date:
286
+
287
+ :.thumbnail_url:
288
+
289
+ :.remix_parent:
290
+
291
+ :.remix_root:
292
+
293
+ :.loves: The project's love count
294
+
295
+ :.favorites: The project's favorite count
296
+
297
+ :.remix_count: The number of remixes
298
+
299
+ :.views: The view count
300
+
301
+ :.project_token: The project token (required to access the project json)
302
+
303
+ :.update(): Updates the attributes
304
+ """
305
+
306
+ def __repr__(self):
307
+ return f"-P {self.id} ({self.title})"
308
+
309
+ def __str__(self):
310
+ return repr(self)
311
+
312
+ @property
313
+ def thumbnail(self) -> bytes:
314
+ with requests.no_error_handling():
315
+ return requests.get(self.thumbnail_url).content
316
+
317
+ def _assert_permission(self):
318
+ self._assert_auth()
319
+ if self._session._username != self.author_name:
320
+ raise exceptions.Unauthorized(
321
+ "You need to be authenticated as the profile owner to do this.")
322
+
323
+ def load_description(self):
324
+ # Overrides the load_description method that exists for unshared projects
325
+ self.update()
326
+
327
+ # -- Project contents (body/json) -- #
328
+
329
+ def download(self, *, filename=None, dir="."):
330
+ """
331
+ Downloads the project json to the given directory.
332
+
333
+ Args:
334
+ filename (str): The name that will be given to the downloaded file.
335
+ dir (str): The path of the directory the file will be saved in.
336
+ """
337
+ try:
338
+ if filename is None:
339
+ filename = str(self.id)
340
+ if not (dir.endswith("/") or dir.endswith("\\")):
341
+ dir += "/"
342
+ self.update()
343
+ response = requests.get(
344
+ f"https://projects.scratch.mit.edu/{self.id}?token={self.project_token}",
345
+ timeout=10,
346
+ )
347
+ filename = filename.removesuffix(".sb3")
348
+ with open(f"{dir}{filename}.sb3", "wb") as f:
349
+ f.write(response.content)
350
+ except Exception:
351
+ raise (
352
+ exceptions.FetchError(
353
+ "Method only works for projects created with Scratch 3"
354
+ )
355
+ )
356
+
357
+ @deprecated("Use raw_json instead")
358
+ def get_json(self) -> str:
359
+ """
360
+ Downloads the project json and returns it as a string
361
+ """
362
+ try:
363
+ self.update()
364
+ response = requests.get(
365
+ f"https://projects.scratch.mit.edu/{self.id}?token={self.project_token}",
366
+ timeout=10,
367
+ )
368
+ return response.text
369
+
370
+ except Exception:
371
+ raise (
372
+ exceptions.FetchError(
373
+ "Method only works for projects created with Scratch 3"
374
+ )
375
+ )
376
+
377
+ def body(self) -> editor.Project:
378
+ """
379
+ Method only works for project created with Scratch 3.
380
+
381
+ Returns:
382
+ scratchattach.editor.Project: The contents of the project as editor Project object
383
+ """
384
+ raw_json = self.raw_json()
385
+ return editor.Project.from_json(raw_json)
386
+
387
+ def raw_json(self):
388
+ """
389
+ Method only works for project created with Scratch 3.
390
+
391
+ Returns:
392
+ dict: The raw project JSON as decoded Python dictionary
393
+ """
394
+ try:
395
+ self.update()
396
+
397
+ except Exception as e:
398
+ raise (
399
+ exceptions.FetchError(
400
+ f"You're not authorized for accessing {self}.\nException: {e}"
401
+ )
402
+ )
403
+
404
+ with requests.no_error_handling():
405
+ resp = requests.get(
406
+ f"https://projects.scratch.mit.edu/{self.id}?token={self.project_token}",
407
+ timeout=10,
408
+ )
409
+
410
+ try:
411
+ return resp.json()
412
+ except json.JSONDecodeError:
413
+ # I am not aware of any cases where this will not be a zip file
414
+ # in the future, cache a projectbody object here and just return the json
415
+ # that is fetched from there to not waste existing asset data from this zip file
416
+
417
+ with zipfile.ZipFile(BytesIO(resp.content)) as zipf:
418
+ return json.load(zipf.open("project.json"))
419
+
420
+ def raw_json_or_empty(self):
421
+ return self.raw_json()
422
+
423
+ def creator_agent(self):
424
+ """
425
+ Method only works for project created with Scratch 3.
426
+
427
+ Returns:
428
+ str: The user agent of the browser that this project was saved with.
429
+ """
430
+ return self.raw_json()["meta"]["agent"]
431
+
432
+ def set_body(self, project_body: editor.Project):
433
+ """
434
+ Sets the project's contents You can use this to upload projects to the Scratch website.
435
+ Returns a dict with Scratch's raw JSON API response.
436
+
437
+ Args:
438
+ project_body (scratchattach.ProjectBody): A ProjectBody object containing the contents of the project
439
+ """
440
+ self._assert_permission()
441
+
442
+ return self.set_json(project_body.to_json())
443
+
444
+ def set_json(self, json_data):
445
+ """
446
+ Sets the project json. You can use this to upload projects to the Scratch website.
447
+ Returns a dict with Scratch's raw JSON API response.
448
+
449
+ Args:
450
+ json_data (dict or JSON): The new project JSON as encoded JSON object or as dict
451
+ """
452
+
453
+ self._assert_permission()
454
+
455
+ if not isinstance(json_data, dict):
456
+ json_data = json.loads(json_data)
457
+
458
+ return requests.put(
459
+ f"https://projects.scratch.mit.edu/{self.id}",
460
+ headers=self._headers,
461
+ cookies=self._cookies,
462
+ json=json_data,
463
+ ).json()
464
+
465
+ def upload_json_from(self, project_id: int | str):
466
+ """
467
+ Uploads the project json from the project with the given id to the project represented by this Project object
468
+ """
469
+ self._assert_auth()
470
+ other_project = self._session.connect_project(project_id) # type: ignore
471
+ self.set_json(other_project.raw_json())
472
+
473
+ # -- other -- #
474
+
475
+ def author(self) -> user.User:
476
+ """
477
+ Returns:
478
+ scratchattach.user.User: An object representing the Scratch user who created this project.
479
+ """
480
+ return self._make_linked_object("username", self.author_name, user.User, exceptions.UserNotFound)
481
+
482
+ def studios(self, *, limit=40, offset=0):
483
+ """
484
+ Returns:
485
+ list<scratchattach.studio.Studio>: A list containing the studios this project is in, each studio is represented by a Studio object.
486
+ """
487
+ response = commons.api_iterative(
488
+ f"https://api.scratch.mit.edu/users/{self.author_name}/projects/{self.id}/studios", limit=limit,
489
+ offset=offset, add_params=f"&cachebust={random.randint(0, 9999)}")
490
+ return commons.parse_object_list(response, studio.Studio, self._session)
491
+
492
+ def comments(self, *, limit=40, offset=0) -> list['comment.Comment']:
493
+ """
494
+ Returns the comments posted on the project (except for replies. To get replies use :meth:`scratchattach.project.Project.comment_replies`).
495
+
496
+ Keyword Arguments:
497
+ page: The page of the comments that should be returned.
498
+ limit: Max. amount of returned comments.
499
+
500
+ Returns:
501
+ list<scratchattach.comment.Comment>: A list containing the requested comments as Comment objects.
502
+ """
503
+
504
+ response = commons.api_iterative(
505
+ f"https://api.scratch.mit.edu/users/{self.author_name}/projects/{self.id}/comments/", limit=limit,
506
+ offset=offset, add_params=f"&cachebust={random.randint(0, 9999)}")
507
+ for i in response:
508
+ i["source"] = "project"
509
+ i["source_id"] = self.id
510
+ return commons.parse_object_list(response, comment.Comment, self._session)
511
+
512
+ def comment_replies(self, *, comment_id, limit=40, offset=0):
513
+ response = commons.api_iterative(
514
+ f"https://api.scratch.mit.edu/users/{self.author_name}/projects/{self.id}/comments/{comment_id}/replies/",
515
+ limit=limit, offset=offset, add_params=f"&cachebust={random.randint(0, 9999)}")
516
+ for x in response:
517
+ x["parent_id"] = comment_id
518
+ x["source"] = "project"
519
+ x["source_id"] = self.id
520
+ return commons.parse_object_list(response, comment.Comment, self._session)
521
+
522
+ def comment_by_id(self, comment_id):
523
+ """
524
+ Returns:
525
+ scratchattach.comments.Comment: A Comment object representing the requested comment.
526
+ """
527
+ # https://api.scratch.mit.edu/users/TimMcCool/projects/404369790/comments/439984518
528
+ data = requests.get(
529
+ f"https://api.scratch.mit.edu/users/{self.author_name}/projects/{self.id}/comments/{comment_id}",
530
+ headers=self._headers,
531
+ cookies=self._cookies
532
+ ).json()
533
+
534
+ if data is None or data.get("code") == "NotFound":
535
+ raise exceptions.CommentNotFound(
536
+ f"Cannot find comment #{comment_id} on -P {self.id} by -U {self.author_name}")
537
+
538
+ _comment = comment.Comment(id=data["id"], _session=self._session, source=comment.CommentSource.PROJECT,
539
+ source_id=self.id)
540
+ _comment._update_from_dict(data)
541
+ return _comment
542
+
543
+ def love(self):
544
+ """
545
+ Posts a love on the project. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
546
+ """
547
+ self._assert_auth()
548
+ r = requests.post(
549
+ f"https://api.scratch.mit.edu/proxy/projects/{self.id}/loves/user/{self._session._username}",
550
+ headers=self._headers,
551
+ cookies=self._cookies,
552
+ ).json()
553
+ if "userLove" in r:
554
+ if r["userLove"] is False:
555
+ self.love()
556
+ else:
557
+ raise exceptions.APIError(str(r))
558
+
559
+ def unlove(self):
560
+ """
561
+ Removes the love from this project. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
562
+ """
563
+ self._assert_auth()
564
+ r = requests.delete(
565
+ f"https://api.scratch.mit.edu/proxy/projects/{self.id}/loves/user/{self._session._username}",
566
+ headers=self._headers,
567
+ cookies=self._cookies,
568
+ ).json()
569
+ if "userLove" in r:
570
+ if r["userLove"] is True:
571
+ self.unlove()
572
+ else:
573
+ raise exceptions.APIError(str(r))
574
+
575
+ def favorite(self):
576
+ """
577
+ Posts a favorite on the project. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
578
+ """
579
+ self._assert_auth()
580
+ r = requests.post(
581
+ f"https://api.scratch.mit.edu/proxy/projects/{self.id}/favorites/user/{self._session._username}",
582
+ headers=self._headers,
583
+ cookies=self._cookies,
584
+ ).json()
585
+ if "userFavorite" in r:
586
+ if r["userFavorite"] is False:
587
+ self.favorite()
588
+ else:
589
+ raise exceptions.APIError(str(r))
590
+
591
+ def unfavorite(self):
592
+ """
593
+ Removes the favorite from this project. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
594
+ """
595
+ self._assert_auth()
596
+ r = requests.delete(
597
+ f"https://api.scratch.mit.edu/proxy/projects/{self.id}/favorites/user/{self._session._username}",
598
+ headers=self._headers,
599
+ cookies=self._cookies,
600
+ ).json()
601
+ if "userFavorite" in r:
602
+ if r["userFavorite"] is True:
603
+ self.unfavorite()
604
+ else:
605
+ raise exceptions.APIError(str(r))
606
+
607
+ def post_view(self):
608
+ """
609
+ Increases the project's view counter by 1. Doesn't require a login.
610
+ """
611
+ requests.post(
612
+ f"https://api.scratch.mit.edu/users/{self.author_name}/projects/{self.id}/views/",
613
+ headers=headers,
614
+ )
615
+
616
+ def set_fields(self, fields_dict, *, use_site_api=False):
617
+ """
618
+ Sets fields. By default, ueses the api.scratch.mit.edu/projects/xxx/ PUT API.
619
+
620
+ Keyword Arguments:
621
+ use_site_api (bool):
622
+ When enabled, the fields are set using the scratch.mit.edu/site-api API.
623
+ This function allows setting more fields than Project.set_fields.
624
+ For example, you can also share / unshare the project by setting the "shared" field.
625
+ According to the Scratch team, this API is deprecated. As of 2024 it's still fully functional though.
626
+ """
627
+ self._assert_permission()
628
+ if use_site_api:
629
+ r = requests.put(
630
+ f"https://scratch.mit.edu/site-api/projects/all/{self.id}",
631
+ headers=self._headers,
632
+ cookies=self._cookies,
633
+ json=fields_dict,
634
+ ).json()
635
+ else:
636
+ r = requests.put(
637
+ f"https://api.scratch.mit.edu/projects/{self.id}",
638
+ headers=self._headers,
639
+ cookies=self._cookies,
640
+ json=fields_dict,
641
+ ).json()
642
+ return self._update_from_dict(r)
643
+
644
+ def turn_off_commenting(self):
645
+ """
646
+ Disables commenting on the project. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
647
+ """
648
+ data = {"comments_allowed": False}
649
+ self.set_fields(data)
650
+
651
+ def turn_on_commenting(self):
652
+ """
653
+ Enables commenting on the project. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
654
+ """
655
+ data = {"comments_allowed": True}
656
+ self.set_fields(data)
657
+
658
+ def toggle_commenting(self):
659
+ """
660
+ Switches commenting on / off on the project (If comments are on, they will be turned off, else they will be turned on). You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
661
+ """
662
+ data = {"comments_allowed": not self.comments_allowed}
663
+ self.set_fields(data)
664
+
665
+ def share(self):
666
+ """
667
+ Shares the project. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
668
+ """
669
+ self._assert_permission()
670
+ requests.put(
671
+ f"https://api.scratch.mit.edu/proxy/projects/{self.id}/share/",
672
+ headers=self._json_headers,
673
+ cookies=self._cookies,
674
+ )
675
+
676
+ def unshare(self):
677
+ """
678
+ Unshares the project. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
679
+ """
680
+ self._assert_permission()
681
+ requests.put(
682
+ f"https://api.scratch.mit.edu/proxy/projects/{self.id}/unshare/",
683
+ headers=self._json_headers,
684
+ cookies=self._cookies,
685
+ )
686
+
687
+ ''' doesn't work. the API's response is valid (no errors), but the fields don't change
688
+ def move_to_trash(self):
689
+ """
690
+ Moves the project to trash folder. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
691
+ """
692
+ self.set_fields({"id":int(self.id), "visibility": "trshbyusr", "isPublished" : False}, use_site_api=True)'''
693
+
694
+ def set_thumbnail(self, *, file):
695
+ """
696
+ You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
697
+ """
698
+ self._assert_permission()
699
+ with open(file, "rb") as f:
700
+ thumbnail = f.read()
701
+ requests.post(
702
+ f"https://scratch.mit.edu/internalapi/project/thumbnail/{self.id}/set/",
703
+ data=thumbnail,
704
+ headers=self._headers,
705
+ cookies=self._cookies,
706
+ )
707
+
708
+ def delete_comment(self, *, comment_id):
709
+ """
710
+ Deletes a comment by its ID. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
711
+
712
+ Args:
713
+ comment_id: The id of the comment that should be deleted
714
+ """
715
+ self._assert_permission()
716
+ return requests.delete(
717
+ f"https://api.scratch.mit.edu/proxy/comments/project/{self.id}/comment/{comment_id}/",
718
+ headers=self._headers,
719
+ cookies=self._cookies,
720
+ )
721
+
722
+ def report_comment(self, *, comment_id):
723
+ """
724
+ 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_project`
725
+
726
+ Args:
727
+ comment_id: The id of the comment that should be reported
728
+ """
729
+ self._assert_auth()
730
+ return requests.delete(
731
+ f"https://api.scratch.mit.edu/proxy/comments/project/{self.id}/comment/{comment_id}/report",
732
+ headers=self._headers,
733
+ cookies=self._cookies,
734
+ )
735
+
736
+ def post_comment(self, content, *, parent_id="", commentee_id=""):
737
+ """
738
+ Posts a comment on the project. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
739
+
740
+ Args:
741
+ content: Content of the comment that should be posted
742
+
743
+ Keyword Arguments:
744
+ 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.
745
+ 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.
746
+
747
+ Returns:
748
+ scratchattach.comments.Comment: Comment object representing the posted comment.
749
+ """
750
+ self._assert_auth()
751
+ data = {
752
+ "commentee_id": commentee_id,
753
+ "content": str(content),
754
+ "parent_id": parent_id,
755
+ }
756
+ headers = dict(self._json_headers)
757
+ headers["referer"] = "https://scratch.mit.edu/projects/" + str(self.id) + "/"
758
+ r = json.loads(
759
+ requests.post(
760
+ f"https://api.scratch.mit.edu/proxy/comments/project/{self.id}/",
761
+ headers=headers,
762
+ cookies=self._cookies,
763
+ data=json.dumps(data),
764
+ ).text
765
+ )
766
+ if "id" not in r:
767
+ raise exceptions.CommentPostFailure(r)
768
+ _comment = comment.Comment(id=r["id"], _session=self._session, source=comment.CommentSource.PROJECT,
769
+ source_id=self.id)
770
+ _comment._update_from_dict(r)
771
+ return _comment
772
+
773
+ def reply_comment(self, content, *, parent_id, commentee_id=""):
774
+ """
775
+ Posts a reply to a comment on the project. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
776
+
777
+ Args:
778
+ content: Content of the comment that should be posted
779
+
780
+ Warning:
781
+ 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.
782
+
783
+ Therefore, parent_id should be the comment id of a top level comment.
784
+
785
+ Keyword Arguments:
786
+ parent_id: ID of the comment you want to reply to
787
+ commentee_id: ID of the user you are replying to
788
+ """
789
+ return self.post_comment(
790
+ content, parent_id=parent_id, commentee_id=commentee_id
791
+ )
792
+
793
+ def set_title(self, text):
794
+ """
795
+ Changes the projects title. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
796
+ """
797
+ self.set_fields({"title": text})
798
+
799
+ def set_instructions(self, text):
800
+ """
801
+ Changes the projects instructions. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
802
+ """
803
+ self.set_fields({"instructions": text})
804
+
805
+ def set_notes(self, text):
806
+ """
807
+ Changes the projects notes and credits. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_project`
808
+ """
809
+ self.set_fields({"description": text})
810
+
811
+ @deprecated("Deprecated because ScratchDB is down indefinitely.")
812
+ def ranks(self):
813
+ """
814
+ Gets information about the project's ranks. Fetched from ScratchDB.
815
+
816
+ Warning:
817
+ This method is deprecated because ScratchDB is down indefinitely.
818
+
819
+ Returns:
820
+ dict: A dict containing the project's ranks. If the ranks aren't available, all values will be -1.
821
+ """
822
+ return requests.get(
823
+ f"https://scratchdb.lefty.one/v3/project/info/{self.id}"
824
+ ).json()["statistics"]["ranks"]
825
+
826
+ def moderation_status(self, *, reload: bool = False):
827
+ """
828
+ Gets information about the project's moderation status. Fetched from jeffalo's API.
829
+
830
+ Returns:
831
+ str: The moderation status of the project.
832
+
833
+ These moderation statuses exist:
834
+
835
+ safe: The project was reviewed by the Scratch team and was considered safe for everyone.
836
+
837
+ notsafe: The project was reviewed by the Scratch team and was considered not safe for everyone (nfe). It can't appear in search results, on the explore page and on the front page.
838
+
839
+ notreviewed: The project hasn't been reviewed yet.
840
+
841
+ no_remixes: Unable to fetch the project's moderation status.
842
+ """
843
+ if self._moderation_status and not reload:
844
+ return self._moderation_status
845
+
846
+ try:
847
+ return requests.get(
848
+ f"https://jeffalo.net/api/nfe/?project={self.id}"
849
+ ).json()["status"]
850
+ except Exception:
851
+ raise exceptions.FetchError
852
+
853
+ def visibility(self):
854
+ """
855
+ Returns info about the project's visibility. Requires authentication.
856
+ """
857
+ self._assert_auth()
858
+ return requests.get(f"https://api.scratch.mit.edu/users/{self._session.username}/projects/{self.id}/visibility",
859
+ headers=self._headers, cookies=self._cookies).json()
860
+
861
+
862
+ # ------ #
863
+
864
+
865
+ def get_project(project_id) -> Project:
866
+ """
867
+ Gets a project without logging in.
868
+
869
+ Args:
870
+ project_id (int): Project id of the requested project
871
+
872
+ Returns:
873
+ scratchattach.project.Project: An object representing the requested project.
874
+
875
+ Warning:
876
+ Any methods that require authentication (like project.love) will not work on the returned object.
877
+
878
+ If you want to use these methods, get the project with :meth:`scratchattach.session.Session.connect_project` instead.
879
+ """
880
+ warnings.warn(
881
+ "For methods that require authentication, use session.connect_project instead of get_project.\n"
882
+ "If you want to remove this warning, "
883
+ "use `warnings.filterwarnings('ignore', category=scratchattach.ProjectAuthenticationWarning)`.\n"
884
+ "To ignore all warnings of the type GetAuthenticationWarning, which includes this warning, use "
885
+ "`warnings.filterwarnings('ignore', category=scratchattach.GetAuthenticationWarning)`.",
886
+ exceptions.ProjectAuthenticationWarning
887
+ )
888
+ return commons._get_object("id", project_id, Project, exceptions.ProjectNotFound)
889
+
890
+
891
+ def search_projects(*, query="", mode="trending", language="en", limit=40, offset=0):
892
+ '''
893
+ Uses the Scratch search to search projects.
894
+
895
+ Keyword arguments:
896
+ query (str): The query that will be searched.
897
+ mode (str): Has to be one of these values: "trending", "popular" or "recent". Defaults to "trending".
898
+ language (str): A language abbreviation, defaults to "en". (Depending on the language used on the Scratch website, Scratch displays you different results.)
899
+ limit (int): Max. amount of returned projects.
900
+ offset (int): Offset of the first returned project.
901
+
902
+ Returns:
903
+ list<scratchattach.project.Project>: List that contains the search results.
904
+ '''
905
+ if not query:
906
+ raise ValueError("The query can't be empty for search")
907
+ response = commons.api_iterative(
908
+ f"https://api.scratch.mit.edu/search/projects", limit=limit, offset=offset,
909
+ add_params=f"&language={language}&mode={mode}&q={query}")
910
+ return commons.parse_object_list(response, Project)
911
+
912
+
913
+ def explore_projects(*, query="*", mode="trending", language="en", limit=40, offset=0):
914
+ '''
915
+ Gets projects from the explore page.
916
+
917
+ Keyword arguments:
918
+ query (str): Specifies the tag of the explore page. To get the projects from the "All" tag, set this argument to "*".
919
+ mode (str): Has to be one of these values: "trending", "popular" or "recent". Defaults to "trending".
920
+ language (str): A language abbreviation, defaults to "en". (Depending on the language used on the Scratch website, Scratch displays you different explore pages.)
921
+ limit (int): Max. amount of returned projects.
922
+ offset (int): Offset of the first returned project.
923
+
924
+ Returns:
925
+ list<scratchattach.project.Project>: List that contains the explore page projects.
926
+ '''
927
+ if not query:
928
+ raise ValueError("The query can't be empty for search")
929
+ response = commons.api_iterative(
930
+ f"https://api.scratch.mit.edu/explore/projects", limit=limit, offset=offset,
931
+ add_params=f"&language={language}&mode={mode}&q={query}")
932
+ return commons.parse_object_list(response, Project)