scratchattach 2.1.9__py3-none-any.whl → 2.1.10a1__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 (59) hide show
  1. scratchattach/__init__.py +28 -25
  2. scratchattach/cloud/__init__.py +2 -0
  3. scratchattach/cloud/_base.py +454 -282
  4. scratchattach/cloud/cloud.py +171 -168
  5. scratchattach/editor/__init__.py +21 -0
  6. scratchattach/editor/asset.py +199 -0
  7. scratchattach/editor/backpack_json.py +117 -0
  8. scratchattach/editor/base.py +142 -0
  9. scratchattach/editor/block.py +507 -0
  10. scratchattach/editor/blockshape.py +353 -0
  11. scratchattach/editor/build_defaulting.py +47 -0
  12. scratchattach/editor/comment.py +74 -0
  13. scratchattach/editor/commons.py +243 -0
  14. scratchattach/editor/extension.py +43 -0
  15. scratchattach/editor/field.py +90 -0
  16. scratchattach/editor/inputs.py +132 -0
  17. scratchattach/editor/meta.py +106 -0
  18. scratchattach/editor/monitor.py +175 -0
  19. scratchattach/editor/mutation.py +317 -0
  20. scratchattach/editor/pallete.py +91 -0
  21. scratchattach/editor/prim.py +170 -0
  22. scratchattach/editor/project.py +273 -0
  23. scratchattach/editor/sbuild.py +2837 -0
  24. scratchattach/editor/sprite.py +586 -0
  25. scratchattach/editor/twconfig.py +113 -0
  26. scratchattach/editor/vlb.py +134 -0
  27. scratchattach/eventhandlers/_base.py +99 -92
  28. scratchattach/eventhandlers/cloud_events.py +110 -103
  29. scratchattach/eventhandlers/cloud_recorder.py +26 -21
  30. scratchattach/eventhandlers/cloud_requests.py +460 -452
  31. scratchattach/eventhandlers/cloud_server.py +246 -244
  32. scratchattach/eventhandlers/cloud_storage.py +135 -134
  33. scratchattach/eventhandlers/combine.py +29 -27
  34. scratchattach/eventhandlers/filterbot.py +160 -159
  35. scratchattach/eventhandlers/message_events.py +41 -40
  36. scratchattach/other/other_apis.py +284 -212
  37. scratchattach/other/project_json_capabilities.py +475 -546
  38. scratchattach/site/_base.py +64 -46
  39. scratchattach/site/activity.py +414 -122
  40. scratchattach/site/backpack_asset.py +118 -84
  41. scratchattach/site/classroom.py +430 -142
  42. scratchattach/site/cloud_activity.py +107 -103
  43. scratchattach/site/comment.py +220 -190
  44. scratchattach/site/forum.py +400 -399
  45. scratchattach/site/project.py +806 -787
  46. scratchattach/site/session.py +1134 -867
  47. scratchattach/site/studio.py +611 -609
  48. scratchattach/site/user.py +835 -837
  49. scratchattach/utils/commons.py +243 -148
  50. scratchattach/utils/encoder.py +157 -156
  51. scratchattach/utils/enums.py +197 -190
  52. scratchattach/utils/exceptions.py +233 -206
  53. scratchattach/utils/requests.py +67 -59
  54. {scratchattach-2.1.9.dist-info → scratchattach-2.1.10a1.dist-info}/METADATA +155 -146
  55. scratchattach-2.1.10a1.dist-info/RECORD +62 -0
  56. {scratchattach-2.1.9.dist-info → scratchattach-2.1.10a1.dist-info}/WHEEL +1 -1
  57. {scratchattach-2.1.9.dist-info → scratchattach-2.1.10a1.dist-info/licenses}/LICENSE +21 -21
  58. scratchattach-2.1.9.dist-info/RECORD +0 -40
  59. {scratchattach-2.1.9.dist-info → scratchattach-2.1.10a1.dist-info}/top_level.txt +0 -0
@@ -1,546 +1,475 @@
1
- """Project JSON reading and editing capabilities.
2
- This code is still in BETA, there are still bugs and potential consistency issues to be fixed. New features will be added."""
3
-
4
- import random
5
- import zipfile
6
- import string
7
- from abc import ABC, abstractmethod
8
- from ..utils import exceptions
9
- from ..utils.requests import Requests as requests
10
- from ..utils.commons import empty_project_json
11
- import json
12
- import hashlib
13
-
14
- def load_components(json_data:list, ComponentClass, target_list):
15
- for element in json_data:
16
- component = ComponentClass()
17
- component.from_json(element)
18
- target_list.append(component)
19
-
20
- class ProjectBody:
21
-
22
- class BaseProjectBodyComponent(ABC):
23
-
24
- def __init__(self, **entries):
25
- # Attributes every object needs to have:
26
- self.id = None
27
- # Update attributes from entries dict:
28
- self.__dict__.update(entries)
29
-
30
- @abstractmethod
31
- def from_json(self, data:dict):
32
- pass
33
-
34
- @abstractmethod
35
- def to_json(self):
36
- pass
37
-
38
- def _generate_new_id(self):
39
- """
40
- Generates a new id and updates the id.
41
-
42
- Warning:
43
- When done on Block objects, the next_id attribute of the parent block and the parent_id attribute of the next block will NOT be updated by this method.
44
- """
45
- self.id = ''.join(random.choices(string.ascii_letters + string.digits, k=20))
46
-
47
-
48
- class Block(BaseProjectBodyComponent):
49
-
50
- # Thanks to @MonkeyBean2 for some scripts
51
-
52
- def from_json(self, data: dict):
53
- self.opcode = data["opcode"] # The name of the block
54
- self.next_id = data.get("next", None) # The id of the block attached below this block
55
- self.parent_id = data.get("parent", None) # The id of the block that this block is attached to
56
- self.input_data = data.get("inputs", None) # The blocks inside of the block (if the block is a loop or an if clause for example)
57
- self.fields = data.get("fields", None) # The values inside the block's inputs
58
- self.shadow = data.get("shadow", False) # Whether the block is displayed with a shadow
59
- self.topLevel = data.get("topLevel", False) # Whether the block has no parent
60
- self.mutation = data.get("mutation", None) # For custom blocks
61
- self.x = data.get("x", None) # x position if topLevel
62
- self.y = data.get("y", None) # y position if topLevel
63
-
64
- def to_json(self):
65
- output = {"opcode":self.opcode,"next":self.next_id,"parent":self.parent_id,"inputs":self.input_data,"fields":self.fields,"shadow":self.shadow,"topLevel":self.topLevel,"mutation":self.mutation,"x":self.x,"y":self.y}
66
- return {k: v for k, v in output.items() if v}
67
-
68
- def attached_block(self):
69
- return self.sprite.block_by_id(self.next_id)
70
-
71
- def previous_block(self):
72
- return self.sprite.block_by_id(self.parent_id)
73
-
74
- def top_level_block(self):
75
- block = self
76
- return block
77
-
78
- def previous_chain(self):
79
- # to implement: a method that detects circular block chains (to make sure this method terminates)
80
- chain = []
81
- block = self
82
- while block.parent_id is not None:
83
- block = block.previous_block()
84
- chain.insert(0,block)
85
- return chain
86
-
87
- def attached_chain(self):
88
- chain = []
89
- block = self
90
- while block.next_id is not None:
91
- block = block.attached_block()
92
- chain.append(block)
93
- return chain
94
-
95
- def complete_chain(self):
96
- return self.previous_chain() + [self] + self.attached_chain()
97
-
98
- def duplicate_single_block(self):
99
- new_block = ProjectBody.Block(**self.__dict__)
100
- new_block.parent_id = None
101
- new_block.next_id = None
102
- new_block._generate_new_id()
103
- self.sprite.blocks.append(new_block)
104
- return new_block
105
-
106
- def duplicate_chain(self):
107
- blocks_to_dupe = [self] + self.attached_chain()
108
- duped = []
109
- for i in range(len(blocks_to_dupe)):
110
- new_block = ProjectBody.Block(**blocks_to_dupe[i].__dict__)
111
- new_block.parent_id = None
112
- new_block.next_id = None
113
- new_block._generate_new_id()
114
- if i != 0:
115
- new_block.parent_id = duped[i-1].id
116
- duped[i-1].next_id = new_block.id
117
- duped.append(new_block)
118
- self.sprite.blocks += duped
119
- return duped
120
-
121
- def _reattach(self, new_parent_id, new_next_id_of_old_parent):
122
- if self.parent_id is not None:
123
- old_parent_block = self.sprite.block_by_id(self.parent_id)
124
- self.sprite.blocks.remove(old_parent_block)
125
- old_parent_block.next_id = new_next_id_of_old_parent
126
- self.sprite.blocks.append(old_parent_block)
127
-
128
- self.parent_id = new_parent_id
129
-
130
- if self.parent_id is not None:
131
- new_parent_block = self.sprite.block_by_id(self.parent_id)
132
- self.sprite.blocks.remove(new_parent_block)
133
- new_parent_block.next_id = self.id
134
- self.sprite.blocks.append(new_parent_block)
135
-
136
- self.topLevel = new_parent_id is None
137
-
138
- def reattach_single_block(self, new_parent_id):
139
- old_parent_id = str(self.parent_id)
140
- self._reattach(new_parent_id, self.next_id)
141
-
142
- if self.next_id is not None:
143
- old_next_block = self.sprite.block_by_id(self.next_id)
144
- self.sprite.blocks.remove(old_next_block)
145
- old_next_block.parent_id = old_parent_id
146
- self.sprite.blocks.append(old_next_block)
147
-
148
- self.next_id = None
149
-
150
- def reattach_chain(self, new_parent_id):
151
- self._reattach(new_parent_id, None)
152
-
153
- def delete_single_block(self):
154
- self.sprite.blocks.remove(self)
155
-
156
- self.reattach_single_block(None, self.next_id)
157
-
158
- def delete_chain(self):
159
- self.sprite.blocks.remove(self)
160
-
161
- self.reattach_chain(None)
162
-
163
- to_delete = self.attached_chain()
164
- for block in to_delete:
165
- self.sprite.blocks.remove(block)
166
-
167
- def inputs_as_blocks(self):
168
- if self.input_data is None:
169
- return None
170
- inputs = []
171
- for input in self.input_data:
172
- inputs.append(self.sprite.block_by_id(self.input_data[input][1]))
173
-
174
-
175
- class Sprite(BaseProjectBodyComponent):
176
-
177
- def from_json(self, data:dict):
178
- self.isStage = data["isStage"]
179
- self.name = data["name"]
180
- self.id = self.name # Sprites are uniquely identifiable through their name
181
- self.variables = []
182
- for variable_id in data["variables"]: #self.lists is a dict with the list_id as key and info as value
183
- pvar = ProjectBody.Variable(id=variable_id)
184
- pvar.from_json(data["variables"][variable_id])
185
- self.variables.append(pvar)
186
- self.lists = []
187
- for list_id in data["lists"]: #self.lists is a dict with the list_id as key and info as value
188
- plist = ProjectBody.List(id=list_id)
189
- plist.from_json(data["lists"][list_id])
190
- self.lists.append(plist)
191
- self.broadcasts = data["broadcasts"]
192
- self.blocks = []
193
- for block_id in data["blocks"]: #self.blocks is a dict with the block_id as key and block content as value
194
- if isinstance(data["blocks"][block_id], dict): # Sometimes there is a weird list at the end of the blocks list. This list is ignored
195
- block = ProjectBody.Block(id=block_id, sprite=self)
196
- block.from_json(data["blocks"][block_id])
197
- self.blocks.append(block)
198
- self.comments = data["comments"]
199
- self.currentCostume = data["currentCostume"]
200
- self.costumes = []
201
- load_components(data["costumes"], ProjectBody.Asset, self.costumes) # load lists
202
- self.sounds = []
203
- load_components(data["sounds"], ProjectBody.Asset, self.sounds) # load lists
204
- self.volume = data["volume"]
205
- self.layerOrder = data["layerOrder"]
206
- if self.isStage:
207
- self.tempo = data.get("tempo", None)
208
- self.videoTransparency = data.get("videoTransparency", None)
209
- self.videoState = data.get("videoState", None)
210
- self.textToSpeechLanguage = data.get("textToSpeechLanguage", None)
211
- else:
212
- self.visible = data.get("visible", None)
213
- self.x = data.get("x", None)
214
- self.y = data.get("y", None)
215
- self.size = data.get("size", None)
216
- self.direction = data.get("direction", None)
217
- self.draggable = data.get("draggable", None)
218
- self.rotationStyle = data.get("rotationStyle", None)
219
-
220
- def to_json(self):
221
- return_data = dict(self.__dict__)
222
- if "projectBody" in return_data:
223
- return_data.pop("projectBody")
224
- return_data.pop("id")
225
- return_data["variables"] = {}
226
- for variable in self.variables:
227
- return_data["variables"][variable.id] = variable.to_json()
228
- return_data["lists"] = {}
229
- for plist in self.lists:
230
- return_data["lists"][plist.id] = plist.to_json()
231
- return_data["blocks"] = {}
232
- for block in self.blocks:
233
- return_data["blocks"][block.id] = block.to_json()
234
- return_data["costumes"] = [custome.to_json() for custome in self.costumes]
235
- return_data["sounds"] = [sound.to_json() for sound in self.sounds]
236
- return return_data
237
-
238
- def variable_by_id(self, variable_id):
239
- matching = list(filter(lambda x : x.id == variable_id, self.variables))
240
- if matching == []:
241
- return None
242
- return matching[0]
243
-
244
- def list_by_id(self, list_id):
245
- matching = list(filter(lambda x : x.id == list_id, self.lists))
246
- if matching == []:
247
- return None
248
- return matching[0]
249
-
250
- def variable_by_name(self, variable_name):
251
- matching = list(filter(lambda x : x.name == variable_name, self.variables))
252
- if matching == []:
253
- return None
254
- return matching[0]
255
-
256
- def list_by_name(self, list_name):
257
- matching = list(filter(lambda x : x.name == list_name, self.lists))
258
- if matching == []:
259
- return None
260
- return matching[0]
261
-
262
- def block_by_id(self, block_id):
263
- matching = list(filter(lambda x : x.id == block_id, self.blocks))
264
- if matching == []:
265
- return None
266
- return matching[0]
267
-
268
- # -- Functions to modify project contents --
269
-
270
- def create_sound(self, asset_content, *, name="new sound", dataFormat="mp3", rate=4800, sampleCount=4800):
271
- data = asset_content if isinstance(asset_content, bytes) else open(asset_content, "rb").read()
272
-
273
- new_asset_id = hashlib.md5(data).hexdigest()
274
- new_asset = ProjectBody.Asset(assetId=new_asset_id, name=name, id=new_asset_id, dataFormat=dataFormat, rate=rate, sampleCound=sampleCount, md5ext=new_asset_id+"."+dataFormat, filename=new_asset_id+"."+dataFormat)
275
- self.sounds.append(new_asset)
276
- if not hasattr(self, "projectBody"):
277
- print("Warning: Since there's no project body connected to this object, the new sound asset won't be uploaded to Scratch")
278
- elif self.projectBody._session is None:
279
- print("Warning: Since there's no login connected to this object, the new sound asset won't be uploaded to Scratch")
280
- else:
281
- self._session.upload_asset(data, asset_id=new_asset_id, file_ext=dataFormat)
282
- return new_asset
283
-
284
- def create_costume(self, asset_content, *, name="new costume", dataFormat="svg", rotationCenterX=0, rotationCenterY=0):
285
- data = asset_content if isinstance(asset_content, bytes) else open(asset_content, "rb").read()
286
-
287
- new_asset_id = hashlib.md5(data).hexdigest()
288
- new_asset = ProjectBody.Asset(assetId=new_asset_id, name=name, id=new_asset_id, dataFormat=dataFormat, rotationCenterX=rotationCenterX, rotationCenterY=rotationCenterY, md5ext=new_asset_id+"."+dataFormat, filename=new_asset_id+"."+dataFormat)
289
- self.costumes.append(new_asset)
290
- if not hasattr(self, "projectBody"):
291
- print("Warning: Since there's no project body connected to this object, the new costume asset won't be uploaded to Scratch")
292
- elif self.projectBody._session is None:
293
- print("Warning: Since there's no login connected to this object, the new costume asset won't be uploaded to Scratch")
294
- else:
295
- self._session.upload_asset(data, asset_id=new_asset_id, file_ext=dataFormat)
296
- return new_asset
297
-
298
- def create_variable(self, name, *, value=0, is_cloud=False):
299
- new_var = ProjectBody.Variable(name=name, value=value, is_cloud=is_cloud)
300
- self.variables.append(new_var)
301
- return new_var
302
-
303
- def create_list(self, name, *, value=[]):
304
- new_list = ProjectBody.List(name=name, value=value)
305
- self.lists.append(new_list)
306
- return new_list
307
-
308
- def add_block(self, block, *, parent_id=None):
309
- block.parent_id = None
310
- block.next_id = None
311
- if parent_id is not None:
312
- block.reattach_single_block(parent_id)
313
- self.blocks.append(block)
314
-
315
- def add_block_chain(self, block_chain, *, parent_id=None):
316
- parent = parent_id
317
- for block in block_chain:
318
- self.add_block(block, parent_id=parent)
319
- parent = str(block.id)
320
-
321
- class Variable(BaseProjectBodyComponent):
322
-
323
- def __init__(self, **entries):
324
- super().__init__(**entries)
325
- if self.id is None:
326
- self._generate_new_id()
327
-
328
- def from_json(self, data:list):
329
- self.name = data[0]
330
- self.saved_value = data[1]
331
- self.is_cloud = len(data) == 3
332
-
333
- def to_json(self):
334
- if self.is_cloud:
335
- return [self.name, self.saved_value, True]
336
- else:
337
- return [self.name, self.saved_value]
338
-
339
- def make_cloud_variable(self):
340
- self.is_cloud = True
341
-
342
- class List(BaseProjectBodyComponent):
343
-
344
- def __init__(self, **entries):
345
- super().__init__(**entries)
346
- if self.id is None:
347
- self._generate_new_id()
348
-
349
- def from_json(self, data:list):
350
- self.name = data[0]
351
- self.saved_content = data[1]
352
-
353
- def to_json(self):
354
- return [self.name, self.saved_content]
355
-
356
- class Monitor(BaseProjectBodyComponent):
357
-
358
- def from_json(self, data:dict):
359
- self.__dict__.update(data)
360
-
361
- def to_json(self):
362
- return_data = dict(self.__dict__)
363
- if "projectBody" in return_data:
364
- return_data.pop("projectBody")
365
- return return_data
366
-
367
- def target(self):
368
- if not hasattr(self, "projectBody"):
369
- print("Can't get represented object because the origin projectBody of this monitor is not saved")
370
- return
371
- if "VARIABLE" in self.params:
372
- return self.projectBody.sprite_by_name(self.spriteName).variable_by_name(self.params["VARIABLE"])
373
- if "LIST" in self.params:
374
- return self.projectBody.sprite_by_name(self.spriteName).list_by_name(self.params["LIST"])
375
-
376
- class Asset(BaseProjectBodyComponent):
377
-
378
- def from_json(self, data:dict):
379
- self.__dict__.update(data)
380
- self.id = self.assetId
381
- self.filename = self.md5ext
382
- self.download_url = f"https://assets.scratch.mit.edu/internalapi/asset/{self.filename}"
383
-
384
- def to_json(self):
385
- return_data = dict(self.__dict__)
386
- return_data.pop("filename")
387
- return_data.pop("id")
388
- return_data.pop("download_url")
389
- return return_data
390
-
391
- def download(self, *, filename=None, dir=""):
392
- if not (dir.endswith("/") or dir.endswith("\\")):
393
- dir = dir+"/"
394
- try:
395
- if filename is None:
396
- filename = str(self.filename)
397
- response = requests.get(
398
- self.download_url,
399
- timeout=10,
400
- )
401
- open(f"{dir}{filename}", "wb").write(response.content)
402
- except Exception:
403
- raise (
404
- exceptions.FetchError(
405
- "Failed to download asset"
406
- )
407
- )
408
-
409
- def __init__(self, *, sprites=[], monitors=[], extensions=[], meta=[{"agent":None}], _session=None):
410
- # sprites are called "targets" in the initial API response
411
- self.sprites = sprites
412
- self.monitors = monitors
413
- self.extensions = extensions
414
- self.meta = meta
415
- self._session = _session
416
-
417
- def from_json(self, data:dict):
418
- """
419
- Imports the project data from a dict that contains the raw project json
420
- """
421
- # Load sprites:
422
- self.sprites = []
423
- load_components(data["targets"], ProjectBody.Sprite, self.sprites)
424
- # Save origin of sprite in Sprite object:
425
- for sprite in self.sprites:
426
- sprite.projectBody = self
427
- # Load monitors:
428
- self.monitors = []
429
- load_components(data["monitors"], ProjectBody.Monitor, self.monitors)
430
- # Save origin of monitor in Monitor object:
431
- for monitor in self.monitors:
432
- monitor.projectBody = self
433
- # Set extensions and meta attributs:
434
- self.extensions = data["extensions"]
435
- self.meta = data["meta"]
436
-
437
- def to_json(self):
438
- """
439
- Returns a valid project JSON dict with the contents of this project
440
- """
441
- return_data = {}
442
- return_data["targets"] = [sprite.to_json() for sprite in self.sprites]
443
- return_data["monitors"] = [monitor.to_json() for monitor in self.monitors]
444
- return_data["extensions"] = self.extensions
445
- return_data["meta"] = self.meta
446
- return return_data
447
-
448
- # -- Functions to get info --
449
-
450
- def blocks(self):
451
- return [block for sprite in self.sprites for block in sprite.blocks]
452
-
453
- def block_count(self):
454
- return len(self.blocks())
455
-
456
- def assets(self):
457
- return [sound for sprite in self.sprites for sound in sprite.sounds] + [costume for sprite in self.sprites for costume in sprite.costumes]
458
-
459
- def asset_count(self):
460
- return len(self.assets())
461
-
462
- def variable_by_id(self, variable_id):
463
- for sprite in self.sprites:
464
- r = sprite.variable_by_id(variable_id)
465
- if r is not None:
466
- return r
467
-
468
- def list_by_id(self, list_id):
469
- for sprite in self.sprites:
470
- r = sprite.list_by_id(list_id)
471
- if r is not None:
472
- return r
473
-
474
- def sprite_by_name(self, sprite_name):
475
- matching = list(filter(lambda x : x.name == sprite_name, self.sprites))
476
- if matching == []:
477
- return None
478
- return matching[0]
479
-
480
- def user_agent(self):
481
- return self.meta["agent"]
482
-
483
- def save(self, *, filename=None, dir=""):
484
- """
485
- Saves the project body to the given directory.
486
-
487
- Args:
488
- filename (str): The name that will be given to the downloaded file.
489
- dir (str): The path of the directory the file will be saved in.
490
- """
491
- if not (dir.endswith("/") or dir.endswith("\\")):
492
- dir = dir + "/"
493
- if filename is None:
494
- filename = "project"
495
- filename = filename.replace(".sb3", "")
496
- with open(f"{dir}{filename}.sb3", "w") as d:
497
- json.dump(self.to_json(), d, indent=4)
498
-
499
- def get_empty_project_pb():
500
- pb = ProjectBody()
501
- pb.from_json(empty_project_json)
502
- return pb
503
-
504
- def get_pb_from_dict(project_json:dict):
505
- pb = ProjectBody()
506
- pb.from_json(project_json)
507
- return pb
508
-
509
- def _load_sb3_file(path_to_file):
510
- try:
511
- with open(path_to_file, "r") as r:
512
- return json.loads(r.read())
513
- except Exception as e:
514
- with zipfile.ZipFile(path_to_file, 'r') as zip_ref:
515
- # Check if the file exists in the zip
516
- if "project.json" in zip_ref.namelist():
517
- # Read the file as bytes
518
- with zip_ref.open("project.json") as file:
519
- return json.loads(file.read())
520
- else:
521
- raise ValueError("specified sb3 archive doesn't contain project.json")
522
-
523
- def read_sb3_file(path_to_file):
524
- pb = ProjectBody()
525
- pb.from_json(_load_sb3_file(path_to_file))
526
- return pb
527
-
528
- def download_asset(asset_id_with_file_ext, *, filename=None, dir=""):
529
- if not (dir.endswith("/") or dir.endswith("\\")):
530
- dir = dir+"/"
531
- try:
532
- if filename is None:
533
- filename = str(asset_id_with_file_ext)
534
- response = requests.get(
535
- "https://assets.scratch.mit.edu/"+str(asset_id_with_file_ext),
536
- timeout=10,
537
- )
538
- open(f"{dir}{filename}", "wb").write(response.content)
539
- except Exception:
540
- raise (
541
- exceptions.FetchError(
542
- "Failed to download asset"
543
- )
544
- )
545
-
546
- # The method for uploading an asset by id requires authentication and can be found in the site.session.Session class
1
+ """Project JSON reading and editing capabilities.
2
+ This code is still in BETA, there are still bugs and potential consistency issues to be fixed. New features will be added."""
3
+
4
+ # Note: You may want to make this into multiple files for better organisation
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import json
9
+ import random
10
+ import string
11
+ import zipfile
12
+ from abc import ABC, abstractmethod
13
+ from ..utils import exceptions
14
+ from ..utils.commons import empty_project_json
15
+ from ..utils.requests import Requests as requests
16
+ # noinspection PyPep8Naming
17
+ def load_components(json_data: list, ComponentClass: type, target_list: list):
18
+ for element in json_data:
19
+ component = ComponentClass()
20
+ component.from_json(element)
21
+ target_list.append(component)
22
+ class ProjectBody:
23
+ class BaseProjectBodyComponent(ABC):
24
+ def __init__(self, **entries):
25
+ # Attributes every object needs to have:
26
+ self.id = None
27
+ # Update attributes from entries dict:
28
+ self.__dict__.update(entries)
29
+ @abstractmethod
30
+ def from_json(self, data: dict):
31
+ pass
32
+ @abstractmethod
33
+ def to_json(self):
34
+ pass
35
+ def _generate_new_id(self):
36
+ """
37
+ Generates a new id and updates the id.
38
+ Warning:
39
+ When done on Block objects, the next_id attribute of the parent block and the parent_id attribute of the next block will NOT be updated by this method.
40
+ """
41
+ self.id = ''.join(random.choices(string.ascii_letters + string.digits, k=20))
42
+ class Block(BaseProjectBodyComponent):
43
+ # Thanks to @MonkeyBean2 for some scripts
44
+ def from_json(self, data: dict):
45
+ self.opcode = data["opcode"] # The name of the block
46
+ self.next_id = data.get("next", None) # The id of the block attached below this block
47
+ self.parent_id = data.get("parent", None) # The id of the block that this block is attached to
48
+ self.input_data = data.get("inputs", None) # The blocks inside of the block (if the block is a loop or an if clause for example)
49
+ self.fields = data.get("fields", None) # The values inside the block's inputs
50
+ self.shadow = data.get("shadow", False) # Whether the block is displayed with a shadow
51
+ self.topLevel = data.get("topLevel", False) # Whether the block has no parent
52
+ self.mutation = data.get("mutation", None) # For custom blocks
53
+ self.x = data.get("x", None) # x position if topLevel
54
+ self.y = data.get("y", None) # y position if topLevel
55
+ def to_json(self):
56
+ output = {"opcode": self.opcode, "next": self.next_id, "parent": self.parent_id, "inputs": self.input_data,
57
+ "fields": self.fields, "shadow": self.shadow, "topLevel": self.topLevel,
58
+ "mutation": self.mutation, "x": self.x, "y": self.y}
59
+ return {k: v for k, v in output.items() if v}
60
+ def attached_block(self):
61
+ return self.sprite.block_by_id(self.next_id)
62
+ def previous_block(self):
63
+ return self.sprite.block_by_id(self.parent_id)
64
+ def top_level_block(self):
65
+ block = self
66
+ return block
67
+ def previous_chain(self):
68
+ # to implement: a method that detects circular block chains (to make sure this method terminates)
69
+ chain = []
70
+ block = self
71
+ while block.parent_id is not None:
72
+ block = block.previous_block()
73
+ chain.insert(0, block)
74
+ return chain
75
+ def attached_chain(self):
76
+ chain = []
77
+ block = self
78
+ while block.next_id is not None:
79
+ block = block.attached_block()
80
+ chain.append(block)
81
+ return chain
82
+ def complete_chain(self):
83
+ return self.previous_chain() + [self] + self.attached_chain()
84
+ def duplicate_single_block(self):
85
+ new_block = ProjectBody.Block(**self.__dict__)
86
+ new_block.parent_id = None
87
+ new_block.next_id = None
88
+ new_block._generate_new_id()
89
+ self.sprite.blocks.append(new_block)
90
+ return new_block
91
+ def duplicate_chain(self):
92
+ blocks_to_dupe = [self] + self.attached_chain()
93
+ duped = []
94
+ for i in range(len(blocks_to_dupe)):
95
+ new_block = ProjectBody.Block(**blocks_to_dupe[i].__dict__)
96
+ new_block.parent_id = None
97
+ new_block.next_id = None
98
+ new_block._generate_new_id()
99
+ if i != 0:
100
+ new_block.parent_id = duped[i - 1].id
101
+ duped[i - 1].next_id = new_block.id
102
+ duped.append(new_block)
103
+ self.sprite.blocks += duped
104
+ return duped
105
+ def _reattach(self, new_parent_id, new_next_id_of_old_parent):
106
+ if self.parent_id is not None:
107
+ old_parent_block = self.sprite.block_by_id(self.parent_id)
108
+ self.sprite.blocks.remove(old_parent_block)
109
+ old_parent_block.next_id = new_next_id_of_old_parent
110
+ self.sprite.blocks.append(old_parent_block)
111
+ self.parent_id = new_parent_id
112
+ if self.parent_id is not None:
113
+ new_parent_block = self.sprite.block_by_id(self.parent_id)
114
+ self.sprite.blocks.remove(new_parent_block)
115
+ new_parent_block.next_id = self.id
116
+ self.sprite.blocks.append(new_parent_block)
117
+ self.topLevel = new_parent_id is None
118
+ def reattach_single_block(self, new_parent_id):
119
+ old_parent_id = str(self.parent_id)
120
+ self._reattach(new_parent_id, self.next_id)
121
+ if self.next_id is not None:
122
+ old_next_block = self.sprite.block_by_id(self.next_id)
123
+ self.sprite.blocks.remove(old_next_block)
124
+ old_next_block.parent_id = old_parent_id
125
+ self.sprite.blocks.append(old_next_block)
126
+ self.next_id = None
127
+ def reattach_chain(self, new_parent_id):
128
+ self._reattach(new_parent_id, None)
129
+ def delete_single_block(self):
130
+ self.sprite.blocks.remove(self)
131
+ self.reattach_single_block(None, self.next_id)
132
+ def delete_chain(self):
133
+ self.sprite.blocks.remove(self)
134
+ self.reattach_chain(None)
135
+ to_delete = self.attached_chain()
136
+ for block in to_delete:
137
+ self.sprite.blocks.remove(block)
138
+ def inputs_as_blocks(self):
139
+ if self.input_data is None:
140
+ return None
141
+ inputs = []
142
+ for input in self.input_data:
143
+ inputs.append(self.sprite.block_by_id(self.input_data[input][1]))
144
+ class Sprite(BaseProjectBodyComponent):
145
+ def from_json(self, data: dict):
146
+ self.isStage = data["isStage"]
147
+ self.name = data["name"]
148
+ self.id = self.name # Sprites are uniquely identifiable through their name
149
+ self.variables = []
150
+ for variable_id in data["variables"]: # self.lists is a dict with the list_id as key and info as value
151
+ pvar = ProjectBody.Variable(id=variable_id)
152
+ pvar.from_json(data["variables"][variable_id])
153
+ self.variables.append(pvar)
154
+ self.lists = []
155
+ for list_id in data["lists"]: # self.lists is a dict with the list_id as key and info as value
156
+ plist = ProjectBody.List(id=list_id)
157
+ plist.from_json(data["lists"][list_id])
158
+ self.lists.append(plist)
159
+ self.broadcasts = data["broadcasts"]
160
+ self.blocks = []
161
+ for block_id in data["blocks"]: # self.blocks is a dict with the block_id as key and block content as value
162
+ if isinstance(data["blocks"][block_id],
163
+ dict): # Sometimes there is a weird list at the end of the blocks list. This list is ignored
164
+ block = ProjectBody.Block(id=block_id, sprite=self)
165
+ block.from_json(data["blocks"][block_id])
166
+ self.blocks.append(block)
167
+ self.comments = data["comments"]
168
+ self.currentCostume = data["currentCostume"]
169
+ self.costumes = []
170
+ load_components(data["costumes"], ProjectBody.Asset, self.costumes) # load lists
171
+ self.sounds = []
172
+ load_components(data["sounds"], ProjectBody.Asset, self.sounds) # load lists
173
+ self.volume = data["volume"]
174
+ self.layerOrder = data["layerOrder"]
175
+ if self.isStage:
176
+ self.tempo = data.get("tempo", None)
177
+ self.videoTransparency = data.get("videoTransparency", None)
178
+ self.videoState = data.get("videoState", None)
179
+ self.textToSpeechLanguage = data.get("textToSpeechLanguage", None)
180
+ else:
181
+ self.visible = data.get("visible", None)
182
+ self.x = data.get("x", None)
183
+ self.y = data.get("y", None)
184
+ self.size = data.get("size", None)
185
+ self.direction = data.get("direction", None)
186
+ self.draggable = data.get("draggable", None)
187
+ self.rotationStyle = data.get("rotationStyle", None)
188
+ def to_json(self):
189
+ return_data = dict(self.__dict__)
190
+ if "projectBody" in return_data:
191
+ return_data.pop("projectBody")
192
+ return_data.pop("id")
193
+ return_data["variables"] = {}
194
+ for variable in self.variables:
195
+ return_data["variables"][variable.id] = variable.to_json()
196
+ return_data["lists"] = {}
197
+ for plist in self.lists:
198
+ return_data["lists"][plist.id] = plist.to_json()
199
+ return_data["blocks"] = {}
200
+ for block in self.blocks:
201
+ return_data["blocks"][block.id] = block.to_json()
202
+ return_data["costumes"] = [custome.to_json() for custome in self.costumes]
203
+ return_data["sounds"] = [sound.to_json() for sound in self.sounds]
204
+ return return_data
205
+ def variable_by_id(self, variable_id):
206
+ matching = list(filter(lambda x: x.id == variable_id, self.variables))
207
+ if matching == []:
208
+ return None
209
+ return matching[0]
210
+ def list_by_id(self, list_id):
211
+ matching = list(filter(lambda x: x.id == list_id, self.lists))
212
+ if matching == []:
213
+ return None
214
+ return matching[0]
215
+ def variable_by_name(self, variable_name):
216
+ matching = list(filter(lambda x: x.name == variable_name, self.variables))
217
+ if matching == []:
218
+ return None
219
+ return matching[0]
220
+ def list_by_name(self, list_name):
221
+ matching = list(filter(lambda x: x.name == list_name, self.lists))
222
+ if matching == []:
223
+ return None
224
+ return matching[0]
225
+ def block_by_id(self, block_id):
226
+ matching = list(filter(lambda x: x.id == block_id, self.blocks))
227
+ if matching == []:
228
+ return None
229
+ return matching[0]
230
+ # -- Functions to modify project contents --
231
+ def create_sound(self, asset_content, *, name="new sound", dataFormat="mp3", rate=4800, sampleCount=4800):
232
+ data = asset_content if isinstance(asset_content, bytes) else open(asset_content, "rb").read()
233
+ new_asset_id = hashlib.md5(data).hexdigest()
234
+ new_asset = ProjectBody.Asset(assetId=new_asset_id, name=name, id=new_asset_id, dataFormat=dataFormat,
235
+ rate=rate, sampleCound=sampleCount, md5ext=new_asset_id + "." + dataFormat,
236
+ filename=new_asset_id + "." + dataFormat)
237
+ self.sounds.append(new_asset)
238
+ if not hasattr(self, "projectBody"):
239
+ print(
240
+ "Warning: Since there's no project body connected to this object, the new sound asset won't be uploaded to Scratch")
241
+ elif self.projectBody._session is None:
242
+ print(
243
+ "Warning: Since there's no login connected to this object, the new sound asset won't be uploaded to Scratch")
244
+ else:
245
+ self._session.upload_asset(data, asset_id=new_asset_id, file_ext=dataFormat)
246
+ return new_asset
247
+ def create_costume(self, asset_content, *, name="new costume", dataFormat="svg", rotationCenterX=0,
248
+ rotationCenterY=0):
249
+ data = asset_content if isinstance(asset_content, bytes) else open(asset_content, "rb").read()
250
+ new_asset_id = hashlib.md5(data).hexdigest()
251
+ new_asset = ProjectBody.Asset(assetId=new_asset_id, name=name, id=new_asset_id, dataFormat=dataFormat,
252
+ rotationCenterX=rotationCenterX, rotationCenterY=rotationCenterY,
253
+ md5ext=new_asset_id + "." + dataFormat,
254
+ filename=new_asset_id + "." + dataFormat)
255
+ self.costumes.append(new_asset)
256
+ if not hasattr(self, "projectBody"):
257
+ print(
258
+ "Warning: Since there's no project body connected to this object, the new costume asset won't be uploaded to Scratch")
259
+ elif self.projectBody._session is None:
260
+ print(
261
+ "Warning: Since there's no login connected to this object, the new costume asset won't be uploaded to Scratch")
262
+ else:
263
+ self._session.upload_asset(data, asset_id=new_asset_id, file_ext=dataFormat)
264
+ return new_asset
265
+ def create_variable(self, name, *, value=0, is_cloud=False):
266
+ new_var = ProjectBody.Variable(name=name, value=value, is_cloud=is_cloud)
267
+ self.variables.append(new_var)
268
+ return new_var
269
+ def create_list(self, name, *, value=[]):
270
+ new_list = ProjectBody.List(name=name, value=value)
271
+ self.lists.append(new_list)
272
+ return new_list
273
+ def add_block(self, block, *, parent_id=None):
274
+ block.parent_id = None
275
+ block.next_id = None
276
+ if parent_id is not None:
277
+ block.reattach_single_block(parent_id)
278
+ self.blocks.append(block)
279
+ def add_block_chain(self, block_chain, *, parent_id=None):
280
+ parent = parent_id
281
+ for block in block_chain:
282
+ self.add_block(block, parent_id=parent)
283
+ parent = str(block.id)
284
+ class Variable(BaseProjectBodyComponent):
285
+ def __init__(self, **entries):
286
+ super().__init__(**entries)
287
+ if self.id is None:
288
+ self._generate_new_id()
289
+ def from_json(self, data: list):
290
+ self.name = data[0]
291
+ self.saved_value = data[1]
292
+ self.is_cloud = len(data) == 3
293
+ def to_json(self):
294
+ if self.is_cloud:
295
+ return [self.name, self.saved_value, True]
296
+ else:
297
+ return [self.name, self.saved_value]
298
+ def make_cloud_variable(self):
299
+ self.is_cloud = True
300
+ class List(BaseProjectBodyComponent):
301
+ def __init__(self, **entries):
302
+ super().__init__(**entries)
303
+ if self.id is None:
304
+ self._generate_new_id()
305
+ def from_json(self, data: list):
306
+ self.name = data[0]
307
+ self.saved_content = data[1]
308
+ def to_json(self):
309
+ return [self.name, self.saved_content]
310
+ class Monitor(BaseProjectBodyComponent):
311
+ def from_json(self, data: dict):
312
+ self.__dict__.update(data)
313
+ def to_json(self):
314
+ return_data = dict(self.__dict__)
315
+ if "projectBody" in return_data:
316
+ return_data.pop("projectBody")
317
+ return return_data
318
+ def target(self):
319
+ if not hasattr(self, "projectBody"):
320
+ print("Can't get represented object because the origin projectBody of this monitor is not saved")
321
+ return
322
+ if "VARIABLE" in self.params:
323
+ return self.projectBody.sprite_by_name(self.spriteName).variable_by_name(self.params["VARIABLE"])
324
+ if "LIST" in self.params:
325
+ return self.projectBody.sprite_by_name(self.spriteName).list_by_name(self.params["LIST"])
326
+ class Asset(BaseProjectBodyComponent):
327
+ def from_json(self, data: dict):
328
+ self.__dict__.update(data)
329
+ self.id = self.assetId
330
+ self.filename = self.md5ext
331
+ self.download_url = f"https://assets.scratch.mit.edu/internalapi/asset/{self.filename}"
332
+ def to_json(self):
333
+ return_data = dict(self.__dict__)
334
+ return_data.pop("filename")
335
+ return_data.pop("id")
336
+ return_data.pop("download_url")
337
+ return return_data
338
+ def download(self, *, filename=None, dir=""):
339
+ if not (dir.endswith("/") or dir.endswith("\\")):
340
+ dir = dir + "/"
341
+ try:
342
+ if filename is None:
343
+ filename = str(self.filename)
344
+ response = requests.get(
345
+ self.download_url,
346
+ timeout=10,
347
+ )
348
+ open(f"{dir}{filename}", "wb").write(response.content)
349
+ except Exception:
350
+ raise (
351
+ exceptions.FetchError(
352
+ "Failed to download asset"
353
+ )
354
+ )
355
+ def __init__(self, *, sprites=[], monitors=[], extensions=[], meta=[{"agent": None}], _session=None):
356
+ # sprites are called "targets" in the initial API response
357
+ self.sprites = sprites
358
+ self.monitors = monitors
359
+ self.extensions = extensions
360
+ self.meta = meta
361
+ self._session = _session
362
+ def from_json(self, data: dict):
363
+ """
364
+ Imports the project data from a dict that contains the raw project json
365
+ """
366
+ # Load sprites:
367
+ self.sprites = []
368
+ load_components(data["targets"], ProjectBody.Sprite, self.sprites)
369
+ # Save origin of sprite in Sprite object:
370
+ for sprite in self.sprites:
371
+ sprite.projectBody = self
372
+ # Load monitors:
373
+ self.monitors = []
374
+ load_components(data["monitors"], ProjectBody.Monitor, self.monitors)
375
+ # Save origin of monitor in Monitor object:
376
+ for monitor in self.monitors:
377
+ monitor.projectBody = self
378
+ # Set extensions and meta attributs:
379
+ self.extensions = data["extensions"]
380
+ self.meta = data["meta"]
381
+ def to_json(self):
382
+ """
383
+ Returns a valid project JSON dict with the contents of this project
384
+ """
385
+ return_data = {}
386
+ return_data["targets"] = [sprite.to_json() for sprite in self.sprites]
387
+ return_data["monitors"] = [monitor.to_json() for monitor in self.monitors]
388
+ return_data["extensions"] = self.extensions
389
+ return_data["meta"] = self.meta
390
+ return return_data
391
+ # -- Functions to get info --
392
+ def blocks(self):
393
+ return [block for sprite in self.sprites for block in sprite.blocks]
394
+ def block_count(self):
395
+ return len(self.blocks())
396
+ def assets(self):
397
+ return [sound for sprite in self.sprites for sound in sprite.sounds] + [costume for sprite in self.sprites for
398
+ costume in sprite.costumes]
399
+ def asset_count(self):
400
+ return len(self.assets())
401
+ def variable_by_id(self, variable_id):
402
+ for sprite in self.sprites:
403
+ r = sprite.variable_by_id(variable_id)
404
+ if r is not None:
405
+ return r
406
+ def list_by_id(self, list_id):
407
+ for sprite in self.sprites:
408
+ r = sprite.list_by_id(list_id)
409
+ if r is not None:
410
+ return r
411
+ def sprite_by_name(self, sprite_name):
412
+ matching = list(filter(lambda x: x.name == sprite_name, self.sprites))
413
+ if matching == []:
414
+ return None
415
+ return matching[0]
416
+ def user_agent(self):
417
+ return self.meta["agent"]
418
+ def save(self, *, filename=None, dir=""):
419
+ """
420
+ Saves the project body to the given directory.
421
+ Args:
422
+ filename (str): The name that will be given to the downloaded file.
423
+ dir (str): The path of the directory the file will be saved in.
424
+ """
425
+ if not (dir.endswith("/") or dir.endswith("\\")):
426
+ dir = dir + "/"
427
+ if filename is None:
428
+ filename = "project"
429
+ filename = filename.replace(".sb3", "")
430
+ with open(f"{dir}{filename}.sb3", "w") as d:
431
+ json.dump(self.to_json(), d, indent=4)
432
+ def get_empty_project_pb():
433
+ pb = ProjectBody()
434
+ pb.from_json(empty_project_json)
435
+ return pb
436
+ def get_pb_from_dict(project_json: dict):
437
+ pb = ProjectBody()
438
+ pb.from_json(project_json)
439
+ return pb
440
+ def _load_sb3_file(path_to_file):
441
+ try:
442
+ with open(path_to_file, "r") as r:
443
+ return json.loads(r.read())
444
+ except Exception as e:
445
+ with zipfile.ZipFile(path_to_file, 'r') as zip_ref:
446
+ # Check if the file exists in the zip
447
+ if "project.json" in zip_ref.namelist():
448
+ # Read the file as bytes
449
+ with zip_ref.open("project.json") as file:
450
+ return json.loads(file.read())
451
+ else:
452
+ raise ValueError("specified sb3 archive doesn't contain project.json")
453
+ def read_sb3_file(path_to_file):
454
+ pb = ProjectBody()
455
+ pb.from_json(_load_sb3_file(path_to_file))
456
+ return pb
457
+ def download_asset(asset_id_with_file_ext, *, filename=None, dir=""):
458
+ if not (dir.endswith("/") or dir.endswith("\\")):
459
+ dir = dir + "/"
460
+ try:
461
+ if filename is None:
462
+ filename = str(asset_id_with_file_ext)
463
+ response = requests.get(
464
+ "https://assets.scratch.mit.edu/" + str(asset_id_with_file_ext),
465
+ timeout=10,
466
+ )
467
+ open(f"{dir}{filename}", "wb").write(response.content)
468
+ except Exception:
469
+ raise (
470
+ exceptions.FetchError(
471
+ "Failed to download asset"
472
+ )
473
+ )
474
+
475
+ # The method for uploading an asset by id requires authentication and can be found in the site.session.Session class