diff --git a/.gitignore b/.gitignore index 90542664..4de6878c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ scratchattach/test.py scratchattach.code-workspace **/.DS_Store setup.py +setup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index eb9e1f03..0328f2ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 2.1.7 + +- Fixed self-hosting TW cloud ws servers feature +- Added sa.translate and sa.text2speech functions + # 2.0.6 Cloud variables: Stability improvement diff --git a/README.md b/README.md index 21cee259..dff5b6da 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The library allows setting cloud variables, following users, updating your profi so much more! Additionally, it provides frameworks that simplify sending data through cloud variables.

- + [![PyPI status](https://img.shields.io/pypi/status/scratchattach.svg)](https://pypi.python.org/pypi/scratchattach/) [![PyPI download month](https://img.shields.io/pypi/dm/scratchattach.svg)](https://pypi.python.org/pypi/scratchattach/) diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index 3e96d7b4..eaa83072 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -10,6 +10,7 @@ from .other.other_apis import * from .other.project_json_capabilities import ProjectBody, get_empty_project_pb, get_pb_from_dict, read_sb3_file, download_asset from .utils.encoder import Encoding +from .utils.enums import Languages, TTSVoices from .site.activity import Activity from .site.backpack_asset import BackpackAsset @@ -22,3 +23,5 @@ from .site.classroom import Classroom, get_classroom from .site.user import User, get_user from .site._base import BaseSiteComponent + +from . import editor diff --git a/scratchattach/cloud/_base.py b/scratchattach/cloud/_base.py index f3076ed7..19928a5a 100644 --- a/scratchattach/cloud/_base.py +++ b/scratchattach/cloud/_base.py @@ -1,60 +1,62 @@ -from abc import ABC, abstractmethod +from __future__ import annotations -import websocket import json +import ssl import time -from ..utils import exceptions -import warnings +from abc import ABC + +import websocket + from ..eventhandlers import cloud_recorder -import ssl +from ..utils import exceptions -class BaseCloud(ABC): +class BaseCloud(ABC): """ Base class for a project's cloud variables. Represents a cloud. - When inheriting from this class, the __init__ function of the inherited class ... - + When inheriting from this class, the __init__ function of the inherited class: - must first call the constructor of the super class: super().__init__() - - must then set some attributes Attributes that must be specified in the __init__ function a class inheriting from this one: + project_id: Project id of the cloud variables - :self.project_id: Project id of the cloud variables - - :self.cloud_host: URL of the websocket server ("wss://..." or "ws://...") + cloud_host: URL of the websocket server ("wss://..." or "ws://...") Attributes that can, but don't have to be specified in the __init__ function: - :self._session: Either None or a site.session.Session object. Defaults to None. + _session: Either None or a site.session.Session object. Defaults to None. - :self.ws_shortterm_ratelimit: The wait time between cloud variable sets. Defaults to 0.1 + ws_shortterm_ratelimit: The wait time between cloud variable sets. Defaults to 0.1 - :self.ws_longterm_ratelimit: The amount of cloud variable set that can be performed long-term without ever getting ratelimited + ws_longterm_ratelimit: The amount of cloud variable set that can be performed long-term without ever getting ratelimited - :self.allow_non_numeric: Whether non-numeric cloud variable values are allowed. Defaults to False + allow_non_numeric: Whether non-numeric cloud variable values are allowed. Defaults to False - :self.length_limit: Length limit for cloud variable values. Defaults to 100000 + length_limit: Length limit for cloud variable values. Defaults to 100000 - :self.username: The username to send during handshake. Defaults to "scratchattach" + username: The username to send during handshake. Defaults to "scratchattach" - :self.header: The header to send. Defaults to None + header: The header to send. Defaults to None - :self.cookie: The cookie to send. Defaults to None + cookie: The cookie to send. Defaults to None - :self.origin: The origin to send. Defaults to None + origin: The origin to send. Defaults to None - :self.print_connect_messages: Whether to print a message on every connect to the cloud server. Defaults to False. + print_connect_messages: Whether to print a message on every connect to the cloud server. Defaults to False. """ - def __init__(self): + + def __init__(self, *, _session=None): # Required internal attributes that every object representing a cloud needs to have (no matter what cloud is represented): - self._session = None + self._session = _session self.active_connection = False #whether a connection to a cloud variable server is currently established + self.websocket = websocket.WebSocket(sslopt={"cert_reqs": ssl.CERT_NONE}) - self.recorder = None # A CloudRecorder object that records cloud activity for the values to be retrieved later will be saved in this attribute as soon as .get_var is called + self.recorder = None # A CloudRecorder object that records cloud activity for the values to be retrieved later, + # which will be saved in this attribute as soon as .get_var is called self.first_var_set = 0 self.last_var_set = 0 self.var_stets_since_first = 0 @@ -63,7 +65,8 @@ def __init__(self): # (These attributes can be specifically in the constructors of classes inheriting from this base class) self.ws_shortterm_ratelimit = 0.06667 self.ws_longterm_ratelimit = 0.1 - self.ws_timeout = 3 # Timeout for send operations (after the timeout, the connection will be renewed and the operation will be retried 3 times) + self.ws_timeout = 3 # Timeout for send operations (after the timeout, + # the connection will be renewed and the operation will be retried 3 times) self.allow_non_numeric = False self.length_limit = 100000 self.username = "scratchattach" @@ -100,7 +103,7 @@ def _send_packet(self, packet): self.websocket.send(json.dumps(packet) + "\n") except Exception: self.active_connection = False - raise exceptions.ConnectionError(f"Sending packet failed three times in a row: {packet}") + raise exceptions.CloudConnectionError(f"Sending packet failed three times in a row: {packet}") def _send_packet_list(self, packet_list): packet_string = "".join([json.dumps(packet) + "\n" for packet in packet_list]) @@ -126,7 +129,8 @@ def _send_packet_list(self, packet_list): self.websocket.send(packet_string) except Exception: self.active_connection = False - raise exceptions.ConnectionError(f"Sending packet list failed four times in a row: {packet_list}") + raise exceptions.CloudConnectionError( + f"Sending packet list failed four times in a row: {packet_list}") def _handshake(self): packet = {"method": "handshake", "user": self.username, "project_id": self.project_id} @@ -139,8 +143,8 @@ def connect(self): cookie=self.cookie, origin=self.origin, enable_multithread=True, - timeout = self.ws_timeout, - header = self.header + timeout=self.ws_timeout, + header=self.header ) self._handshake() self.active_connection = True @@ -166,29 +170,29 @@ def _assert_valid_value(self, value): if not (value in [True, False, float('inf'), -float('inf')]): value = str(value) if len(value) > self.length_limit: - raise(exceptions.InvalidCloudValue( + raise (exceptions.InvalidCloudValue( f"Value exceeds length limit: {str(value)}" )) if not self.allow_non_numeric: x = value.replace(".", "") x = x.replace("-", "") if not (x.isnumeric() or x == ""): - raise(exceptions.InvalidCloudValue( + raise (exceptions.InvalidCloudValue( "Value not numeric" )) def _enforce_ratelimit(self, *, n): # n is the amount of variables being set - if (time.time() - self.first_var_set) / (self.var_stets_since_first+1) > self.ws_longterm_ratelimit: # if the average delay between cloud variable sets has been bigger than the long-term rate-limit, cloud variables can be set fast (wait time smaller than long-term rate limit) again + if (time.time() - self.first_var_set) / ( + self.var_stets_since_first + 1) > self.ws_longterm_ratelimit: # if the average delay between cloud variable sets has been bigger than the long-term rate-limit, cloud variables can be set fast (wait time smaller than long-term rate limit) again self.var_stets_since_first = 0 self.first_var_set = time.time() wait_time = self.ws_shortterm_ratelimit * n - if time.time() - self.first_var_set > 25: # if cloud variables have been continously set fast (wait time smaller than long-term rate limit) for 25 seconds, they should be set slow now (wait time = long-term rate limit) to avoid getting rate-limited + if time.time() - self.first_var_set > 25: # if cloud variables have been continously set fast (wait time smaller than long-term rate limit) for 25 seconds, they should be set slow now (wait time = long-term rate limit) to avoid getting rate-limited wait_time = self.ws_longterm_ratelimit * n while self.last_var_set + wait_time >= time.time(): time.sleep(0.001) - def set_var(self, variable, value): """ @@ -231,7 +235,7 @@ def set_vars(self, var_value_dict, *, intelligent_waits=True): self.connect() if intelligent_waits: self._enforce_ratelimit(n=len(list(var_value_dict.keys()))) - + self.var_stets_since_first += len(list(var_value_dict.keys())) packet_list = [] @@ -256,7 +260,7 @@ def get_var(self, var, *, recorder_initial_values={}): self.recorder = cloud_recorder.CloudRecorder(self, initial_values=recorder_initial_values) self.recorder.start() start_time = time.time() - while not (self.recorder.cloud_values != {} or start_time < time.time() -5): + while not (self.recorder.cloud_values != {} or start_time < time.time() - 5): time.sleep(0.01) return self.recorder.get_var(var) @@ -265,7 +269,7 @@ def get_all_vars(self, *, recorder_initial_values={}): self.recorder = cloud_recorder.CloudRecorder(self, initial_values=recorder_initial_values) self.recorder.start() start_time = time.time() - while not (self.recorder.cloud_values != {} or start_time < time.time() -5): + while not (self.recorder.cloud_values != {} or start_time < time.time() - 5): time.sleep(0.01) return self.recorder.get_all_vars() @@ -273,9 +277,11 @@ def events(self): from ..eventhandlers.cloud_events import CloudEvents return CloudEvents(self) - def requests(self, *, no_packet_loss=False, used_cloud_vars=["1", "2", "3", "4", "5", "6", "7", "8", "9"], respond_order="receive"): + def requests(self, *, no_packet_loss=False, used_cloud_vars=["1", "2", "3", "4", "5", "6", "7", "8", "9"], + respond_order="receive"): from ..eventhandlers.cloud_requests import CloudRequests - return CloudRequests(self, used_cloud_vars=used_cloud_vars, no_packet_loss=no_packet_loss, respond_order=respond_order) + return CloudRequests(self, used_cloud_vars=used_cloud_vars, no_packet_loss=no_packet_loss, + respond_order=respond_order) def storage(self, *, no_packet_loss=False, used_cloud_vars=["1", "2", "3", "4", "5", "6", "7", "8", "9"]): from ..eventhandlers.cloud_storage import CloudStorage diff --git a/scratchattach/cloud/cloud.py b/scratchattach/cloud/cloud.py index c0378c8b..a622b92b 100644 --- a/scratchattach/cloud/cloud.py +++ b/scratchattach/cloud/cloud.py @@ -1,13 +1,15 @@ """v2 ready: ScratchCloud, TwCloud and CustomCloud classes""" +from __future__ import annotations + from ._base import BaseCloud from typing import Type from ..utils.requests import Requests as requests from ..utils import exceptions, commons from ..site import cloud_activity -class ScratchCloud(BaseCloud): +class ScratchCloud(BaseCloud): def __init__(self, *, project_id, _session=None): super().__init__() @@ -91,9 +93,10 @@ def events(self, *, use_logs=False): else: return super().events() -class TwCloud(BaseCloud): - def __init__(self, *, project_id, cloud_host="wss://clouddata.turbowarp.org", purpose="", contact=""): +class TwCloud(BaseCloud): + def __init__(self, *, project_id, cloud_host="wss://clouddata.turbowarp.org", purpose="", contact="", + _session=None): super().__init__() self.project_id = project_id diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py new file mode 100644 index 00000000..0e3a2452 --- /dev/null +++ b/scratchattach/editor/__init__.py @@ -0,0 +1,21 @@ +""" +scratchattach.editor (sbeditor v2) - a library for all things sb3 +""" + +from .asset import Asset, Costume, Sound +from .project import Project +from .extension import Extensions, Extension +from .mutation import Mutation, Argument, parse_proc_code +from .meta import Meta, set_meta_platform +from .sprite import Sprite +from .block import Block +from .prim import Prim, PrimTypes +from .backpack_json import load_script as load_script_from_backpack +from .twconfig import TWConfig, is_valid_twconfig +from .inputs import Input, ShadowStatuses +from .field import Field +from .vlb import Variable, List, Broadcast +from .comment import Comment +from .monitor import Monitor + +from .build_defaulting import add_chain, add_comment, add_block diff --git a/scratchattach/editor/asset.py b/scratchattach/editor/asset.py new file mode 100644 index 00000000..30280e5c --- /dev/null +++ b/scratchattach/editor/asset.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from hashlib import md5 +import requests + +from . import base, commons, sprite, build_defaulting + + +@dataclass(init=True, repr=True) +class AssetFile: + filename: str + _data: bytes = field(repr=False, default=None) + _md5: str = field(repr=False, default=None) + + @property + def data(self): + if self._data is None: + # Download and cache + rq = requests.get(f"https://assets.scratch.mit.edu/internalapi/asset/{self.filename}/get/") + if rq.status_code != 200: + raise ValueError(f"Can't download asset {self.filename}\nIs not uploaded to scratch! Response: {rq.text}") + + self._data = rq.content + + return self._data + + @property + def md5(self): + if self._md5 is None: + self._md5 = md5(self.data).hexdigest() + + return self._md5 + + +class Asset(base.SpriteSubComponent): + def __init__(self, + name: str = "costume1", + file_name: str = "b7853f557e4426412e64bb3da6531a99.svg", + _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): + """ + Represents a generic asset. Can be a sound or an image. + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Assets + """ + try: + asset_id, data_format = file_name.split('.') + except ValueError: + raise ValueError(f"Invalid file name: {file_name}, # of '.' in {file_name} ({file_name.count('.')}) != 2; " + f"(too many/few values to unpack)") + self.name = name + + self.id = asset_id + self.data_format = data_format + + super().__init__(_sprite) + + def __repr__(self): + return f"Asset<{self.name!r}>" + + @property + def folder(self): + return commons.get_folder_name(self.name) + + @property + def name_nfldr(self): + return commons.get_name_nofldr(self.name) + + @property + def file_name(self): + return f"{self.id}.{self.data_format}" + + @property + def md5ext(self): + return self.file_name + + @property + def parent(self): + if self.project is None: + return self.sprite + else: + return self.project + + @property + def asset_file(self) -> AssetFile: + for asset_file in self.parent.asset_data: + if asset_file.filename == self.file_name: + return asset_file + + # No pre-existing asset file object; create one and add it to the project + asset_file = AssetFile(self.file_name) + self.project.asset_data.append(asset_file) + return asset_file + + @staticmethod + def from_json(data: dict): + _name = data.get("name") + _file_name = data.get("md5ext") + if _file_name is None: + if "dataFormat" in data and "assetId" in data: + _id = data["assetId"] + _data_format = data["dataFormat"] + _file_name = f"{_id}.{_data_format}" + + return Asset(_name, _file_name) + + def to_json(self) -> dict: + return { + "name": self.name, + + "assetId": self.id, + "md5ext": self.file_name, + "dataFormat": self.data_format, + } + + """ + @staticmethod + def from_file(fp: str, name: str = None): + image_types = ("png", "jpg", "jpeg", "svg") + sound_types = ("wav", "mp3") + + # Should save data as well so it can be uploaded to scratch if required (add to project asset data) + ... + """ + + +class Costume(Asset): + def __init__(self, + name: str = "Cat", + file_name: str = "b7853f557e4426412e64bb3da6531a99.svg", + + bitmap_resolution=None, + rotation_center_x: int | float = 48, + rotation_center_y: int | float = 50, + _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): + """ + A costume. An asset with additional properties + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Costumes + """ + super().__init__(name, file_name, _sprite) + + self.bitmap_resolution = bitmap_resolution + self.rotation_center_x = rotation_center_x + self.rotation_center_y = rotation_center_y + + @staticmethod + def from_json(data): + _asset_load = Asset.from_json(data) + + bitmap_resolution = data.get("bitmapResolution") + + rotation_center_x = data["rotationCenterX"] + rotation_center_y = data["rotationCenterY"] + return Costume(_asset_load.name, _asset_load.file_name, + + bitmap_resolution, rotation_center_x, rotation_center_y) + + def to_json(self) -> dict: + _json = super().to_json() + _json.update({ + "bitmapResolution": self.bitmap_resolution, + "rotationCenterX": self.rotation_center_x, + "rotationCenterY": self.rotation_center_y + }) + return _json + + +class Sound(Asset): + def __init__(self, + name: str = "pop", + file_name: str = "83a9787d4cb6f3b7632b4ddfebf74367.wav", + + rate: int = None, + sample_count: int = None, + _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): + """ + A sound. An asset with additional properties + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Sounds + """ + super().__init__(name, file_name, _sprite) + + self.rate = rate + self.sample_count = sample_count + + @staticmethod + def from_json(data): + _asset_load = Asset.from_json(data) + + rate = data.get("rate") + sample_count = data.get("sampleCount") + return Sound(_asset_load.name, _asset_load.file_name, rate, sample_count) + + def to_json(self) -> dict: + _json = super().to_json() + commons.noneless_update(_json, { + "rate": self.rate, + "sampleCount": self.sample_count + }) + return _json diff --git a/scratchattach/editor/backpack_json.py b/scratchattach/editor/backpack_json.py new file mode 100644 index 00000000..5e101ea6 --- /dev/null +++ b/scratchattach/editor/backpack_json.py @@ -0,0 +1,117 @@ +""" +Module to deal with the backpack's weird JSON format, by overriding with new load methods +""" +from __future__ import annotations + +from . import block, prim, field, inputs, mutation, sprite + + +def parse_prim_fields(_fields: dict[str]) -> tuple[str | None, str | None, str | None]: + """ + Function for reading the fields in a backpack **primitive** + """ + for key, value in _fields.items(): + key: str + value: dict[str, str] + prim_value, prim_name, prim_id = (None,) * 3 + if key == "NUM": + prim_value = value.get("value") + else: + prim_name = value.get("value") + prim_id = value.get("id") + + # There really should only be 1 item, and this function can only return for that item + return prim_value, prim_name, prim_id + return (None,) * 3 + + +class BpField(field.Field): + """ + A normal field but with a different load method + """ + + @staticmethod + def from_json(data: dict[str, str]) -> field.Field: + # We can very simply convert it to the regular format + data = [data.get("value"), data.get("id")] + return field.Field.from_json(data) + + +class BpInput(inputs.Input): + """ + A normal input but with a different load method + """ + + @staticmethod + def from_json(data: dict[str, str]) -> inputs.Input: + # The actual data is stored in a separate prim block + _id = data.get("shadow") + _obscurer_id = data.get("block") + + if _obscurer_id == _id: + # If both the shadow and obscurer are the same, then there is no actual obscurer + _obscurer_id = None + # We cannot work out the shadow status yet since that is located in the primitive + return inputs.Input(None, _id=_id, _obscurer_id=_obscurer_id) + + +class BpBlock(block.Block): + """ + A normal block but with a different load method + """ + + @staticmethod + def from_json(data: dict) -> prim.Prim | block.Block: + """ + Load a block in the **backpack** JSON format + :param data: A dictionary (not list) + :return: A new block/prim object + """ + _opcode = data["opcode"] + + _x, _y = data.get("x"), data.get("y") + if prim.is_prim_opcode(_opcode): + # This is actually a prim + prim_value, prim_name, prim_id = parse_prim_fields(data["fields"]) + return prim.Prim(prim.PrimTypes.find(_opcode, "opcode"), + prim_value, prim_name, prim_id) + + _next_id = data.get("next") + _parent_id = data.get("parent") + + _shadow = data.get("shadow", False) + _top_level = data.get("topLevel", _parent_id is None) + + _inputs = {} + for _input_code, _input_data in data.get("inputs", {}).items(): + _inputs[_input_code] = BpInput.from_json(_input_data) + + _fields = {} + for _field_code, _field_data in data.get("fields", {}).items(): + _fields[_field_code] = BpField.from_json(_field_data) + + if "mutation" in data: + _mutation = mutation.Mutation.from_json(data["mutation"]) + else: + _mutation = None + + return block.Block(_opcode, _shadow, _top_level, _mutation, _fields, _inputs, _x, _y, _next_id=_next_id, + _parent_id=_parent_id) + + +def load_script(_script_data: list[dict]) -> sprite.Sprite: + """ + Loads a script into a sprite from the backpack JSON format + :param _script_data: Backpack script JSON data + :return: a blockchain object containing the script + """ + # Using a sprite since it simplifies things, e.g. local global loading + _blockchain = sprite.Sprite() + + for _block_data in _script_data: + _block = BpBlock.from_json(_block_data) + _block.sprite = _blockchain + _blockchain.blocks[_block_data["id"]] = _block + + _blockchain.link_subcomponents() + return _blockchain diff --git a/scratchattach/editor/base.py b/scratchattach/editor/base.py new file mode 100644 index 00000000..58bac9df --- /dev/null +++ b/scratchattach/editor/base.py @@ -0,0 +1,142 @@ +""" +Editor base classes +""" + +from __future__ import annotations + +import copy +import json +from abc import ABC, abstractmethod +from io import TextIOWrapper +from typing import Any, TYPE_CHECKING, BinaryIO + +if TYPE_CHECKING: + from . import project, sprite, block, mutation, asset + +from . import build_defaulting + + +class Base(ABC): + def dcopy(self): + """ + :return: A **deep** copy of self + """ + return copy.deepcopy(self) + + def copy(self): + """ + :return: A **shallow** copy of self + """ + return copy.copy(self) + + +class JSONSerializable(Base, ABC): + @staticmethod + @abstractmethod + def from_json(data: dict | list | Any): + pass + + @abstractmethod + def to_json(self) -> dict | list | Any: + pass + + def save_json(self, name: str = ''): + data = self.to_json() + with open(f"{self.__class__.__name__.lower()}{name}.json", "w") as f: + json.dump(data, f) + + +class JSONExtractable(JSONSerializable, ABC): + @staticmethod + @abstractmethod + def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: str = None) -> tuple[ + str, list[asset.AssetFile], str]: + """ + Automatically extracts the JSON data as a string, as well as providing auto naming + :param data: Either a string of JSON, sb3 file as bytes or as a file object + :param load_assets: Whether to extract assets as well (if applicable) + :param _name: Any provided name (will automatically find one otherwise) + :return: tuple of the name, asset data & json as a string + """ + ... + + +class ProjectSubcomponent(JSONSerializable, ABC): + def __init__(self, _project: project.Project = None): + self.project = _project + + +class SpriteSubComponent(JSONSerializable, ABC): + def __init__(self, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): + if _sprite is build_defaulting.SPRITE_DEFAULT: + _sprite = build_defaulting.current_sprite() + + self.sprite = _sprite + + # @property + # def sprite(self): + # if self._sprite is None: + # print("ok, ", build_defaulting.current_sprite()) + # return build_defaulting.current_sprite() + # else: + # return self._sprite + + # @sprite.setter + # def sprite(self, value): + # self._sprite = value + + @property + def project(self) -> project.Project: + return self.sprite.project + + +class IDComponent(SpriteSubComponent, ABC): + def __init__(self, _id: str, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): + self.id = _id + super().__init__(_sprite) + + def __repr__(self): + return f"<{self.__class__.__name__}: {self.id}>" + + +class NamedIDComponent(IDComponent, ABC): + """ + Base class for Variables, Lists and Broadcasts (Name + ID + sprite) + """ + + def __init__(self, _id: str, name: str, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): + self.name = name + super().__init__(_id, _sprite) + + def __repr__(self): + return f"<{self.__class__.__name__} '{self.name}'>" + + +class BlockSubComponent(JSONSerializable, ABC): + def __init__(self, _block: block.Block = None): + self.block = _block + + @property + def sprite(self) -> sprite.Sprite: + return self.block.sprite + + @property + def project(self) -> project.Project: + return self.sprite.project + + +class MutationSubComponent(JSONSerializable, ABC): + def __init__(self, _mutation: mutation.Mutation = None): + self.mutation = _mutation + + @property + def block(self) -> block.Block: + return self.mutation.block + + @property + def sprite(self) -> sprite.Sprite: + return self.block.sprite + + @property + def project(self) -> project.Project: + return self.sprite.project diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py new file mode 100644 index 00000000..fa43a8c2 --- /dev/null +++ b/scratchattach/editor/block.py @@ -0,0 +1,507 @@ +from __future__ import annotations + +import warnings +from typing import Iterable, Self + +from . import base, sprite, mutation, field, inputs, commons, vlb, blockshape, prim, comment, build_defaulting +from ..utils import exceptions + + +class Block(base.SpriteSubComponent): + def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = None, + _mutation: mutation.Mutation = None, _fields: dict[str, field.Field] = None, + _inputs: dict[str, inputs.Input] = None, x: int = 0, y: int = 0, pos: tuple[int, int] = None, + + _next: Block = None, _parent: Block = None, + *, _next_id: str = None, _parent_id: str = None, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): + # Defaulting for args + if _fields is None: + _fields = {} + if _inputs is None: + _inputs = {} + + if pos is not None: + x, y = pos + + self.opcode = _opcode + self.is_shadow = _shadow + self.is_top_level = _top_level + + self.x, self.y = x, y + + self.mutation = _mutation + self.fields = _fields + self.inputs = _inputs + + self._next_id = _next_id + """ + Temporarily stores id of next block. Will be used later during project instantiation to find the next block object + """ + self._parent_id = _parent_id + """ + Temporarily stores id of parent block. Will be used later during project instantiation to find the parent block object + """ + + self.next = _next + self.parent = _parent + + self.check_toplevel() + + super().__init__(_sprite) + self.link_subcomponents() + + def __repr__(self): + return f"Block<{self.opcode!r}>" + + def link_subcomponents(self): + if self.mutation: + self.mutation.block = self + + for iterable in (self.fields.values(), self.inputs.values()): + for subcomponent in iterable: + subcomponent.block = self + + def add_input(self, name: str, _input: inputs.Input) -> Self: + self.inputs[name] = _input + for val in (_input.value, _input.obscurer): + if isinstance(val, Block): + val.parent = self + return self + + def add_field(self, name: str, _field: field.Field) -> Self: + self.fields[name] = _field + return self + + def set_mutation(self, _mutation: mutation.Mutation) -> Self: + self.mutation = _mutation + _mutation.block = self + _mutation.link_arguments() + return self + + def set_comment(self, _comment: comment.Comment) -> Self: + _comment.block = self + self.sprite.add_comment(_comment) + + return self + + def check_toplevel(self): + self.is_top_level = self.parent is None + + if not self.is_top_level: + self.x, self.y = None, None + + @property + def target(self): + """ + Alias for sprite + """ + return self.sprite + + @property + def block_shape(self) -> blockshape.BlockShape: + """ + Search for the blockshape stored in blockshape.py + :return: The block's block shape (by opcode) + """ + _shape = blockshape.BlockShapes.find(self.opcode, "opcode") + if _shape is None: + warnings.warn(f"No blockshape {self.opcode!r} exists! Defaulting to {blockshape.BlockShapes.UNDEFINED}") + return blockshape.BlockShapes.UNDEFINED + return _shape + + @property + def can_next(self): + """ + :return: Whether the block *can* have a next block (basically checks if it's not a cap block, also considering the behaviour of control_stop) + """ + _shape = self.block_shape + if _shape.is_cap is not blockshape.MUTATION_DEPENDENT: + return _shape.is_attachable + else: + if self.mutation is None: + # If there's no mutation, let's just assume yes + warnings.warn(f"{self} has no mutation! Assuming we can add block ;-;") + return True + + return self.mutation.has_next + + @property + def id(self) -> str | None: + """ + Work out the id of this block by searching through the sprite dictionary + """ + # warnings.warn(f"Using block IDs can cause consistency issues and is not recommended") + # This property is used when converting comments to JSON (we don't want random warning when exporting a project) + for _block_id, _block in self.sprite.blocks.items(): + if _block is self: + return _block_id + + # Let's just automatically assign ourselves an id + self.sprite.add_block(self) + + @property + def parent_id(self): + if self.parent is not None: + return self.parent.id + else: + return None + + @property + def next_id(self): + if self.next is not None: + return self.next.id + else: + return None + + @property + def relatives(self) -> list[Block]: + """ + :return: A list of blocks which are related to this block (e.g. parent, next, inputs) + """ + _ret = [] + + def yield_block(_block: Block | None): + if isinstance(_block, Block): + _ret.append(_block) + + yield_block(self.next) + yield_block(self.parent) + + return _ret + + @property + def children(self) -> list[Block | prim.Prim]: + """ + :return: A list of blocks that are inside of this block, **NOT INCLUDING THE ATTACHED BLOCK** + """ + _children = [] + for _input in self.inputs.values(): + if isinstance(_input.value, Block) or isinstance(_input.value, prim.Prim): + _children.append(_input.value) + + if _input.obscurer is not None: + _children.append(_input.obscurer) + return _children + + @property + def previous_chain(self): + if self.parent is None: + return [self] + + return [self] + self.parent.previous_chain + + @property + def attached_chain(self): + if self.next is None: + return [self] + + return [self] + self.next.attached_chain + + @property + def complete_chain(self): + # Both previous and attached chains start with self + return self.previous_chain[:1:-1] + self.attached_chain + + @property + def top_level_block(self): + """ + same as the old stack_parent property from sbedtior v1 + """ + return self.previous_chain[-1] + + @property + def bottom_level_block(self): + return self.attached_chain[-1] + + @property + def stack_tree(self): + """ + :return: A tree-like nested list structure representing the stack of blocks, including inputs, starting at this block + """ + _tree = [self] + for child in self.children: + if isinstance(child, prim.Prim): + _tree.append(child) + elif isinstance(child, Block): + _tree.append(child.stack_tree) + + if self.next: + _tree += self.next.stack_tree + + return _tree + + @property + def category(self): + """ + Works out what category of block this is using the opcode. Does not perform validation + """ + return self.opcode.split('_')[0] + + @property + def is_input(self): + """ + :return: Whether this block is an input obscurer or value + """ + return self.parent_input is not None + + @property + def is_next_block(self): + """ + :return: Whether this block is attached (as next block) to a previous block and not an input + """ + return self.parent and not self.is_input + + @property + def parent_input(self): + if not self.parent: + return None + + for _input in self.parent.inputs.values(): + if _input.obscurer is self or _input.value is self: + return _input + return None + + @property + def new_id(self): + return self.sprite.new_id + + @property + def comment(self) -> comment.Comment | None: + for _comment in self.sprite.comments: + if _comment.block is self: + return _comment + return None + + @property + def turbowarp_block_opcode(self): + """ + :return: The 'opcode' if this is a turbowarp block: e.g. + - log + - breakpoint + - error + - warn + - is compiled? + - is turbowarp? + - is forkphorus? + If it's not one, just returns None + """ + if self.opcode == "procedures_call": + if self.mutation: + if self.mutation.proc_code: + # \u200B is a zero-width space + if self.mutation.proc_code == "\u200B\u200Bbreakpoint\u200B\u200B": + return "breakpoint" + elif self.mutation.proc_code == "\u200B\u200Blog\u200B\u200B %s": + return "log" + elif self.mutation.proc_code == "\u200B\u200Berror\u200B\u200B %s": + return "error" + elif self.mutation.proc_code == "\u200B\u200Bwarn\u200B\u200B %s": + return "warn" + + elif self.opcode == "argument_reporter_boolean": + arg = self.fields.get("VALUE") + + if arg is not None: + arg = arg.value + if isinstance(arg, str): + arg = arg.lower() + + if arg == "is turbowarp?": + return "is_turbowarp?" + + elif arg == "is compiled?": + return "is_compiled?" + + elif arg == "is forkphorus?": + return "is_forkphorus?" + + return None + + @property + def is_turbowarp_block(self): + return self.turbowarp_block_opcode is not None + + @staticmethod + def from_json(data: dict) -> Block: + """ + Load a block from the JSON dictionary. + :param data: a dictionary (not list) + :return: The new Block object + """ + _opcode = data["opcode"] + + _x, _y = data.get("x"), data.get("y") + + _next_id = data.get("next") + _parent_id = data.get("parent") + + _shadow = data.get("shadow", False) + _top_level = data.get("topLevel", _parent_id is None) + + _inputs = {} + for _input_code, _input_data in data.get("inputs", {}).items(): + _inputs[_input_code] = inputs.Input.from_json(_input_data) + + _fields = {} + for _field_code, _field_data in data.get("fields", {}).items(): + _fields[_field_code] = field.Field.from_json(_field_data) + + if "mutation" in data: + _mutation = mutation.Mutation.from_json(data["mutation"]) + else: + _mutation = None + + return Block(_opcode, _shadow, _top_level, _mutation, _fields, _inputs, _x, _y, _next_id=_next_id, + _parent_id=_parent_id) + + def to_json(self) -> dict: + self.check_toplevel() + + _json = { + "opcode": self.opcode, + "next": self.next_id, + "parent": self.parent_id, + "inputs": {_id: _input.to_json() for _id, _input in self.inputs.items()}, + "fields": {_id: _field.to_json() for _id, _field in self.fields.items()}, + "shadow": self.is_shadow, + "topLevel": self.is_top_level, + } + _comment = self.comment + if _comment: + commons.noneless_update(_json, { + "comment": _comment.id + }) + + if self.is_top_level: + commons.noneless_update(_json, { + "x": self.x, + "y": self.y, + }) + + if self.mutation is not None: + commons.noneless_update(_json, { + "mutation": self.mutation.to_json(), + }) + + return _json + + def link_using_sprite(self, link_subs: bool = True): + if link_subs: + self.link_subcomponents() + + if self.mutation: + self.mutation.link_arguments() + + if self._parent_id is not None: + self.parent = self.sprite.find_block(self._parent_id, "id") + if self.parent is not None: + self._parent_id = None + + if self._next_id is not None: + self.next = self.sprite.find_block(self._next_id, "id") + if self.next is not None: + self._next_id = None + + for _block in self.relatives: + _block.sprite = self.sprite + + for _field in self.fields.values(): + if _field.id is not None: + new_value = self.sprite.find_vlb(_field.id, "id") + if new_value is None: + # We probably need to add a local global variable + _type = _field.type + + if _type == field.Types.VARIABLE: + # Create a new variable + new_value = vlb.Variable(commons.gen_id(), + _field.value) + elif _type == field.Types.LIST: + # Create a list + new_value = vlb.List(commons.gen_id(), + _field.value) + elif _type == field.Types.BROADCAST: + # Create a broadcast + new_value = vlb.Broadcast(commons.gen_id(), + _field.value) + else: + # Something probably went wrong + warnings.warn( + f"Could not find {_field.id!r} in {self.sprite}. Can't create a new {_type} so we gave a warning") + + if new_value is not None: + self.sprite.add_local_global(new_value) + + # Check again since there may have been a newly created VLB + if new_value is not None: + _field.value = new_value + _field.id = None + + for _input in self.inputs.values(): + _input.link_using_block() + + # Adding/removing block + def attach_block(self, new: Block) -> Block: + if not self.can_next: + raise exceptions.BadBlockShape(f"{self.block_shape} cannot be stacked onto") + elif new.block_shape.is_hat or not new.block_shape.is_stack: + raise exceptions.BadBlockShape(f"{new.block_shape} is not stackable") + + new.parent = self + new.next = self.next + + self.next = new + + new.check_toplevel() + self.sprite.add_block(new) + + return new + + def duplicate_single_block(self) -> Block: + return self.attach_block(self.dcopy()) + + def attach_chain(self, *chain: Iterable[Block]) -> Block: + attaching_block = self + for _block in chain: + attaching_block = attaching_block.attach_block(_block) + + return attaching_block + + def duplicate_chain(self) -> Block: + return self.bottom_level_block.attach_chain( + *map(Block.dcopy, self.attached_chain) + ) + + def slot_above(self, new: Block) -> Block: + if not new.can_next: + raise exceptions.BadBlockShape(f"{new.block_shape} cannot be stacked onto") + + elif self.block_shape.is_hat or not self.block_shape.is_stack: + raise exceptions.BadBlockShape(f"{self.block_shape} is not stackable") + + new.parent, new.next = self.parent, self + + self.parent = new + + if new.parent: + new.parent.next = new + + return self.sprite.add_block(new) + + def delete_single_block(self): + if self.is_next_block: + self.parent.next = self.next + + if self.next: + self.next.parent = self.parent + + if self.is_top_level: + self.next.is_top_level = True + self.next.x, self.next.y = self.next.x, self.next.y + + self.sprite.remove_block(self) + + def delete_chain(self): + for _block in self.attached_chain: + _block.delete_single_block() + diff --git a/scratchattach/editor/blockshape.py b/scratchattach/editor/blockshape.py new file mode 100644 index 00000000..ebfa6614 --- /dev/null +++ b/scratchattach/editor/blockshape.py @@ -0,0 +1,353 @@ +""" +Enums stating the shape of a block from its opcode (i.e: stack, c-mouth, cap, hat etc) +""" +from __future__ import annotations + +# Perhaps this should be merged with pallet.py +from dataclasses import dataclass +from typing import Final + +from . import commons +from ..utils.enums import _EnumWrapper + + +class _MutationDependent(commons.Singleton): + def __bool__(self): + raise TypeError("Need mutation data to work out attribute value.") + + +MUTATION_DEPENDENT: Final[_MutationDependent] = _MutationDependent() +"""Value used when mutation data is required to work out the attribute value""" + + +@dataclass(init=True, repr=True) +class BlockShape: + """ + A class that describes the shape of a block; e.g. is it a stack, c-mouth, cap, hat reporter, boolean or menu block? + """ + is_stack: bool | _MutationDependent = False # Most blocks - e.g. move [10] steps + is_c_mouth: bool | _MutationDependent = False # Has substack - e.g. repeat + is_cap: bool | _MutationDependent = False # No next - e.g. forever + is_hat: bool | _MutationDependent = False # No parent - e.g. when gf clicked + is_reporter: bool | _MutationDependent = False # (reporter) + is_boolean: bool | _MutationDependent = False # + is_menu: bool | _MutationDependent = False # Shadow reporters, e.g. costume menu + opcode: str = None + + @property + def is_attachable(self): + if self.is_cap is MUTATION_DEPENDENT: + raise TypeError( + "Can't tell if the block is attachable because we can't be sure if it is a cap block or not (stop block)") + return not self.is_cap and not self.is_reporter + + +class BlockShapes(_EnumWrapper): + MOTION_MOVESTEPS = BlockShape(is_stack=True, opcode="motion_movesteps") + MOTION_TURNRIGHT = BlockShape(is_stack=True, opcode="motion_turnright") + MOTION_TURNLEFT = BlockShape(is_stack=True, opcode="motion_turnleft") + MOTION_GOTO = BlockShape(is_stack=True, opcode="motion_goto") + MOTION_GOTOXY = BlockShape(is_stack=True, opcode="motion_gotoxy") + MOTION_GLIDETO = BlockShape(is_stack=True, opcode="motion_glideto") + MOTION_GLIDESECSTOXY = BlockShape(is_stack=True, opcode="motion_glidesecstoxy") + MOTION_POINTINDIRECTION = BlockShape(is_stack=True, opcode="motion_pointindirection") + MOTION_POINTTOWARDS = BlockShape(is_stack=True, opcode="motion_pointtowards") + MOTION_CHANGEXBY = BlockShape(is_stack=True, opcode="motion_changexby") + MOTION_SETX = BlockShape(is_stack=True, opcode="motion_setx") + MOTION_CHANGEYBY = BlockShape(is_stack=True, opcode="motion_changeyby") + MOTION_SETY = BlockShape(is_stack=True, opcode="motion_sety") + MOTION_IFONEDGEBOUNCE = BlockShape(is_stack=True, opcode="motion_ifonedgebounce") + MOTION_SETROTATIONSTYLE = BlockShape(is_stack=True, opcode="motion_setrotationstyle") + MOTION_XPOSITION = BlockShape(is_reporter=True, opcode="motion_xposition") + MOTION_YPOSITION = BlockShape(is_reporter=True, opcode="motion_yposition") + MOTION_DIRECTION = BlockShape(is_reporter=True, opcode="motion_direction") + MOTION_SCROLL_RIGHT = BlockShape(is_stack=True, opcode="motion_scroll_right") + MOTION_SCROLL_UP = BlockShape(is_stack=True, opcode="motion_scroll_up") + MOTION_ALIGN_SCENE = BlockShape(is_stack=True, opcode="motion_align_scene") + MOTION_XSCROLL = BlockShape(is_reporter=True, opcode="motion_xscroll") + MOTION_YSCROLL = BlockShape(is_reporter=True, opcode="motion_yscroll") + MOTION_GOTO_MENU = BlockShape(is_reporter=True, is_menu=True, opcode="motion_goto_menu") + MOTION_GLIDETO_MENU = BlockShape(is_reporter=True, is_menu=True, opcode="motion_glideto_menu") + MOTION_POINTTOWARDS_MENU = BlockShape(is_reporter=True, is_menu=True, opcode="motion_pointtowards_menu") + + LOOKS_SAYFORSECS = BlockShape(is_stack=True, opcode="looks_sayforsecs") + LOOKS_SAY = BlockShape(is_stack=True, opcode="looks_say") + LOOKS_THINKFORSECS = BlockShape(is_stack=True, opcode="looks_thinkforsecs") + LOOKS_THINK = BlockShape(is_stack=True, opcode="looks_think") + LOOKS_SWITCHCOSTUMETO = BlockShape(is_stack=True, opcode="looks_switchcostumeto") + LOOKS_NEXTCOSTUME = BlockShape(is_stack=True, opcode="looks_nextcostume") + LOOKS_SWITCHBACKDROPTO = BlockShape(is_stack=True, opcode="looks_switchbackdropto") + LOOKS_SWITCHBACKDROPTOANDWAIT = BlockShape(is_stack=True, opcode="looks_switchbackdroptoandwait") + LOOKS_NEXTBACKDROP = BlockShape(is_stack=True, opcode="looks_nextbackdrop") + LOOKS_CHANGESIZEBY = BlockShape(is_stack=True, opcode="looks_changesizeby") + LOOKS_SETSIZETO = BlockShape(is_stack=True, opcode="looks_setsizeto") + LOOKS_CHANGEEFFECTBY = BlockShape(is_stack=True, opcode="looks_changeeffectby") + LOOKS_SETEFFECTTO = BlockShape(is_stack=True, opcode="looks_seteffectto") + LOOKS_CLEARGRAPHICEFFECTS = BlockShape(is_stack=True, opcode="looks_cleargraphiceffects") + LOOKS_SHOW = BlockShape(is_stack=True, opcode="looks_show") + LOOKS_HIDE = BlockShape(is_stack=True, opcode="looks_hide") + LOOKS_GOTOFRONTBACK = BlockShape(is_stack=True, opcode="looks_gotofrontback") + LOOKS_GOFORWARDBACKWARDLAYERS = BlockShape(is_stack=True, opcode="looks_goforwardbackwardlayers") + LOOKS_COSTUMENUMBERNAME = BlockShape(is_reporter=True, opcode="looks_costumenumbername") + LOOKS_BACKDROPNUMBERNAME = BlockShape(is_reporter=True, opcode="looks_backdropnumbername") + LOOKS_SIZE = BlockShape(is_reporter=True, opcode="looks_size") + LOOKS_HIDEALLSPRITES = BlockShape(is_stack=True, opcode="looks_hideallsprites") + LOOKS_SETSTRETCHTO = BlockShape(is_stack=True, opcode="looks_setstretchto") + LOOKS_CHANGESTRETCHBY = BlockShape(is_stack=True, opcode="looks_changestretchby") + LOOKS_COSTUME = BlockShape(is_reporter=True, is_menu=True, opcode="looks_costume") + LOOKS_BACKDROPS = BlockShape(is_reporter=True, is_menu=True, opcode="looks_backdrops") + + SOUND_PLAYUNTILDONE = BlockShape(is_stack=True, opcode="sound_playuntildone") + SOUND_PLAY = BlockShape(is_stack=True, opcode="sound_play") + SOUND_STOPALLSOUNDS = BlockShape(is_stack=True, opcode="sound_stopallsounds") + SOUND_CHANGEEFFECTBY = BlockShape(is_stack=True, opcode="sound_changeeffectby") + SOUND_SETEFFECTTO = BlockShape(is_stack=True, opcode="sound_seteffectto") + SOUND_CLEAREFFECTS = BlockShape(is_stack=True, opcode="sound_cleareffects") + SOUND_CHANGEVOLUMEBY = BlockShape(is_stack=True, opcode="sound_changevolumeby") + SOUND_SETVOLUMETO = BlockShape(is_stack=True, opcode="sound_setvolumeto") + SOUND_VOLUME = BlockShape(is_reporter=True, opcode="sound_volume") + SOUND_SOUNDS_MENU = BlockShape(is_reporter=True, is_menu=True, opcode="sound_sounds_menu") + + EVENT_WHENFLAGCLICKED = BlockShape(is_hat=True, opcode="event_whenflagclicked") + EVENT_WHENKEYPRESSED = BlockShape(is_hat=True, opcode="event_whenkeypressed") + EVENT_WHENTHISSPRITECLICKED = BlockShape(is_hat=True, opcode="event_whenthisspriteclicked") + EVENT_WHENSTAGECLICKED = BlockShape(is_hat=True, opcode="event_whenstageclicked") + EVENT_WHENBACKDROPSWITCHESTO = BlockShape(is_hat=True, opcode="event_whenbackdropswitchesto") + EVENT_WHENGREATERTHAN = BlockShape(is_hat=True, opcode="event_whengreaterthan") + EVENT_WHENBROADCASTRECEIVED = BlockShape(is_hat=True, opcode="event_whenbroadcastreceived") + EVENT_BROADCAST = BlockShape(is_stack=True, opcode="event_broadcast") + EVENT_BROADCASTANDWAIT = BlockShape(is_stack=True, opcode="event_broadcastandwait") + EVENT_WHENTOUCHINGOBJECT = BlockShape(is_hat=True, opcode="event_whentouchingobject") + EVENT_BROADCAST_MENU = BlockShape(is_reporter=True, is_menu=True, opcode="event_broadcast_menu") + EVENT_TOUCHINGOBJECTMENU = BlockShape(is_reporter=True, is_menu=True, opcode="event_touchingobjectmenu") + + CONTROL_WAIT = BlockShape(is_stack=True, opcode="control_wait") + CONTROL_FOREVER = BlockShape(is_c_mouth=True, is_stack=True, is_cap=True, opcode="control_forever") + CONTROL_IF = BlockShape(is_c_mouth=True, is_stack=True, opcode="control_if") + CONTROL_IF_ELSE = BlockShape(is_c_mouth=True, is_stack=True, opcode="control_if_else") + CONTROL_WAIT_UNTIL = BlockShape(is_stack=True, opcode="control_wait_until") + CONTROL_REPEAT_UNTIL = BlockShape(is_c_mouth=True, is_stack=True, opcode="control_repeat_until") + CONTROL_STOP = BlockShape(is_stack=True, is_cap=MUTATION_DEPENDENT, opcode="control_stop") + CONTROL_START_AS_CLONE = BlockShape(is_hat=True, opcode="control_start_as_clone") + CONTROL_CREATE_CLONE_OF = BlockShape(is_stack=True, opcode="control_create_clone_of") + CONTROL_DELETE_THIS_CLONE = BlockShape(is_stack=True, is_cap=True, opcode="control_delete_this_clone") + CONTROL_FOR_EACH = BlockShape(is_c_mouth=True, is_stack=True, opcode="control_for_each") + CONTROL_WHILE = BlockShape(is_c_mouth=True, is_stack=True, opcode="control_while") + CONTROL_GET_COUNTER = BlockShape(is_reporter=True, opcode="control_get_counter") + CONTROL_INCR_COUNTER = BlockShape(is_stack=True, opcode="control_incr_counter") + CONTROL_CLEAR_COUNTER = BlockShape(is_stack=True, opcode="control_clear_counter") + CONTROL_ALL_AT_ONCE = BlockShape(is_c_mouth=True, is_stack=True, opcode="control_all_at_once") + CONTROL_CREATE_CLONE_OF_MENU = BlockShape(is_reporter=True, is_menu=True, opcode="control_create_clone_of_menu") + + SENSING_TOUCHINGOBJECT = BlockShape(is_reporter=True, is_boolean=True, opcode="sensing_touchingobject") + SENSING_TOUCHINGCOLOR = BlockShape(is_reporter=True, is_boolean=True, opcode="sensing_touchingcolor") + SENSING_COLORISTOUCHINGCOLOR = BlockShape(is_reporter=True, is_boolean=True, opcode="sensing_coloristouchingcolor") + SENSING_DISTANCETO = BlockShape(is_reporter=True, opcode="sensing_distanceto") + SENSING_ASKANDWAIT = BlockShape(is_stack=True, opcode="sensing_askandwait") + SENSING_ANSWER = BlockShape(is_reporter=True, opcode="sensing_answer") + SENSING_KEYPRESSED = BlockShape(is_reporter=True, is_boolean=True, opcode="sensing_keypressed") + SENSING_MOUSEDOWN = BlockShape(is_reporter=True, is_boolean=True, opcode="sensing_mousedown") + SENSING_MOUSEX = BlockShape(is_reporter=True, opcode="sensing_mousex") + SENSING_MOUSEY = BlockShape(is_reporter=True, opcode="sensing_mousey") + SENSING_SETDRAGMODE = BlockShape(is_stack=True, opcode="sensing_setdragmode") + SENSING_LOUDNESS = BlockShape(is_reporter=True, opcode="sensing_loudness") + SENSING_TIMER = BlockShape(is_reporter=True, opcode="sensing_timer") + SENSING_RESETTIMER = BlockShape(is_stack=True, opcode="sensing_resettimer") + SENSING_OF = BlockShape(is_reporter=True, opcode="sensing_of") + SENSING_CURRENT = BlockShape(is_reporter=True, opcode="sensing_current") + SENSING_DAYSSINCE2000 = BlockShape(is_reporter=True, opcode="sensing_dayssince2000") + SENSING_USERNAME = BlockShape(is_reporter=True, opcode="sensing_username") + SENSING_LOUD = BlockShape(is_reporter=True, is_boolean=True, opcode="sensing_loud") + SENSING_USERID = BlockShape(is_reporter=True, opcode="sensing_userid") + SENSING_TOUCHINGOBJECTMENU = BlockShape(is_reporter=True, is_menu=True, opcode="sensing_touchingobjectmenu") + SENSING_DISTANCETOMENU = BlockShape(is_reporter=True, is_menu=True, opcode="sensing_distancetomenu") + SENSING_KEYOPTIONS = BlockShape(is_reporter=True, is_menu=True, opcode="sensing_keyoptions") + SENSING_OF_OBJECT_MENU = BlockShape(is_reporter=True, is_menu=True, opcode="sensing_of_object_menu") + + OPERATOR_ADD = BlockShape(is_reporter=True, opcode="operator_add") + OPERATOR_SUBTRACT = BlockShape(is_reporter=True, opcode="operator_subtract") + OPERATOR_MULTIPLY = BlockShape(is_reporter=True, opcode="operator_multiply") + OPERATOR_DIVIDE = BlockShape(is_reporter=True, opcode="operator_divide") + OPERATOR_RANDOM = BlockShape(is_reporter=True, opcode="operator_random") + OPERATOR_GT = BlockShape(is_reporter=True, is_boolean=True, opcode="operator_gt") + OPERATOR_LT = BlockShape(is_reporter=True, is_boolean=True, opcode="operator_lt") + OPERATOR_EQUALS = BlockShape(is_reporter=True, is_boolean=True, opcode="operator_equals") + OPERATOR_AND = BlockShape(is_reporter=True, is_boolean=True, opcode="operator_and") + OPERATOR_OR = BlockShape(is_reporter=True, is_boolean=True, opcode="operator_or") + OPERATOR_NOT = BlockShape(is_reporter=True, is_boolean=True, opcode="operator_not") + OPERATOR_JOIN = BlockShape(is_reporter=True, opcode="operator_join") + OPERATOR_LETTER_OF = BlockShape(is_reporter=True, opcode="operator_letter_of") + OPERATOR_LENGTH = BlockShape(is_reporter=True, opcode="operator_length") + OPERATOR_CONTAINS = BlockShape(is_reporter=True, is_boolean=True, opcode="operator_contains") + OPERATOR_MOD = BlockShape(is_reporter=True, opcode="operator_mod") + OPERATOR_ROUND = BlockShape(is_reporter=True, opcode="operator_round") + OPERATOR_MATHOP = BlockShape(is_reporter=True, opcode="operator_mathop") + + DATA_VARIABLE = BlockShape(is_reporter=True, opcode="data_variable") + DATA_SETVARIABLETO = BlockShape(is_stack=True, opcode="data_setvariableto") + DATA_CHANGEVARIABLEBY = BlockShape(is_stack=True, opcode="data_changevariableby") + DATA_SHOWVARIABLE = BlockShape(is_stack=True, opcode="data_showvariable") + DATA_HIDEVARIABLE = BlockShape(is_stack=True, opcode="data_hidevariable") + DATA_LISTCONTENTS = BlockShape(is_reporter=True, opcode="data_listcontents") + DATA_ADDTOLIST = BlockShape(is_stack=True, opcode="data_addtolist") + DATA_DELETEOFLIST = BlockShape(is_stack=True, opcode="data_deleteoflist") + DATA_DELETEALLOFLIST = BlockShape(is_stack=True, opcode="data_deletealloflist") + DATA_INSERTATLIST = BlockShape(is_stack=True, opcode="data_insertatlist") + DATA_REPLACEITEMOFLIST = BlockShape(is_stack=True, opcode="data_replaceitemoflist") + DATA_ITEMOFLIST = BlockShape(is_reporter=True, is_boolean=True, opcode="data_itemoflist") + DATA_ITEMNUMOFLIST = BlockShape(is_reporter=True, opcode="data_itemnumoflist") + DATA_LENGTHOFLIST = BlockShape(is_reporter=True, opcode="data_lengthoflist") + DATA_LISTCONTAINSITEM = BlockShape(is_reporter=True, is_boolean=True, opcode="data_listcontainsitem") + DATA_SHOWLIST = BlockShape(is_stack=True, opcode="data_showlist") + DATA_HIDELIST = BlockShape(is_stack=True, opcode="data_hidelist") + DATA_LISTINDEXALL = BlockShape(is_reporter=True, is_menu=True, opcode="data_listindexall") + DATA_LISTINDEXRANDOM = BlockShape(is_reporter=True, is_menu=True, opcode="data_listindexrandom") + + PROCEDURES_DEFINITION = BlockShape(is_hat=True, opcode="procedures_definition") + PROCEDURES_CALL = BlockShape(is_stack=True, opcode="procedures_call") + PROCEDURES_DECLARATION = BlockShape(is_stack=True, opcode="procedures_declaration") + PROCEDURES_PROTOTYPE = BlockShape(is_stack=True, opcode="procedures_prototype") + + ARGUMENT_REPORTER_STRING_NUMBER = BlockShape(is_reporter=True, opcode="argument_reporter_string_number") + ARGUMENT_REPORTER_BOOLEAN = BlockShape(is_reporter=True, is_boolean=True, opcode="argument_reporter_boolean") + ARGUMENT_EDITOR_REPORTER = BlockShape(is_reporter=True, is_boolean=True, opcode="argument_editor_reporter") + ARGUMENT_EDITOR_STRING_NUMBER = BlockShape(is_reporter=True, opcode="argument_editor_string_number") + + MUSIC_PLAYDRUMFORBEATS = BlockShape(is_stack=True, opcode="music_playDrumForBeats") + MUSIC_RESTFORBEATS = BlockShape(is_stack=True, opcode="music_restForBeats") + MUSIC_PLAYNOTEFORBEATS = BlockShape(is_stack=True, opcode="music_playNoteForBeats") + MUSIC_SETINSTRUMENT = BlockShape(is_stack=True, opcode="music_setInstrument") + MUSIC_SETTEMPO = BlockShape(is_stack=True, opcode="music_setTempo") + MUSIC_CHANGETEMPO = BlockShape(is_stack=True, opcode="music_changeTempo") + MUSIC_GETTEMPO = BlockShape(is_reporter=True, opcode="music_getTempo") + MUSIC_MIDIPLAYDRUMFORBEATS = BlockShape(is_stack=True, opcode="music_midiPlayDrumForBeats") + MUSIC_MIDISETINSTRUMENT = BlockShape(is_stack=True, opcode="music_midiSetInstrument") + MUSIC_MENU_DRUM = BlockShape(is_reporter=True, is_menu=True, opcode="music_menu_DRUM") + MUSIC_MENU_INSTRUMENT = BlockShape(is_reporter=True, is_menu=True, opcode="music_menu_INSTRUMENT") + + PEN_CLEAR = BlockShape(is_stack=True, opcode="pen_clear") + PEN_STAMP = BlockShape(is_stack=True, opcode="pen_stamp") + PEN_PENDOWN = BlockShape(is_stack=True, opcode="pen_penDown") + PEN_PENUP = BlockShape(is_stack=True, opcode="pen_penUp") + PEN_SETPENCOLORTOCOLOR = BlockShape(is_stack=True, opcode="pen_setPenColorToColor") + PEN_CHANGEPENCOLORPARAMBY = BlockShape(is_stack=True, opcode="pen_changePenColorParamBy") + PEN_SETPENCOLORPARAMTO = BlockShape(is_stack=True, opcode="pen_setPenColorParamTo") + PEN_CHANGEPENSIZEBY = BlockShape(is_stack=True, opcode="pen_changePenSizeBy") + PEN_SETPENSIZETO = BlockShape(is_stack=True, opcode="pen_setPenSizeTo") + PEN_SETPENHUETONUMBER = BlockShape(is_stack=True, opcode="pen_setPenHueToNumber") + PEN_CHANGEPENHUEBY = BlockShape(is_stack=True, opcode="pen_changePenHueBy") + PEN_SETPENSHADETONUMBER = BlockShape(is_stack=True, opcode="pen_setPenShadeToNumber") + PEN_CHANGEPENSHADEBY = BlockShape(is_stack=True, opcode="pen_changePenShadeBy") + PEN_MENU_COLORPARAM = BlockShape(is_reporter=True, is_menu=True, opcode="pen_menu_colorParam") + + VIDEOSENSING_WHENMOTIONGREATERTHAN = BlockShape(is_hat=True, opcode="videoSensing_whenMotionGreaterThan") + VIDEOSENSING_VIDEOON = BlockShape(is_reporter=True, opcode="videoSensing_videoOn") + VIDEOSENSING_VIDEOTOGGLE = BlockShape(is_stack=True, opcode="videoSensing_videoToggle") + VIDEOSENSING_SETVIDEOTRANSPARENCY = BlockShape(is_stack=True, opcode="videoSensing_setVideoTransparency") + VIDEOSENSING_MENU_ATTRIBUTE = BlockShape(is_reporter=True, is_menu=True, opcode="videoSensing_menu_ATTRIBUTE") + VIDEOSENSING_MENU_SUBJECT = BlockShape(is_reporter=True, is_menu=True, opcode="videoSensing_menu_SUBJECT") + VIDEOSENSING_MENU_VIDEO_STATE = BlockShape(is_reporter=True, is_menu=True, opcode="videoSensing_menu_VIDEO_STATE") + + TEXT2SPEECH_SPEAKANDWAIT = BlockShape(is_stack=True, opcode="text2speech_speakAndWait") + TEXT2SPEECH_SETVOICE = BlockShape(is_stack=True, opcode="text2speech_setVoice") + TEXT2SPEECH_SETLANGUAGE = BlockShape(is_stack=True, opcode="text2speech_setLanguage") + TEXT2SPEECH_MENU_VOICES = BlockShape(is_reporter=True, is_menu=True, opcode="text2speech_menu_voices") + TEXT2SPEECH_MENU_LANGUAGES = BlockShape(is_reporter=True, is_menu=True, opcode="text2speech_menu_languages") + TRANSLATE_GETTRANSLATE = BlockShape(is_reporter=True, opcode="translate_getTranslate") + TRANSLATE_GETVIEWERLANGUAGE = BlockShape(is_reporter=True, opcode="translate_getViewerLanguage") + TRANSLATE_MENU_LANGUAGES = BlockShape(is_reporter=True, is_menu=True, opcode="translate_menu_languages") + + MAKEYMAKEY_WHENMAKEYKEYPRESSED = BlockShape(is_hat=True, opcode="makeymakey_whenMakeyKeyPressed") + MAKEYMAKEY_WHENCODEPRESSED = BlockShape(is_hat=True, opcode="makeymakey_whenCodePressed") + MAKEYMAKEY_MENU_KEY = BlockShape(is_reporter=True, is_menu=True, opcode="makeymakey_menu_KEY") + MAKEYMAKEY_MENU_SEQUENCE = BlockShape(is_reporter=True, is_menu=True, opcode="makeymakey_menu_SEQUENCE") + + MICROBIT_WHENBUTTONPRESSED = BlockShape(opcode="microbit_whenButtonPressed") + MICROBIT_ISBUTTONPRESSED = BlockShape(opcode="microbit_isButtonPressed") + MICROBIT_WHENGESTURE = BlockShape(opcode="microbit_whenGesture") + MICROBIT_DISPLAYSYMBOL = BlockShape(opcode="microbit_displaySymbol") + MICROBIT_DISPLAYTEXT = BlockShape(opcode="microbit_displayText") + MICROBIT_DISPLAYCLEAR = BlockShape(opcode="microbit_displayClear") + MICROBIT_WHENTILTED = BlockShape(opcode="microbit_whenTilted") + MICROBIT_ISTILTED = BlockShape(opcode="microbit_isTilted") + MICROBIT_GETTILTANGLE = BlockShape(opcode="microbit_getTiltAngle") + MICROBIT_WHENPINCONNECTED = BlockShape(opcode="microbit_whenPinConnected") + MICROBIT_MENU_BUTTONS = BlockShape(opcode="microbit_menu_buttons") + MICROBIT_MENU_GESTURES = BlockShape(opcode="microbit_menu_gestures") + MICROBIT_MENU_TILTDIRECTIONANY = BlockShape(opcode="microbit_menu_tiltDirectionAny") + MICROBIT_MENU_TILTDIRECTION = BlockShape(opcode="microbit_menu_tiltDirection") + MICROBIT_MENU_TOUCHPINS = BlockShape(opcode="microbit_menu_touchPins") + MICROBIT_MENU_PINSTATE = BlockShape(opcode="microbit_menu_pinState") + + EV3_MOTORTURNCLOCKWISE = BlockShape(opcode="ev3_motorTurnClockwise") + EV3_MOTORTURNCOUNTERCLOCKWISE = BlockShape(opcode="ev3_motorTurnCounterClockwise") + EV3_MOTORSETPOWER = BlockShape(opcode="ev3_motorSetPower") + EV3_GETMOTORPOSITION = BlockShape(opcode="ev3_getMotorPosition") + EV3_WHENBUTTONPRESSED = BlockShape(opcode="ev3_whenButtonPressed") + EV3_WHENDISTANCELESSTHAN = BlockShape(opcode="ev3_whenDistanceLessThan") + EV3_WHENBRIGHTNESSLESSTHAN = BlockShape(opcode="ev3_whenBrightnessLessThan") + EV3_BUTTONPRESSED = BlockShape(opcode="ev3_buttonPressed") + EV3_GETDISTANCE = BlockShape(opcode="ev3_getDistance") + EV3_GETBRIGHTNESS = BlockShape(opcode="ev3_getBrightness") + EV3_BEEP = BlockShape(opcode="ev3_beep") + EV3_MENU_MOTORPORTS = BlockShape(opcode="ev3_menu_motorPorts") + EV3_MENU_SENSORPORTS = BlockShape(opcode="ev3_menu_sensorPorts") + + BOOST_MOTORONFOR = BlockShape(opcode="boost_motorOnFor") + BOOST_MOTORONFORROTATION = BlockShape(opcode="boost_motorOnForRotation") + BOOST_MOTORON = BlockShape(opcode="boost_motorOn") + BOOST_MOTOROFF = BlockShape(opcode="boost_motorOff") + BOOST_SETMOTORPOWER = BlockShape(opcode="boost_setMotorPower") + BOOST_SETMOTORDIRECTION = BlockShape(opcode="boost_setMotorDirection") + BOOST_GETMOTORPOSITION = BlockShape(opcode="boost_getMotorPosition") + BOOST_WHENCOLOR = BlockShape(opcode="boost_whenColor") + BOOST_SEEINGCOLOR = BlockShape(opcode="boost_seeingColor") + BOOST_WHENTILTED = BlockShape(opcode="boost_whenTilted") + BOOST_GETTILTANGLE = BlockShape(opcode="boost_getTiltAngle") + BOOST_SETLIGHTHUE = BlockShape(opcode="boost_setLightHue") + BOOST_MENU_MOTOR_ID = BlockShape(opcode="boost_menu_MOTOR_ID") + BOOST_MENU_MOTOR_DIRECTION = BlockShape(opcode="boost_menu_MOTOR_DIRECTION") + BOOST_MENU_MOTOR_REPORTER_ID = BlockShape(opcode="boost_menu_MOTOR_REPORTER_ID") + BOOST_MENU_COLOR = BlockShape(opcode="boost_menu_COLOR") + BOOST_MENU_TILT_DIRECTION_ANY = BlockShape(opcode="boost_menu_TILT_DIRECTION_ANY") + BOOST_MENU_TILT_DIRECTION = BlockShape(opcode="boost_menu_TILT_DIRECTION") + + WEDO2_MOTORONFOR = BlockShape(opcode="wedo2_motorOnFor") + WEDO2_MOTORON = BlockShape(opcode="wedo2_motorOn") + WEDO2_MOTOROFF = BlockShape(opcode="wedo2_motorOff") + WEDO2_STARTMOTORPOWER = BlockShape(opcode="wedo2_startMotorPower") + WEDO2_SETMOTORDIRECTION = BlockShape(opcode="wedo2_setMotorDirection") + WEDO2_SETLIGHTHUE = BlockShape(opcode="wedo2_setLightHue") + WEDO2_WHENDISTANCE = BlockShape(opcode="wedo2_whenDistance") + WEDO2_WHENTILTED = BlockShape(opcode="wedo2_whenTilted") + WEDO2_GETDISTANCE = BlockShape(opcode="wedo2_getDistance") + WEDO2_ISTILTED = BlockShape(opcode="wedo2_isTilted") + WEDO2_GETTILTANGLE = BlockShape(opcode="wedo2_getTiltAngle") + WEDO2_PLAYNOTEFOR = BlockShape(opcode="wedo2_playNoteFor") + WEDO2_MENU_MOTOR_ID = BlockShape(opcode="wedo2_menu_MOTOR_ID") + WEDO2_MENU_MOTOR_DIRECTION = BlockShape(opcode="wedo2_menu_MOTOR_DIRECTION") + WEDO2_MENU_OP = BlockShape(opcode="wedo2_menu_OP") + WEDO2_MENU_TILT_DIRECTION_ANY = BlockShape(opcode="wedo2_menu_TILT_DIRECTION_ANY") + WEDO2_MENU_TILT_DIRECTION = BlockShape(opcode="wedo2_menu_TILT_DIRECTION") + + GDXFOR_WHENGESTURE = BlockShape(opcode="gdxfor_whenGesture") + GDXFOR_WHENFORCEPUSHEDORPULLED = BlockShape(opcode="gdxfor_whenForcePushedOrPulled") + GDXFOR_GETFORCE = BlockShape(opcode="gdxfor_getForce") + GDXFOR_WHENTILTED = BlockShape(opcode="gdxfor_whenTilted") + GDXFOR_ISTILTED = BlockShape(opcode="gdxfor_isTilted") + GDXFOR_GETTILT = BlockShape(opcode="gdxfor_getTilt") + GDXFOR_ISFREEFALLING = BlockShape(opcode="gdxfor_isFreeFalling") + GDXFOR_GETSPINSPEED = BlockShape(opcode="gdxfor_getSpinSpeed") + GDXFOR_GETACCELERATION = BlockShape(opcode="gdxfor_getAcceleration") + GDXFOR_MENU_GESTUREOPTIONS = BlockShape(opcode="gdxfor_menu_gestureOptions") + GDXFOR_MENU_PUSHPULLOPTIONS = BlockShape(opcode="gdxfor_menu_pushPullOptions") + GDXFOR_MENU_TILTANYOPTIONS = BlockShape(opcode="gdxfor_menu_tiltAnyOptions") + GDXFOR_MENU_TILTOPTIONS = BlockShape(opcode="gdxfor_menu_tiltOptions") + GDXFOR_MENU_AXISOPTIONS = BlockShape(opcode="gdxfor_menu_axisOptions") + + COREEXAMPLE_EXAMPLEOPCODE = BlockShape(is_reporter=True, opcode="coreExample_exampleOpcode") + COREEXAMPLE_EXAMPLEWITHINLINEIMAGE = BlockShape(is_stack=True, opcode="coreExample_exampleWithInlineImage") + + NOTE = BlockShape(is_reporter=True, is_menu=True, opcode="note") + MATRIX = BlockShape(is_reporter=True, is_menu=True, opcode="matrix") + UNDEFINED = BlockShape(is_hat=True, is_cap=True, opcode="red_hat_block") diff --git a/scratchattach/editor/build_defaulting.py b/scratchattach/editor/build_defaulting.py new file mode 100644 index 00000000..b7d22f7d --- /dev/null +++ b/scratchattach/editor/build_defaulting.py @@ -0,0 +1,47 @@ +""" +Module which stores the 'default' or 'current' selected Sprite/project (stored as a stack) which makes it easier to write scratch code directly in Python +""" +from __future__ import annotations + +from typing import Iterable, TYPE_CHECKING, Final + +if TYPE_CHECKING: + from . import sprite, block, prim, comment +from . import commons + + +class _SetSprite(commons.Singleton): + def __repr__(self): + return f'' + + +SPRITE_DEFAULT: Final[_SetSprite] = _SetSprite() + +_sprite_stack: list[sprite.Sprite] = [] + + +def stack_add_sprite(_sprite: sprite.Sprite): + _sprite_stack.append(_sprite) + + +def current_sprite() -> sprite.Sprite | None: + if len(_sprite_stack) == 0: + return None + return _sprite_stack[-1] + + +def pop_sprite(_sprite: sprite.Sprite) -> sprite.Sprite | None: + assert _sprite_stack.pop() == _sprite + return _sprite + + +def add_block(_block: block.Block | prim.Prim) -> block.Block | prim.Prim: + return current_sprite().add_block(_block) + + +def add_chain(*chain: Iterable[block.Block, prim.Prim]) -> block.Block | prim.Prim: + return current_sprite().add_chain(*chain) + + +def add_comment(_comment: comment.Comment): + return current_sprite().add_comment(_comment) diff --git a/scratchattach/editor/comment.py b/scratchattach/editor/comment.py new file mode 100644 index 00000000..d436e246 --- /dev/null +++ b/scratchattach/editor/comment.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from . import base, block, sprite, build_defaulting + + +class Comment(base.IDComponent): + def __init__(self, _id: str = None, _block: block.Block = None, x: int = 0, y: int = 0, width: int = 200, + height: int = 200, minimized: bool = False, text: str = '', *, _block_id: str = None, + _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT, pos: tuple[int, int] = None): + self.block = _block + self._block_id = _block_id + """ + ID of connected block. Will be set to None upon sprite initialization when the block attribute is updated to the relevant Block. + """ + if pos is not None: + x, y = pos + + self.x = x + self.y = y + + self.width = width + self.height = height + + self.minimized = minimized + self.text = text + + super().__init__(_id, _sprite) + + def __repr__(self): + return f"Comment<{self.text[:10]!r}...>" + + @property + def block_id(self): + if self.block is not None: + return self.block.id + elif self._block_id is not None: + return self._block_id + else: + return None + + @staticmethod + def from_json(data: tuple[str, dict]): + assert len(data) == 2 + _id, data = data + + _block_id = data.get("blockId") + + x = data.get("x", 0) + y = data.get("y", 0) + + width = data.get("width", 100) + height = data.get("height", 100) + + minimized = data.get("minimized", False) + text = data.get("text") + + ret = Comment(_id, None, x, y, width, height, minimized, text, _block_id=_block_id) + return ret + + def to_json(self) -> dict: + return { + "blockId": self.block_id, + "x": self.x, "y": self.y, + "width": self.width, "height": self.height, + "minimized": self.minimized, + "text": self.text, + } + + def link_using_sprite(self): + if self._block_id is not None: + self.block = self.sprite.find_block(self._block_id, "id") + if self.block is not None: + self._block_id = None diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py new file mode 100644 index 00000000..2341aa95 --- /dev/null +++ b/scratchattach/editor/commons.py @@ -0,0 +1,243 @@ +""" +Shared functions used by the editor module +""" +from __future__ import annotations + +import json +import random +import string +from typing import Final, Any + +from ..utils import exceptions + +DIGITS: Final[tuple[str]] = tuple("0123456789") + +ID_CHARS: Final[str] = string.ascii_letters + string.digits # + string.punctuation + + +# Strangely enough, it seems like something in string.punctuation causes issues. Not sure why + + +def _read_json_number(_str: str) -> float | int: + ret = '' + + minus = _str[0] == '-' + if minus: + ret += '-' + _str = _str[1:] + + def read_fraction(sub: str): + sub_ret = '' + if sub[0] == '.': + sub_ret += '.' + sub = sub[1:] + while sub[0] in DIGITS: + sub_ret += sub[0] + sub = sub[1:] + + return sub_ret, sub + + def read_exponent(sub: str): + sub_ret = '' + if sub[0].lower() == 'e': + sub_ret += sub[0] + sub = sub[1:] + + if sub[0] in "-+": + sub_ret += sub[0] + sub = sub[1:] + + if sub[0] not in DIGITS: + raise exceptions.UnclosedJSONError(f"Invalid exponent {sub}") + + while sub[0] in DIGITS: + sub_ret += sub[0] + sub = sub[1:] + + return sub_ret + + if _str[0] == '0': + ret += '0' + _str = _str[1:] + + elif _str[0] in DIGITS[1:9]: + while _str[0] in DIGITS: + ret += _str[0] + _str = _str[1:] + + frac, _str = read_fraction(_str) + ret += frac + + ret += read_exponent(_str) + + return json.loads(ret) + + +def consume_json(_str: str, i: int = 0) -> str | float | int | dict | list | bool | None: + """ + *'gobble up some JSON until we hit something not quite so tasty'* + + Reads a JSON string and stops at the natural end (i.e. when brackets close, or when quotes end, etc.) + """ + # Named by ChatGPT + section = ''.join(_str[i:]) + if section.startswith("true"): + return True + elif section.startswith("false"): + return False + elif section.startswith("null"): + return None + elif section[0] in "0123456789.-": + return _read_json_number(section) + + depth = 0 + json_text = '' + out_string = True + + for char in section: + json_text += char + + if char == '"': + if len(json_text) > 1: + unescaped = json_text[-2] != '\\' + else: + unescaped = True + if unescaped: + out_string ^= True + if out_string: + depth -= 1 + else: + depth += 1 + + if out_string: + if char in "[{": + depth += 1 + elif char in "}]": + depth -= 1 + + if depth == 0 and json_text.strip(): + return json.loads(json_text.strip()) + + raise exceptions.UnclosedJSONError(f"Unclosed JSON string, read {json_text}") + + +def is_partial_json(_str: str, i: int = 0) -> bool: + try: + consume_json(_str, i) + return True + + except exceptions.UnclosedJSONError: + return False + + except ValueError: + return False + + +def is_valid_json(_str: Any) -> bool: + try: + json.loads(_str) + return True + except ValueError: + return False + except TypeError: + return False + + +def noneless_update(obj: dict, update: dict) -> None: + for key, value in update.items(): + if value is not None: + obj[key] = value + + +def remove_nones(obj: dict) -> None: + """ + Removes all None values from a dict. + :param obj: Dictionary to remove all None values. + """ + nones = [] + for key, value in obj.items(): + if value is None: + nones.append(key) + for key in nones: + del obj[key] + + +def safe_get(lst: list | tuple, _i: int, default: Any = None) -> Any: + if len(lst) <= _i: + return default + else: + return lst[_i] + + +def trim_final_nones(lst: list) -> list: + """ + Removes the last None values from a list until a non-None value is hit. + :param lst: list which will **not** be modified. + """ + i = len(lst) + for item in lst[::-1]: + if item is not None: + break + i -= 1 + return lst[:i] + + +def dumps_ifnn(obj: Any) -> str: + if obj is None: + return None + else: + return json.dumps(obj) + + +def gen_id() -> str: + # The old 'naïve' method but that chances of a repeat are so miniscule + # Have to check if whitespace chars break it + # May later add checking within sprites so that we don't need such long ids (we can save space this way) + return ''.join(random.choices(ID_CHARS, k=20)) + + +def sanitize_fn(filename: str): + """ + Removes illegal chars from a filename + :return: Sanitized filename + """ + # Maybe could import a slugify module, but it's a bit overkill + ret = '' + for char in filename: + if char in string.ascii_letters + string.digits + "-_": + ret += char + else: + ret += '_' + return ret + + +def get_folder_name(name: str) -> str | None: + if name.startswith('//'): + return None + + if '//' in name: + return name.split('//')[0] + else: + return None + + +def get_name_nofldr(name: str) -> str: + """ + Get the sprite/asset name without the folder name + """ + fldr = get_folder_name(name) + if fldr is None: + return name + else: + return name[len(fldr) + 2:] + + +class Singleton(object): + _instance: Singleton + + def __new__(cls, *args, **kwargs): + if hasattr(cls, "_instance"): + return cls._instance + else: + cls._instance = super(Singleton, cls).__new__(cls) + return cls._instance diff --git a/scratchattach/editor/extension.py b/scratchattach/editor/extension.py new file mode 100644 index 00000000..81bfed06 --- /dev/null +++ b/scratchattach/editor/extension.py @@ -0,0 +1,43 @@ +from __future__ import annotations + + +from dataclasses import dataclass + +from . import base +from ..utils import enums + + +@dataclass(init=True, repr=True) +class Extension(base.JSONSerializable): + code: str + name: str = None + + def __eq__(self, other): + return self.code == other.code + + @staticmethod + def from_json(data: str): + assert isinstance(data, str) + _extension = Extensions.find(data, "code") + if _extension is None: + _extension = Extension(data) + + return _extension + + def to_json(self) -> str: + return self.code + + +class Extensions(enums._EnumWrapper): + BOOST = Extension("boost", "LEGO BOOST Extension") + EV3 = Extension("ev3", "LEGO MINDSTORMS EV3 Extension") + GDXFOR = Extension("gdxfor", "Go Direct Force & Acceleration Extension") + MAKEYMAKEY = Extension("makeymakey", "Makey Makey Extension") + MICROBIT = Extension("microbit", "micro:bit Extension") + MUSIC = Extension("music", "Music Extension") + PEN = Extension("pen", "Pen Extension") + TEXT2SPEECH = Extension("text2speech", "Text to Speech Extension") + TRANSLATE = Extension("translate", "Translate Extension") + VIDEOSENSING = Extension("videoSensing", "Video Sensing Extension") + WEDO2 = Extension("wedo2", "LEGO Education WeDo 2.0 Extension") + COREEXAMPLE = Extension("coreExample", "CoreEx Extension") diff --git a/scratchattach/editor/field.py b/scratchattach/editor/field.py new file mode 100644 index 00000000..34afed15 --- /dev/null +++ b/scratchattach/editor/field.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + + +if TYPE_CHECKING: + from . import block, vlb + +from . import base, commons + + +class Types: + VARIABLE: Final[str] = "variable" + LIST: Final[str] = "list" + BROADCAST: Final[str] = "broadcast" + DEFAULT: Final[str] = "default" + + +class Field(base.BlockSubComponent): + def __init__(self, _value: str | vlb.Variable | vlb.List | vlb.Broadcast, _id: str = None, *, _block: block.Block = None): + """ + A field for a scratch block + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Blocks:~:text=it.%5B9%5D-,fields,element%2C%20which%20is%20the%20ID%20of%20the%20field%27s%20value.%5B10%5D,-shadow + """ + self.value = _value + self.id = _id + """ + ID of associated VLB. Will be used to get VLB object during sprite initialisation, where it will be replaced with 'None' + """ + super().__init__(_block) + + def __repr__(self): + if self.id is not None: + # This shouldn't occur after sprite initialisation + return f"" + else: + return f"" + + @property + def value_id(self): + if self.id is not None: + return self.id + else: + if hasattr(self.value, "id"): + return self.value.id + else: + return None + + @property + def value_str(self): + if not isinstance(self.value, base.NamedIDComponent): + return self.value + else: + return self.value.name + + @property + def name(self) -> str: + for _name, _field in self.block.fields.items(): + if _field is self: + return _name + + @property + def type(self): + """ + Infer the type of value that this field holds + :return: A string (from field.Types) as a name of the type + """ + if "variable" in self.name.lower(): + return Types.VARIABLE + elif "list" in self.name.lower(): + return Types.LIST + elif "broadcast" in self.name.lower(): + return Types.BROADCAST + else: + return Types.DEFAULT + + @staticmethod + def from_json(data: list[str, str | None]): + # Sometimes you may have a stray field with no id. Not sure why + while len(data) < 2: + data.append(None) + data = data[:2] + + _value, _id = data + return Field(_value, _id) + + def to_json(self) -> dict: + return commons.trim_final_nones([ + self.value_str, self.value_id + ]) diff --git a/scratchattach/editor/inputs.py b/scratchattach/editor/inputs.py new file mode 100644 index 00000000..ff14a01a --- /dev/null +++ b/scratchattach/editor/inputs.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import warnings +from typing import Final + +from . import block +from . import base, commons, prim +from dataclasses import dataclass + + +@dataclass(init=True) +class ShadowStatus: + idx: int + name: str + + def __repr__(self): + return f"" + + +class ShadowStatuses: + # Not an enum so you don't need to do .value + # Uh why? + HAS_SHADOW: Final[ShadowStatus] = ShadowStatus(1, "has shadow") + NO_SHADOW: Final[ShadowStatus] = ShadowStatus(2, "no shadow") + OBSCURED: Final[ShadowStatus] = ShadowStatus(3, "obscured") + + @classmethod + def find(cls, idx: int) -> ShadowStatus: + for status in (cls.HAS_SHADOW, cls.NO_SHADOW, cls.OBSCURED): + if status.idx == idx: + return status + + if not 1 <= idx <= 3: + raise ValueError(f"Invalid ShadowStatus idx={idx}") + + +class Input(base.BlockSubComponent): + def __init__(self, _shadow: ShadowStatus | None = ShadowStatuses.HAS_SHADOW, _value: prim.Prim | block.Block | str = None, _id: str = None, + _obscurer: prim.Prim | block.Block | str = None, *, _obscurer_id: str = None, _block: block.Block = None): + """ + An input for a scratch block + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Blocks:~:text=inputs,it.%5B9%5D + """ + super().__init__(_block) + + # If the shadow is None, we'll have to work it out later + self.shadow = _shadow + + self.value: prim.Prim | block.Block = _value + self.obscurer: prim.Prim | block.Block = _obscurer + + self._id = _id + """ + ID referring to the input value. Upon project initialisation, this will be set to None and the value attribute will be set to the relevant object + """ + self._obscurer_id = _obscurer_id + """ + ID referring to the obscurer. Upon project initialisation, this will be set to None and the obscurer attribute will be set to the relevant block + """ + + def __repr__(self): + if self._id is not None: + return f"" + else: + return f"" + + @staticmethod + def from_json(data: list): + _shadow = ShadowStatuses.find(data[0]) + + _value, _id = None, None + if isinstance(data[1], list): + _value = prim.Prim.from_json(data[1]) + else: + _id = data[1] + + _obscurer_data = commons.safe_get(data, 2) + + _obscurer, _obscurer_id = None, None + if isinstance(_obscurer_data, list): + _obscurer = prim.Prim.from_json(_obscurer_data) + else: + _obscurer_id = _obscurer_data + return Input(_shadow, _value, _id, _obscurer, _obscurer_id=_obscurer_id) + + def to_json(self) -> list: + data = [self.shadow.idx] + + def add_pblock(pblock: prim.Prim | block.Block | None): + """ + Adds a primitive or a block to the data in the right format + """ + if pblock is None: + return + + if isinstance(pblock, prim.Prim): + data.append(pblock.to_json()) + + elif isinstance(pblock, block.Block): + data.append(pblock.id) + + else: + warnings.warn(f"Bad prim/block {pblock!r} of type {type(pblock)}") + + add_pblock(self.value) + add_pblock(self.obscurer) + + return data + + def link_using_block(self): + # Link to value + if self._id is not None: + new_value = self.sprite.find_block(self._id, "id") + if new_value is not None: + self.value = new_value + self._id = None + + # Link to obscurer + if self._obscurer_id is not None: + new_block = self.sprite.find_block(self._obscurer_id, "id") + if new_block is not None: + self.obscurer = new_block + self._obscurer_id = None + + # Link value to sprite + if isinstance(self.value, prim.Prim): + self.value.sprite = self.sprite + self.value.link_using_sprite() + + # Link obscurer to sprite + if self.obscurer is not None: + self.obscurer.sprite = self.sprite diff --git a/scratchattach/editor/meta.py b/scratchattach/editor/meta.py new file mode 100644 index 00000000..0f1fb562 --- /dev/null +++ b/scratchattach/editor/meta.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from . import base, commons + + +@dataclass(init=True, repr=True) +class PlatformMeta(base.JSONSerializable): + name: str = None + url: str = field(repr=True, default=None) + + def __bool__(self): + return self.name is not None or self.url is not None + + def to_json(self) -> dict: + _json = {"name": self.name, "url": self.url} + commons.remove_nones(_json) + return _json + + @staticmethod + def from_json(data: dict | None): + if data is None: + return PlatformMeta() + else: + return PlatformMeta(data.get("name"), data.get("url")) + + +DEFAULT_VM = "0.1.0" +DEFAULT_AGENT = "scratchattach.editor by https://scratch.mit.edu/users/timmccool/" +DEFAULT_PLATFORM = PlatformMeta("scratchattach", "https://github.com/timMcCool/scratchattach/") + +EDIT_META = True +META_SET_PLATFORM = False + + +def set_meta_platform(true_false: bool = False): + global META_SET_PLATFORM + META_SET_PLATFORM = true_false + + +class Meta(base.JSONSerializable): + def __init__(self, semver: str = "3.0.0", vm: str = DEFAULT_VM, agent: str = DEFAULT_AGENT, + platform: PlatformMeta = None): + """ + Represents metadata of the project + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Metadata + """ + if platform is None and META_SET_PLATFORM: + platform = DEFAULT_PLATFORM.dcopy() + + self.semver = semver + self.vm = vm + self.agent = agent + self.platform = platform + + if not self.vm_is_valid: + raise ValueError( + f"{vm!r} does not match pattern '^([0-9]+\\.[0-9]+\\.[0-9]+)($|-)' - maybe try '0.0.0'?") + + def __repr__(self): + data = f"{self.semver} : {self.vm} : {self.agent}" + if self.platform: + data += f": {self.platform}" + + return f"Meta<{data}>" + + @property + def vm_is_valid(self): + # Thanks to TurboWarp for this pattern ↓↓↓↓, I just copied it + return re.match("^([0-9]+\\.[0-9]+\\.[0-9]+)($|-)", self.vm) is not None + + def to_json(self): + _json = { + "semver": self.semver, + "vm": self.vm, + "agent": self.agent + } + + if self.platform: + _json["platform"] = self.platform.to_json() + return _json + + @staticmethod + def from_json(data): + if data is None: + data = "" + + semver = data["semver"] + vm = data.get("vm") + agent = data.get("agent") + platform = PlatformMeta.from_json(data.get("platform")) + + if EDIT_META or vm is None: + vm = DEFAULT_VM + + if EDIT_META or agent is None: + agent = DEFAULT_AGENT + + if EDIT_META: + if META_SET_PLATFORM and not platform: + platform = DEFAULT_PLATFORM.dcopy() + + return Meta(semver, vm, agent, platform) diff --git a/scratchattach/editor/monitor.py b/scratchattach/editor/monitor.py new file mode 100644 index 00000000..0c20486a --- /dev/null +++ b/scratchattach/editor/monitor.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from . import project + +from . import base + + +class Monitor(base.ProjectSubcomponent): + def __init__(self, reporter: base.NamedIDComponent = None, + mode: str = "default", + opcode: str = "data_variable", + params: dict = None, + sprite_name: str = None, + value=0, + width: int | float = 0, + height: int | float = 0, + x: int | float = 5, + y: int | float = 5, + visible: bool = False, + slider_min: int | float = 0, + slider_max: int | float = 100, + is_discrete: bool = True, *, reporter_id: str = None, _project: project.Project = None): + """ + Represents a variable/list monitor + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Monitors + """ + assert isinstance(reporter, base.SpriteSubComponent) or reporter is None + + self.reporter_id = reporter_id + """ + ID referencing the VLB being referenced. Replaced with None during project instantiation, where the reporter attribute is updated + """ + + self.reporter = reporter + if params is None: + params = {} + + self.mode = mode + + self.opcode = opcode + self.params = params + + self.sprite_name = sprite_name + + self.value = value + + self.width, self.height = width, height + self.x, self.y = x, y + + self.visible = visible + + self.slider_min, self.slider_max = slider_min, slider_max + self.is_discrete = is_discrete + + super().__init__(_project) + + def __repr__(self): + return f"Monitor<{self.opcode}>" + + @property + def id(self): + if self.reporter is not None: + return self.reporter.id + # if isinstance(self.reporter, str): + # return self.reporter + # else: + # return self.reporter.id + else: + return self.reporter_id + + @staticmethod + def from_json(data: dict): + _id = data["id"] + # ^^ NEED TO FIND REPORTER OBJECT + + mode = data["mode"] + + opcode = data["opcode"] + params: dict = data["params"] + + sprite_name = data["spriteName"] + + value = data["value"] + + width, height = data["width"], data["height"] + x, y = data["x"], data["y"] + + visible = data["visible"] + + if "isDiscrete" in data.keys(): + slider_min, slider_max = data["sliderMin"], data["sliderMax"] + is_discrete = data["isDiscrete"] + else: + slider_min, slider_max, is_discrete = None, None, None + + return Monitor(None, mode, opcode, params, sprite_name, value, width, height, x, y, visible, slider_min, + slider_max, is_discrete, reporter_id=_id) + + def to_json(self): + _json = { + "id": self.id, + "mode": self.mode, + + "opcode": self.opcode, + "params": self.params, + + "spriteName": self.sprite_name, + + "value": self.value, + + "width": self.width, + "height": self.height, + + "x": self.x, + "y": self.y, + + "visible": self.visible + } + if self.is_discrete is not None: + _json["sliderMin"] = self.slider_min + _json["sliderMax"] = self.slider_max + _json["isDiscrete"] = self.is_discrete + + return _json + + def link_using_project(self): + assert self.project is not None + + if self.opcode in ("data_variable", "data_listcontents", "event_broadcast_menu"): + new_vlb = self.project.find_vlb(self.reporter_id, "id") + if new_vlb is not None: + self.reporter = new_vlb + self.reporter_id = None + + # @staticmethod + # def from_reporter(reporter: Block, _id: str = None, mode: str = "default", + # opcode: str = None, sprite_name: str = None, value=0, width: int | float = 0, + # height: int | float = 0, + # x: int | float = 5, y: int | float = 5, visible: bool = False, slider_min: int | float = 0, + # slider_max: int | float = 100, is_discrete: bool = True, params: dict = None): + # if "reporter" not in reporter.stack_type: + # warnings.warn(f"{reporter} is not a reporter block; the monitor will return '0'") + # elif "(menu)" in reporter.stack_type: + # warnings.warn(f"{reporter} is a menu block; the monitor will return '0'") + # # Maybe add note that length of list doesn't work fsr?? idk + # if _id is None: + # _id = reporter.opcode + # if opcode is None: + # opcode = reporter.opcode # .replace('_', ' ') + + # if params is None: + # params = {} + # for field in reporter.fields: + # if field.value_id is None: + # params[field.id] = field.value + # else: + # params[field.id] = field.value, field.value_id + + # return Monitor( + # _id, + # mode, + # opcode, + + # params, + # sprite_name, + # value, + + # width, height, + # x, y, + # visible, + # slider_min, slider_max, is_discrete + # ) diff --git a/scratchattach/editor/mutation.py b/scratchattach/editor/mutation.py new file mode 100644 index 00000000..91b764fe --- /dev/null +++ b/scratchattach/editor/mutation.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import json +import warnings +from dataclasses import dataclass +from typing import TYPE_CHECKING, Iterable, Any + +from . import base, commons +from ..utils import enums + +if TYPE_CHECKING: + from . import block + + +@dataclass(init=True) +class ArgumentType(base.Base): + type: str + proc_str: str + + def __eq__(self, other): + if isinstance(other, enums._EnumWrapper): + other = other.value + + assert isinstance(other, ArgumentType) + + return self.type == other.type + + def __repr__(self): + return f"" + + @property + def default(self) -> str | None: + if self.proc_str == "%b": + return "false" + elif self.proc_str == "%s": + return '' + else: + return None + + +@dataclass(init=True, repr=True) +class ArgSettings(base.Base): + ids: bool + names: bool + defaults: bool + + def __int__(self): + return (int(self.ids) + + int(self.names) + + int(self.defaults)) + + def __eq__(self, other): + return (self.ids == other.ids and + self.names == other.names and + self.defaults == other.defaults) + + def __gt__(self, other): + return int(self) > int(other) + + def __lt__(self, other): + return int(self) > int(other) + + +@dataclass(init=True, repr=True) +class Argument(base.MutationSubComponent): + name: str + default: str = '' + + _id: str = None + """ + Argument ID: Will be used to replace other parameters during block instantiation. + """ + + @property + def index(self): + return self.mutation.arguments.index(self) + + @property + def type(self) -> None | ArgumentType: + i = 0 + goal = self.index + for token in parse_proc_code(self.mutation.proc_code): + if isinstance(token, ArgumentType): + if i == goal: + return token + i += 1 + + @staticmethod + def from_json(data: dict | list | Any): + warnings.warn("No from_json method defined for Arguments (yet?)") + + def to_json(self) -> dict | list | Any: + warnings.warn("No to_json method defined for Arguments (yet?)") + + def link_using_mutation(self): + if self._id is None: + self._id = self.block.new_id + + +class ArgTypes(enums._EnumWrapper): + BOOLEAN = ArgumentType("boolean", "%b") + NUMBER_OR_TEXT = ArgumentType("number or text", "%s") + + +def parse_proc_code(_proc_code: str) -> list[str, ArgumentType] | None: + if _proc_code is None: + return None + token = '' + tokens = [] + + last_char = '' + for char in _proc_code: + if last_char == '%': + if char in "sb": + # If we've hit an %s or %b + token = token[:-1] + # Clip the % sign off the token + + if token != '': + # Make sure not to append an empty token + tokens.append(token) + + # Add the parameter token + token = f"%{char}" + if token == "%b": + tokens.append(ArgTypes.BOOLEAN.value.dcopy()) + elif token == "%s": + tokens.append(ArgTypes.NUMBER_OR_TEXT.value.dcopy()) + + token = '' + continue + + token += char + last_char = char + + if token != '': + tokens.append(token) + + return tokens + + +class Mutation(base.BlockSubComponent): + def __init__(self, _tag_name: str = "mutation", _children: list = None, _proc_code: str = None, + _is_warp: bool = None, _arguments: list[Argument] = None, _has_next: bool = None, + _argument_settings: ArgSettings = None, *, + _block: block.Block = None): + """ + Mutation for Control:stop block and procedures + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Mutations + """ + # Defaulting for args + if _children is None: + _children = [] + + if _argument_settings is None: + if _arguments: + _argument_settings = ArgSettings( + _arguments[0]._id is None, + _arguments[0].name is None, + _arguments[0].default is None + ) + else: + _argument_settings = ArgSettings(False, False, False) + + self.tag_name = _tag_name + self.children = _children + + self.proc_code = _proc_code + self.is_warp = _is_warp + self.arguments = _arguments + self.og_argument_settings = _argument_settings + + self.has_next = _has_next + + super().__init__(_block) + + def __repr__(self): + if self.arguments is not None: + return f"Mutation" + else: + return f"Mutation" + + @property + def argument_ids(self): + if self.arguments is not None: + return [_arg._id for _arg in self.arguments] + else: + return None + + @property + def argument_names(self): + if self.arguments is not None: + return [_arg.name for _arg in self.arguments] + else: + return None + + @property + def argument_defaults(self): + if self.arguments is not None: + return [_arg.default for _arg in self.arguments] + else: + return None + + @property + def argument_settings(self) -> ArgSettings: + return ArgSettings(bool(commons.safe_get(self.argument_ids, 0)), + bool(commons.safe_get(self.argument_names, 0)), + bool(commons.safe_get(self.argument_defaults, 0))) + + @property + def parsed_proc_code(self) -> list[str, ArgumentType] | None: + return parse_proc_code(self.proc_code) + + @staticmethod + def from_json(data: dict) -> Mutation: + assert isinstance(data, dict) + + _tag_name = data.get("tagName", "mutation") + _children = data.get("children", []) + + # procedures_prototype & procedures_call attrs + _proc_code = data.get("proccode") + _is_warp = data.get("warp") + if isinstance(_is_warp, str): + _is_warp = json.loads(_is_warp) + + _argument_ids = data.get("argumentids") + # For some reason these are stored as JSON strings + if _argument_ids is not None: + _argument_ids = json.loads(_argument_ids) + + # procedures_prototype attrs + _argument_names = data.get("argumentnames") + _argument_defaults = data.get("argumentdefaults") + # For some reason these are stored as JSON strings + if _argument_names is not None: + assert isinstance(_argument_names, str) + _argument_names = json.loads(_argument_names) + if _argument_defaults is not None: + assert isinstance(_argument_defaults, str) + _argument_defaults = json.loads(_argument_defaults) + _argument_settings = ArgSettings(_argument_ids is not None, + _argument_names is not None, + _argument_defaults is not None) + + # control_stop attrs + _has_next = data.get("hasnext") + if isinstance(_has_next, str): + _has_next = json.loads(_has_next) + + def get(_lst: list | tuple | None, _idx: int): + if _lst is None: + return None + + if len(_lst) <= _idx: + return None + else: + return _lst[_idx] + + if _argument_ids is None: + _arguments = None + else: + _arguments = [] + for i, _arg_id in enumerate(_argument_ids): + _arg_name = get(_argument_names, i) + _arg_default = get(_argument_defaults, i) + + _arguments.append(Argument(_arg_name, _arg_default, _arg_id)) + + return Mutation(_tag_name, _children, _proc_code, _is_warp, _arguments, _has_next, _argument_settings) + + def to_json(self) -> dict | None: + _json = { + "tagName": self.tag_name, + "children": self.children, + } + commons.noneless_update(_json, { + "proccode": self.proc_code, + "warp": commons.dumps_ifnn(self.is_warp), + "argumentids": commons.dumps_ifnn(self.argument_ids), + "argumentnames": commons.dumps_ifnn(self.argument_names), + "argumentdefaults": commons.dumps_ifnn(self.argument_defaults), + + "hasNext": commons.dumps_ifnn(self.has_next) + }) + + return _json + + def link_arguments(self): + if self.arguments is None: + return + + # You only need to fetch argument data if you actually have arguments + if len(self.arguments) > 0: + if self.arguments[0].name is None: + # This requires linking + _proc_uses = self.sprite.find_block(self.argument_ids, "argument ids", True) + # Note: Sometimes there may not be any argument ids provided. There will be no way to find out the names + # Technically, defaults can be found by using the proc code + for _use in _proc_uses: + if _use.mutation.argument_settings > self.argument_settings: + self.arguments = _use.mutation.arguments + if int(self.argument_settings) == 3: + # If all of our argument data is filled, we can stop early + return + + # We can still work out argument defaults from parsing the proc code + if self.arguments[0].default is None: + _parsed = self.parsed_proc_code + _arg_phs: Iterable[ArgumentType] = filter(lambda tkn: isinstance(tkn, ArgumentType), + _parsed) + for i, _arg_ph in enumerate(_arg_phs): + self.arguments[i].default = _arg_ph.default + + for _argument in self.arguments: + _argument.mutation = self + _argument.link_using_mutation() diff --git a/scratchattach/editor/pallete.py b/scratchattach/editor/pallete.py new file mode 100644 index 00000000..587fb485 --- /dev/null +++ b/scratchattach/editor/pallete.py @@ -0,0 +1,91 @@ +""" +Collection of block information, stating input/field names and opcodes +New version of sbuild.py + +May want to completely change this later +""" +from __future__ import annotations + +from dataclasses import dataclass + +from . import prim +from ..utils.enums import _EnumWrapper + + +@dataclass(init=True, repr=True) +class FieldUsage: + name: str + value_type: prim.PrimTypes = None + + +@dataclass(init=True, repr=True) +class SpecialFieldUsage(FieldUsage): + name: str + attrs: list[str] = None + if attrs is None: + attrs = [] + + value_type: None = None + + +@dataclass(init=True, repr=True) +class InputUsage: + name: str + value_type: prim.PrimTypes = None + default_obscurer: BlockUsage = None + + +@dataclass(init=True, repr=True) +class BlockUsage: + opcode: str + fields: list[FieldUsage] = None + if fields is None: + fields = [] + + inputs: list[InputUsage] = None + if inputs is None: + inputs = [] + + +class BlockUsages(_EnumWrapper): + # Special Enum blocks + MATH_NUMBER = BlockUsage( + "math_number", + [SpecialFieldUsage("NUM", ["name", "value"])] + ) + MATH_POSITIVE_NUMBER = BlockUsage( + "math_positive_number", + [SpecialFieldUsage("NUM", ["name", "value"])] + ) + MATH_WHOLE_NUMBER = BlockUsage( + "math_whole_number", + [SpecialFieldUsage("NUM", ["name", "value"])] + ) + MATH_INTEGER = BlockUsage( + "math_integer", + [SpecialFieldUsage("NUM", ["name", "value"])] + ) + MATH_ANGLE = BlockUsage( + "math_angle", + [SpecialFieldUsage("NUM", ["name", "value"])] + ) + COLOUR_PICKER = BlockUsage( + "colour_picker", + [SpecialFieldUsage("COLOUR", ["name", "value"])] + ) + TEXT = BlockUsage( + "text", + [SpecialFieldUsage("TEXT", ["name", "value"])] + ) + EVENT_BROADCAST_MENU = BlockUsage( + "event_broadcast_menu", + [SpecialFieldUsage("BROADCAST_OPTION", ["name", "id", "value", "variableType"])] + ) + DATA_VARIABLE = BlockUsage( + "data_variable", + [SpecialFieldUsage("VARIABLE", ["name", "id", "value", "variableType"])] + ) + DATA_LISTCONTENTS = BlockUsage( + "data_listcontents", + [SpecialFieldUsage("LIST", ["name", "id", "value", "variableType"])] + ) diff --git a/scratchattach/editor/prim.py b/scratchattach/editor/prim.py new file mode 100644 index 00000000..b787b480 --- /dev/null +++ b/scratchattach/editor/prim.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import warnings +from dataclasses import dataclass +from typing import Callable, Final + +from . import base, sprite, vlb, commons, build_defaulting +from ..utils import enums, exceptions + + +@dataclass(init=True, repr=True) +class PrimType(base.JSONSerializable): + code: int + name: str + attrs: list = None + opcode: str = None + + def __eq__(self, other): + if isinstance(other, str): + return self.name == other + elif isinstance(other, enums._EnumWrapper): + other = other.value + return super().__eq__(other) + + @staticmethod + def from_json(data: int): + return PrimTypes.find(data, "code") + + def to_json(self) -> int: + return self.code + + +BASIC_ATTRS: Final[tuple[str]] = ("value",) +VLB_ATTRS: Final[tuple[str]] = ("name", "id", "x", "y") + + +class PrimTypes(enums._EnumWrapper): + # Yeah, they actually do have opcodes + NUMBER = PrimType(4, "number", BASIC_ATTRS, "math_number") + POSITIVE_NUMBER = PrimType(5, "positive number", BASIC_ATTRS, "math_positive_number") + POSITIVE_INTEGER = PrimType(6, "positive integer", BASIC_ATTRS, "math_whole_number") + INTEGER = PrimType(7, "integer", BASIC_ATTRS, "math_integer") + ANGLE = PrimType(8, "angle", BASIC_ATTRS, "math_angle") + COLOR = PrimType(9, "color", BASIC_ATTRS, "colour_picker") + STRING = PrimType(10, "string", BASIC_ATTRS, "text") + BROADCAST = PrimType(11, "broadcast", VLB_ATTRS, "event_broadcast_menu") + VARIABLE = PrimType(12, "variable", VLB_ATTRS, "data_variable") + LIST = PrimType(13, "list", VLB_ATTRS, "data_listcontents") + + @classmethod + def find(cls, value, by: str, apply_func: Callable = None) -> PrimType: + return super().find(value, by, apply_func=apply_func) + + +def is_prim_opcode(opcode: str): + return opcode in PrimTypes.all_of("opcode") and opcode is not None + + +class Prim(base.SpriteSubComponent): + def __init__(self, _primtype: PrimType | PrimTypes, _value: str | vlb.Variable | vlb.List | vlb.Broadcast = None, + _name: str = None, _id: str = None, _x: int = None, + _y: int = None, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): + """ + Class representing a Scratch string, number, angle, variable etc. + Technically blocks but behave differently + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets:~:text=A%20few%20blocks,13 + """ + if isinstance(_primtype, PrimTypes): + _primtype = _primtype.value + + self.type = _primtype + + self.value = _value + + self.name = _name + """ + Once you get the object associated with this primitive (sprite.link_prims()), + the name will be removed and the value will be changed from ``None`` + """ + self.value_id = _id + """ + It's not an object accessed by id, but it may reference an object with an id. + + ---- + + Once you get the object associated with it (sprite.link_prims()), + the id will be removed and the value will be changed from ``None`` + """ + + self.x = _x + self.y = _y + + super().__init__(_sprite) + + def __repr__(self): + if self.is_basic: + return f"Prim<{self.type.name}: {self.value}>" + elif self.is_vlb: + return f"Prim<{self.type.name}: {self.value}>" + else: + return f"Prim<{self.type.name}>" + + @property + def is_vlb(self): + return self.type.attrs == VLB_ATTRS + + @property + def is_basic(self): + return self.type.attrs == BASIC_ATTRS + + @staticmethod + def from_json(data: list): + assert isinstance(data, list) + + _type_idx = data[0] + _prim_type = PrimTypes.find(_type_idx, "code") + + _value, _name, _value_id, _x, _y = (None,) * 5 + if _prim_type.attrs == BASIC_ATTRS: + assert len(data) == 2 + _value = data[1] + + elif _prim_type.attrs == VLB_ATTRS: + assert len(data) in (3, 5) + _name, _value_id = data[1:3] + + if len(data) == 5: + _x, _y = data[3:] + + return Prim(_prim_type, _value, _name, _value_id, _x, _y) + + def to_json(self) -> list: + if self.type.attrs == BASIC_ATTRS: + return [self.type.code, self.value] + else: + return commons.trim_final_nones([self.type.code, self.value.name, self.value.id, self.x, self.y]) + + def link_using_sprite(self): + # Link prim to var/list/broadcast + if self.is_vlb: + if self.type.name == "variable": + self.value = self.sprite.find_variable(self.value_id, "id") + + elif self.type.name == "list": + self.value = self.sprite.find_list(self.value_id, "id") + + elif self.type.name == "broadcast": + self.value = self.sprite.find_broadcast(self.value_id, "id") + else: + # This should never happen + raise exceptions.BadVLBPrimitiveError(f"{self} claims to be VLB, but is {self.type.name}") + + if self.value is None: + if not self.project: + new_vlb = vlb.construct(self.type.name.lower(), self.value_id, self.name) + self.sprite.add_local_global(new_vlb) + self.value = new_vlb + + else: + new_vlb = vlb.construct(self.type.name.lower(), self.value_id, self.name) + self.sprite.stage.add_vlb(new_vlb) + + warnings.warn( + f"Prim has unknown {self.type.name} id; adding as global variable") + self.name = None + self.value_id = None + + @property + def can_next(self): + return False diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py new file mode 100644 index 00000000..029601c6 --- /dev/null +++ b/scratchattach/editor/project.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import json +import os +import warnings +from io import BytesIO, TextIOWrapper +from typing import Iterable, Generator, BinaryIO +from zipfile import ZipFile + +from . import base, meta, extension, monitor, sprite, asset, vlb, twconfig, comment, commons +from ..site import session +from ..site.project import get_project +from ..utils import exceptions + + +class Project(base.JSONExtractable): + def __init__(self, _name: str = None, _meta: meta.Meta = None, _extensions: Iterable[extension.Extension] = (), + _monitors: Iterable[monitor.Monitor] = (), _sprites: Iterable[sprite.Sprite] = (), *, + _asset_data: list[asset.AssetFile] = None, _session: session.Session = None): + # Defaulting for list parameters + if _meta is None: + _meta = meta.Meta() + if _asset_data is None: + _asset_data = [] + + self._session = _session + + self.name = _name + + self.meta = _meta + self.extensions = _extensions + self.monitors = list(_monitors) + self.sprites = list(_sprites) + + self.asset_data = _asset_data + + self._tw_config_comment = None + + # Link subcomponents + for iterable in (self.monitors, self.sprites): + for _subcomponent in iterable: + _subcomponent.project = self + + # Link sprites + _stage_count = 0 + + for _sprite in self.sprites: + if _sprite.is_stage: + _stage_count += 1 + + _sprite.link_subcomponents() + + # Link monitors + for _monitor in self.monitors: + _monitor.link_using_project() + + if _stage_count != 1: + raise exceptions.InvalidStageCount(f"Project {self}") + + def __repr__(self): + _ret = "Project<" + if self.name is not None: + _ret += f"name={self.name}, " + _ret += f"meta={self.meta}" + _ret += '>' + return _ret + + @property + def stage(self) -> sprite.Sprite: + for _sprite in self.sprites: + if _sprite.is_stage: + return _sprite + warnings.warn(f"Could not find stage for {self.name}") + + def to_json(self) -> dict: + _json = { + "targets": [_sprite.to_json() for _sprite in self.sprites], + "monitors": [_monitor.to_json() for _monitor in self.monitors], + "extensions": [_extension.to_json() for _extension in self.extensions], + "meta": self.meta.to_json(), + } + + return _json + + @property + def assets(self) -> Generator[asset.Asset, None, None]: + for _sprite in self.sprites: + for _asset in _sprite.assets: + yield _asset + + @property + def tw_config_comment(self) -> comment.Comment | None: + for _comment in self.stage.comments: + if twconfig.is_valid_twconfig(_comment.text): + return _comment + return None + + @property + def tw_config(self) -> twconfig.TWConfig | None: + _comment = self.tw_config_comment + if _comment: + return twconfig.TWConfig.from_str(_comment.text) + return None + + @property + def all_ids(self): + _ret = [] + for _sprite in self.sprites: + _ret += _sprite.all_ids + return _ret + + @property + def new_id(self): + return commons.gen_id() + + @staticmethod + def from_json(data: dict): + assert isinstance(data, dict) + + # Load metadata + _meta = meta.Meta.from_json(data.get("meta")) + + # Load extensions + _extensions = [] + for _extension_data in data.get("extensions", []): + _extensions.append(extension.Extension.from_json(_extension_data)) + + # Load monitors + _monitors = [] + for _monitor_data in data.get("monitors", []): + _monitors.append(monitor.Monitor.from_json(_monitor_data)) + + # Load sprites (targets) + _sprites = [] + for _sprite_data in data.get("targets", []): + _sprites.append(sprite.Sprite.from_json(_sprite_data)) + + return Project(None, _meta, _extensions, _monitors, _sprites) + + @staticmethod + def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: str = None): + """ + Load project JSON and assets from an .sb3 file/bytes/file path + :return: Project name, asset data, json string + """ + _dir_for_name = None + + if _name is None: + if hasattr(data, "name"): + _dir_for_name = data.name + + if not isinstance(_name, str) and _name is not None: + _name = str(_name) + + if isinstance(data, bytes): + data = BytesIO(data) + + elif isinstance(data, str): + _dir_for_name = data + data = open(data, "rb") + + if _name is None and _dir_for_name is not None: + # Remove any directory names and the file extension + _name = _dir_for_name.split('/')[-1] + _name = '.'.join(_name.split('.')[:-1]) + + asset_data = [] + with data: + # For if the sb3 is just JSON (e.g. if it's exported from scratchattach) + if commons.is_valid_json(data): + json_str = data + + else: + with ZipFile(data) as archive: + json_str = archive.read("project.json") + + # Also load assets + if load_assets: + for filename in archive.namelist(): + if filename != "project.json": + md5_hash = filename.split('.')[0] + + asset_data.append( + asset.AssetFile(filename, archive.read(filename), md5_hash) + ) + + else: + warnings.warn( + "Loading sb3 without loading assets. When exporting the project, there may be errors due to assets not being uploaded to the Scratch website") + + return _name, asset_data, json_str + + @classmethod + def from_sb3(cls, data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: str = None): + """ + Load a project from an .sb3 file/bytes/file path + """ + _name, asset_data, json_str = cls.load_json(data, load_assets, _name) + data = json.loads(json_str) + + project = cls.from_json(data) + project.name = _name + project.asset_data = asset_data + + return project + + @staticmethod + def from_id(project_id: int, _name: str = None): + _proj = get_project(project_id) + data = json.loads(_proj.get_json()) + + if _name is None: + _name = _proj.title + _name = str(_name) + + _proj = Project.from_json(data) + _proj.name = _name + return _proj + + def find_vlb(self, value: str | None, by: str = "name", + multiple: bool = False) -> vlb.Variable | vlb.List | vlb.Broadcast | list[ + vlb.Variable | vlb.List | vlb.Broadcast]: + _ret = [] + for _sprite in self.sprites: + val = _sprite.find_vlb(value, by, multiple) + if multiple: + _ret += val + else: + if val is not None: + return val + if multiple: + return _ret + + def find_sprite(self, value: str | None, by: str = "name", + multiple: bool = False) -> sprite.Sprite | list[sprite.Sprite]: + _ret = [] + for _sprite in self.sprites: + if by == "name": + _val = _sprite.name + else: + _val = getattr(_sprite, by) + + if _val == value: + if multiple: + _ret.append(_sprite) + else: + return _sprite + + if multiple: + return _ret + + def export(self, fp: str, *, auto_open: bool = False, export_as_zip: bool = True): + data = self.to_json() + + if export_as_zip: + with ZipFile(fp, "w") as archive: + for _asset in self.assets: + asset_file = _asset.asset_file + if asset_file.filename not in archive.namelist(): + archive.writestr(asset_file.filename, asset_file.data) + + archive.writestr("project.json", json.dumps(data)) + else: + with open(fp, "w") as json_file: + json.dump(data, json_file) + + if auto_open: + os.system(f"explorer.exe \"{fp}\"") + + def add_monitor(self, _monitor: monitor.Monitor) -> monitor.Monitor: + _monitor.project = self + _monitor.reporter_id = self.new_id + self.monitors.append(_monitor) diff --git a/scratchattach/editor/sbuild.py b/scratchattach/editor/sbuild.py new file mode 100644 index 00000000..7b57c306 --- /dev/null +++ b/scratchattach/editor/sbuild.py @@ -0,0 +1,2835 @@ +from __future__ import annotations + +from .. import editor +# Copied from sbuild so we have to make a few wrappers ;-; +# May need to recreate this from scratch. In which case, it is to be done in palette.py +class Block(editor.Block): + ... + +class Input(editor.Input): + ... +class Field(editor.Field): + ... +class Variable(editor.Variable): + ... +class List(editor.List): + ... +class Broadcast(editor.Broadcast): + ... +class Mutation(editor.Mutation): + ... + + +class Motion: + class MoveSteps(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_movesteps", _shadow=shadow, pos=pos) + + def set_steps(self, value, input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("STEPS", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class TurnRight(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_turnright", _shadow=shadow, pos=pos) + + def set_degrees(self, value, input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("DEGREES", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class TurnLeft(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_turnleft", _shadow=shadow, pos=pos) + + def set_degrees(self, value, input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("DEGREES", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class GoTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_goto", _shadow=shadow, pos=pos) + + def set_to(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input(Input("TO", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class GoToMenu(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_goto_menu", _shadow=shadow, pos=pos) + + def set_to(self, value: str = "_random_", value_id: str = None): + return self.add_field(Field("TO", value, value_id)) + + class GoToXY(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_gotoxy", _shadow=shadow, pos=pos) + + def set_x(self, value, input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input(Input("X", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_y(self, value, input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input(Input("Y", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class GlideTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_glideto", _shadow=shadow, pos=pos) + + def set_secs(self, value, input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input(Input("SECS", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_to(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input(Input("TO", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class GlideToMenu(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_glideto_menu", _shadow=shadow, pos=pos) + + def set_to(self, value: str = "_random_", value_id: str = None): + return self.add_field(Field("TO", value, value_id)) + + class GlideSecsToXY(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_glidesecstoxy", _shadow=shadow, pos=pos) + + def set_x(self, value, input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input(Input("X", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_y(self, value, input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input(Input("Y", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_secs(self, value, input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input(Input("SECS", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class PointInDirection(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_pointindirection", _shadow=shadow, pos=pos) + + def set_direction(self, value, input_type: str | int = "angle", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("DIRECTION", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class PointTowards(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_pointtowards", _shadow=shadow, pos=pos) + + def set_towards(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("TOWARDS", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class PointTowardsMenu(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_pointtowards_menu", _shadow=shadow, pos=pos) + + def set_towards(self, value: str = "_mouse_", value_id: str = None): + return self.add_field(Field("TOWARDS", value, value_id)) + + class ChangeXBy(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_changexby", _shadow=shadow, pos=pos) + + def set_dx(self, value, input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input(Input("DX", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class ChangeYBy(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_changeyby", _shadow=shadow, pos=pos) + + def set_dy(self, value, input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input(Input("DY", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class SetX(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_setx", _shadow=shadow, pos=pos) + + def set_x(self, value, input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input(Input("X", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class SetY(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_sety", _shadow=shadow, pos=pos) + + def set_y(self, value, input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input(Input("Y", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class IfOnEdgeBounce(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_ifonedgebounce", _shadow=shadow, pos=pos) + + class SetRotationStyle(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_setrotationstyle", _shadow=shadow, pos=pos) + + def set_style(self, value: str = "all around", value_id: str = None): + return self.add_field(Field("STYLE", value, value_id)) + + class XPosition(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_xposition", _shadow=shadow, pos=pos) + + class YPosition(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_yposition", _shadow=shadow, pos=pos) + + class Direction(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_direction", _shadow=shadow, pos=pos) + + class ScrollRight(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_scroll_right", _shadow=shadow, pos=pos) + + def set_distance(self, value, input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("DISTANCE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class ScrollUp(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_scroll_up", _shadow=shadow, pos=pos) + + def set_distance(self, value, input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("DISTANCE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class AlignScene(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_align_scene", _shadow=shadow, pos=pos) + + def set_alignment(self, value: str = "bottom-left", value_id: str = None): + return self.add_field(Field("ALIGNMENT", value, value_id)) + + class XScroll(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_xscroll", _shadow=shadow, pos=pos) + + class YScroll(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "motion_yscroll", _shadow=shadow, pos=pos) + + +class Looks: + class SayForSecs(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_sayforsecs", _shadow=shadow, pos=pos) + + def set_message(self, value="Hello!", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("MESSAGE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer) + ) + + def set_secs(self, value=2, input_type: str | int = "positive integer", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("SECS", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer) + ) + + class Say(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_say", _shadow=shadow, pos=pos) + + def set_message(self, value="Hello!", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("MESSAGE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer) + ) + + class ThinkForSecs(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_thinkforsecs", _shadow=shadow, pos=pos) + + def set_message(self, value="Hmm...", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("MESSAGE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer) + ) + + def set_secs(self, value=2, input_type: str | int = "positive integer", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("SECS", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer) + ) + + class Think(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_think", _shadow=shadow, pos=pos) + + def set_message(self, value="Hmm...", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("MESSAGE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer) + ) + + class SwitchCostumeTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_switchcostumeto", _shadow=shadow, pos=pos) + + def set_costume(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("COSTUME", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Costume(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_costume", _shadow=shadow, pos=pos) + + def set_costume(self, value: str = "costume1", value_id: str = None): + return self.add_field(Field("COSTUME", value, value_id)) + + class NextCostume(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_nextcostume", _shadow=shadow, pos=pos) + + class SwitchBackdropTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_switchbackdropto", _shadow=shadow, pos=pos) + + def set_backdrop(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("BACKDROP", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Backdrops(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_backdrops", _shadow=shadow, pos=pos) + + def set_backdrop(self, value: str = "costume1", value_id: str = None): + return self.add_field(Field("BACKDROP", value, value_id)) + + class SwitchBackdropToAndWait(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_switchbackdroptoandwait", _shadow=shadow, pos=pos) + + def set_backdrop(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("BACKDROP", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class NextBackdrop(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_nextbackdrop", _shadow=shadow, pos=pos) + + class ChangeSizeBy(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_changesizeby", _shadow=shadow, pos=pos) + + def set_change(self, value="10", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("CHANGE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class SetSizeTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_setsizeto", _shadow=shadow, pos=pos) + + def set_size(self, value="100", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("SIZE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class ChangeEffectBy(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_changeeffectby", _shadow=shadow, pos=pos) + + def set_change(self, value="100", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("CHANGE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_effect(self, value: str = "COLOR", value_id: str = None): + return self.add_field(Field("EFFECT", value, value_id)) + + class SetEffectTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_seteffectto", _shadow=shadow, pos=pos) + + def set_value(self, value="0", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("VALUE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_effect(self, value: str = "COLOR", value_id: str = None): + return self.add_field(Field("EFFECT", value, value_id)) + + class ClearGraphicEffects(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_cleargraphiceffects", _shadow=shadow, pos=pos) + + class Hide(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_hide", _shadow=shadow, pos=pos) + + class Show(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_show", _shadow=shadow, pos=pos) + + class GoToFrontBack(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_gotofrontback", _shadow=shadow, pos=pos) + + def set_front_back(self, value: str = "front", value_id: str = None): + return self.add_field(Field("FRONT_BACK", value, value_id)) + + class GoForwardBackwardLayers(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_goforwardbackwardlayers", _shadow=shadow, pos=pos) + + def set_num(self, value="1", input_type: str | int = "positive integer", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NUM", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_fowrward_backward(self, value: str = "forward", value_id: str = None): + return self.add_field(Field("FORWARD_BACKWARD", value, value_id)) + + class CostumeNumberName(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_costumenumbername", _shadow=shadow, pos=pos) + + def set_number_name(self, value: str = "string", value_id: str = None): + return self.add_field(Field("NUMBER_NAME", value, value_id)) + + class BackdropNumberName(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_backdropnumbername", _shadow=shadow, pos=pos) + + def set_number_name(self, value: str = "number", value_id: str = None): + return self.add_field(Field("NUMBER_NAME", value, value_id)) + + class Size(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_size", _shadow=shadow, pos=pos) + + class HideAllSprites(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_hideallsprites", _shadow=shadow, pos=pos) + + class SetStretchTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_setstretchto", _shadow=shadow, pos=pos) + + def set_stretch(self, value="100", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("STRETCH", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class ChangeStretchBy(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "looks_changestretchby", _shadow=shadow, pos=pos) + + def set_change(self, value="10", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("CHANGE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + +class Sounds: + class Play(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sound_play", _shadow=shadow, pos=pos) + + def set_sound_menu(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("SOUND_MENU", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class SoundsMenu(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sound_sounds_menu", _shadow=shadow, pos=pos) + + def set_sound_menu(self, value: str = "pop", value_id: str = None): + return self.add_field(Field("SOUND_MENU", value, value_id)) + + class PlayUntilDone(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sound_playuntildone", _shadow=shadow, pos=pos) + + def set_sound_menu(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("SOUND_MENU", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class StopAllSounds(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sound_stopallsounds", _shadow=shadow, pos=pos) + + class ChangeEffectBy(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sound_changeeffectby", _shadow=shadow, pos=pos) + + def set_value(self, value="10", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("VALUE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_effect(self, value: str = "PITCH", value_id: str = None): + return self.add_field(Field("EFFECT", value, value_id)) + + class SetEffectTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sound_seteffectto", _shadow=shadow, pos=pos) + + def set_value(self, value="100", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("VALUE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_effect(self, value: str = "PITCH", value_id: str = None): + return self.add_field(Field("EFFECT", value, value_id)) + + class ClearEffects(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sound_cleareffects", _shadow=shadow, pos=pos) + + class ChangeVolumeBy(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sound_changevolumeby", _shadow=shadow, pos=pos) + + def set_volume(self, value="-10", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("VOLUME", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class SetVolumeTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sound_setvolumeto", _shadow=shadow, pos=pos) + + def set_volume(self, value="100", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("VOLUME", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Volume(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sound_volume", _shadow=shadow, pos=pos) + + +class Events: + class WhenFlagClicked(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "event_whenflagclicked", _shadow=shadow, pos=pos) + + class WhenKeyPressed(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "event_whenkeypressed", _shadow=shadow, pos=pos) + + def set_key_option(self, value: str = "space", value_id: str = None): + return self.add_field(Field("KEY_OPTION", value, value_id)) + + class WhenThisSpriteClicked(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "event_whenthisspriteclicked", _shadow=shadow, pos=pos) + + class WhenStageClicked(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "event_whenstageclicked", _shadow=shadow, pos=pos) + + class WhenBackdropSwitchesTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "event_whenbackdropswitchesto", _shadow=shadow, pos=pos) + + def set_backdrop(self, value: str = "backdrop1", value_id: str = None): + return self.add_field(Field("BACKDROP", value, value_id)) + + class WhenGreaterThan(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "event_whengreaterthan", _shadow=shadow, pos=pos) + + def set_value(self, value="10", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("VALUE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_when_greater_than_menu(self, value: str = "LOUDNESS", value_id: str = None): + return self.add_field(Field("WHENGREATERTHANMENU", value, value_id)) + + class WhenBroadcastReceived(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "event_whenbroadcastreceived", _shadow=shadow, pos=pos) + + def set_broadcast_option(self, value="message1", value_id: str = "I didn't get an id..."): + return self.add_field(Field("BROADCAST_OPTION", value, value_id)) + + class Broadcast(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "event_broadcast", _shadow=shadow, pos=pos) + + def set_broadcast_input(self, value="message1", input_type: str | int = "broadcast", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("BROADCAST_INPUT", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class BroadcastAndWait(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "event_broadcastandwait", _shadow=shadow, pos=pos) + + def set_broadcast_input(self, value="message1", input_type: str | int = "broadcast", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("BROADCAST_INPUT", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class WhenTouchingObject(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "event_whentouchingobject", _shadow=shadow, pos=pos) + + def set_touching_object_menu(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("TOUCHINGOBJECTMENU", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class TouchingObjectMenu(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "event_touchingobjectmenu", _shadow=shadow, pos=pos) + + def set_touching_object_menu(self, value: str = "_mouse_", value_id: str = None): + return self.add_field(Field("TOUCHINGOBJECTMENU", value, value_id)) + + +class Control: + class Wait(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_wait", _shadow=shadow, pos=pos) + + def set_duration(self, value="1", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("DURATION", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Forever(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_forever", _shadow=shadow, pos=pos, can_next=False) + + def set_substack(self, value, input_type: str | int = "block", shadow_status: int = 2, *, + input_id: str = None): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input("SUBSTACK", value, input_type, shadow_status, input_id=input_id) + return self.add_input(inp) + + class If(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_if", _shadow=shadow, pos=pos) + + def set_substack(self, value, input_type: str | int = "block", shadow_status: int = 2, *, + input_id: str = None): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input("SUBSTACK", value, input_type, shadow_status, input_id=input_id) + return self.add_input(inp) + + def set_condition(self, value, input_type: str | int = "block", shadow_status: int = 2, *, + input_id: str = None): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input("CONDITION", value, input_type, shadow_status, input_id=input_id) + return self.add_input(inp) + + class IfElse(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_if_else", _shadow=shadow, pos=pos) + + def set_substack1(self, value, input_type: str | int = "block", shadow_status: int = 2, *, + input_id: str = None): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input("SUBSTACK", value, input_type, shadow_status, input_id=input_id) + return self.add_input(inp) + + def set_substack2(self, value, input_type: str | int = "block", shadow_status: int = 2, *, + input_id: str = None): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input("SUBSTACK2", value, input_type, shadow_status, input_id=input_id) + return self.add_input(inp) + + def set_condition(self, value, input_type: str | int = "block", shadow_status: int = 2, *, + input_id: str = None): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input("CONDITION", value, input_type, shadow_status, input_id=input_id) + return self.add_input(inp) + + class WaitUntil(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_wait_until", _shadow=shadow, pos=pos) + + def set_condition(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("CONDITION", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class RepeatUntil(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_repeat_until", _shadow=shadow, pos=pos) + + def set_substack(self, value, input_type: str | int = "block", shadow_status: int = 2, *, + input_id: str = None): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input("SUBSTACK", value, input_type, shadow_status, input_id=input_id) + return self.add_input(inp) + + def set_condition(self, value, input_type: str | int = "block", shadow_status: int = 2, *, + input_id: str = None): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input("CONDITION", value, input_type, shadow_status, input_id=input_id) + return self.add_input(inp) + + class While(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_while", _shadow=shadow, pos=pos) + + def set_substack(self, value, input_type: str | int = "block", shadow_status: int = 2, *, + input_id: str = None): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input("SUBSTACK", value, input_type, shadow_status, input_id=input_id) + return self.add_input(inp) + + def set_condition(self, value, input_type: str | int = "block", shadow_status: int = 2, *, + input_id: str = None): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input("CONDITION", value, input_type, shadow_status, input_id=input_id) + return self.add_input(inp) + + class Stop(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_stop", _shadow=shadow, pos=pos, mutation=Mutation()) + + def set_stop_option(self, value: str = "all", value_id: str = None): + return self.add_field(Field("STOP_OPTION", value, value_id)) + + def set_hasnext(self, has_next: bool = True): + self.mutation.has_next = has_next + return self + + class StartAsClone(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_start_as_clone", _shadow=shadow, pos=pos) + + class CreateCloneOf(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_create_clone_of", _shadow=shadow, pos=pos) + + def set_clone_option(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("CLONE_OPTION", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class CreateCloneOfMenu(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_create_clone_of_menu", _shadow=shadow, pos=pos) + + def set_clone_option(self, value: str = "_myself_", value_id: str = None): + return self.add_field(Field("CLONE_OPTION", value, value_id)) + + class DeleteThisClone(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_delete_this_clone", _shadow=shadow, pos=pos, can_next=False) + + class ForEach(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_for_each", _shadow=shadow, pos=pos) + + def set_substack(self, value, input_type: str | int = "block", shadow_status: int = 2, *, + input_id: str = None): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input("SUBSTACK", value, input_type, shadow_status, input_id=input_id) + return self.add_input(inp) + + def set_value(self, value="5", input_type: str | int = "positive integer", shadow_status: int = 1, *, + input_id: str = None): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input("VALUE", value, input_type, shadow_status, input_id=input_id) + return self.add_input(inp) + + def set_variable(self, value: str = "i", value_id: str = None): + return self.add_field(Field("VARIABLE", value, value_id)) + + class GetCounter(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_get_counter", _shadow=shadow, pos=pos) + + class IncrCounter(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_incr_counter", _shadow=shadow, pos=pos) + + class ClearCounter(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_clear_counter", _shadow=shadow, pos=pos) + + class AllAtOnce(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "control_all_at_once", _shadow=shadow, pos=pos) + + def set_substack(self, value, input_type: str | int = "block", shadow_status: int = 2, *, + input_id: str = None): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input("SUBSTACK", value, input_type, shadow_status, input_id=input_id) + return self.add_input(inp) + + +class Sensing: + class TouchingObject(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_touchingobject", _shadow=shadow, pos=pos) + + def set_touching_object_menu(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("TOUCHINGOBJECTMENU", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class TouchingObjectMenu(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_touchingobjectmenu", _shadow=shadow, pos=pos) + + def set_touching_object_menu(self, value: str = "_mouse_", value_id: str = None): + return self.add_field(Field("TOUCHINGOBJECTMENU", value, value_id)) + + class TouchingColor(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_touchingcolor", _shadow=shadow, pos=pos) + + def set_color(self, value="#0000FF", input_type: str | int = "color", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("COLOR", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class ColorIsTouchingColor(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_coloristouchingcolor", _shadow=shadow, pos=pos) + + def set_color1(self, value="#0000FF", input_type: str | int = "color", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("COLOR", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_color2(self, value="#00FF00", input_type: str | int = "color", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("COLOR2", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class DistanceTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_distanceto", _shadow=shadow, pos=pos) + + def set_distance_to_menu(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("DISTANCETOMENU", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class DistanceToMenu(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_distancetomenu", _shadow=shadow, pos=pos) + + def set_distance_to_menu(self, value: str = "_mouse_", value_id: str = None): + return self.add_field(Field("DISTANCETOMENU", value, value_id)) + + class Loud(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_loud", _shadow=shadow, pos=pos) + + class AskAndWait(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_askandwait", _shadow=shadow, pos=pos) + + def set_question(self, value="What's your name?", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("QUESTION", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer) + ) + + class Answer(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_answer", _shadow=shadow, pos=pos) + + class KeyPressed(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_keypressed", _shadow=shadow, pos=pos) + + def set_key_option(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("KEY_OPTION", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class KeyOptions(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_keyoptions", _shadow=shadow, pos=pos) + + def set_key_option(self, value: str = "space", value_id: str = None): + return self.add_field(Field("KEY_OPTION", value, value_id)) + + class MouseDown(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_mousedown", _shadow=shadow, pos=pos) + + class MouseX(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_mousex", _shadow=shadow, pos=pos) + + class MouseY(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_mousey", _shadow=shadow, pos=pos) + + class SetDragMode(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_setdragmode", _shadow=shadow, pos=pos) + + def set_drag_mode(self, value: str = "draggable", value_id: str = None): + return self.add_field(Field("DRAG_MODE", value, value_id)) + + class Loudness(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_loudness", _shadow=shadow, pos=pos) + + class Timer(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_timer", _shadow=shadow, pos=pos) + + class ResetTimer(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_resettimer", _shadow=shadow, pos=pos) + + class Of(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_of", _shadow=shadow, pos=pos) + + def set_object(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("OBJECT", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_property(self, value: str = "backdrop #", value_id: str = None): + return self.add_field(Field("PROPERTY", value, value_id)) + + class OfObjectMenu(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_of_object_menu", _shadow=shadow, pos=pos) + + def set_object(self, value: str = "_stage_", value_id: str = None): + return self.add_field(Field("OBJECT", value, value_id)) + + class Current(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_current", _shadow=shadow, pos=pos) + + def set_current_menu(self, value: str = "YEAR", value_id: str = None): + return self.add_field(Field("CURRENTMENU", value, value_id)) + + class DaysSince2000(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_dayssince2000", _shadow=shadow, pos=pos) + + class Username(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_username", _shadow=shadow, pos=pos) + + class UserID(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "sensing_userid", _shadow=shadow, pos=pos) + + +class Operators: + class Add(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_add", _shadow=shadow, pos=pos) + + def set_num1(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NUM1", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_num2(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NUM2", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Subtract(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_subtract", _shadow=shadow, pos=pos) + + def set_num1(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NUM1", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_num2(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NUM2", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Multiply(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_multiply", _shadow=shadow, pos=pos) + + def set_num1(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NUM1", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_num2(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NUM2", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Divide(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_divide", _shadow=shadow, pos=pos) + + def set_num1(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NUM1", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_num2(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NUM2", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Random(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_random", _shadow=shadow, pos=pos) + + def set_from(self, value="1", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("FROM", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_to(self, value="10", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("TO", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class GT(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_gt", _shadow=shadow, pos=pos) + + def set_operand1(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("OPERAND1", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_operand2(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("OPERAND2", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class LT(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_lt", _shadow=shadow, pos=pos) + + def set_operand1(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("OPERAND1", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_operand2(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("OPERAND2", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Equals(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_equals", _shadow=shadow, pos=pos) + + def set_operand1(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("OPERAND1", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_operand2(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("OPERAND2", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class And(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_and", _shadow=shadow, pos=pos) + + def set_operand1(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("OPERAND1", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_operand2(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("OPERAND2", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Or(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_or", _shadow=shadow, pos=pos) + + def set_operand1(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("OPERAND1", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_operand2(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("OPERAND2", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Not(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_not", _shadow=shadow, pos=pos) + + def set_operand(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("OPERAND", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Join(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_join", _shadow=shadow, pos=pos) + + def set_string1(self, value="apple ", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("STRING1", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_string2(self, value="banana", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("STRING2", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class LetterOf(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_letter_of", _shadow=shadow, pos=pos) + + def set_letter(self, value="1", input_type: str | int = "positive integer", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("LETTER", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_string(self, value="apple", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("STRING", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Length(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_length", _shadow=shadow, pos=pos) + + def set_string(self, value="apple", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("STRING", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Contains(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_contains", _shadow=shadow, pos=pos) + + def set_string1(self, value="apple", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("STRING1", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_string2(self, value="a", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("STRING2", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Mod(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_mod", _shadow=shadow, pos=pos) + + def set_num1(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NUM1", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_num2(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NUM2", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Round(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_round", _shadow=shadow, pos=pos) + + def set_num(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NUM", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class MathOp(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "operator_mathop", _shadow=shadow, pos=pos) + + def set_num(self, value='', input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NUM", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_operator(self, value: str = "abs", value_id: str = None): + return self.add_field(Field("OPERATOR", value, value_id)) + + +class Data: + class VariableArr(Block): + def __init__(self, value, input_type: str | int = "variable", shadow_status: int = None, *, + pos: tuple[int | float, int | float] = (0, 0)): + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + inp = Input(None, value, input_type, shadow_status) + if inp.type_str == "block": + arr = inp.json[0] + else: + arr = inp.json[1][-1] + + super().__init__(array=arr, pos=pos) + + class Variable(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_variable", _shadow=shadow, pos=pos) + + def set_variable(self, value: str | Variable = "variable", value_id: str = None): + return self.add_field(Field("VARIABLE", value, value_id)) + + class SetVariableTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_setvariableto", _shadow=shadow, pos=pos) + + def set_value(self, value="0", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("VALUE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_variable(self, value: str | Variable = "variable", value_id: str = None): + return self.add_field(Field("VARIABLE", value, value_id)) + + class ChangeVariableBy(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_changevariableby", _shadow=shadow, pos=pos) + + def set_value(self, value="1", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("VALUE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_variable(self, value: str | Variable = "variable", value_id: str = None): + return self.add_field(Field("VARIABLE", value, value_id)) + + class ShowVariable(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_showvariable", _shadow=shadow, pos=pos) + + def set_variable(self, value: str | Variable = "variable", value_id: str = None): + return self.add_field(Field("VARIABLE", value, value_id)) + + class HideVariable(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_hidevariable", _shadow=shadow, pos=pos) + + def set_variable(self, value: str | Variable = "variable", value_id: str = None): + return self.add_field(Field("VARIABLE", value, value_id)) + + class ListArr(Block): + def __init__(self, value, input_type: str | int = "list", shadow_status: int = None, *, + pos: tuple[int | float, int | float] = (0, 0)): + inp = Input(None, value, input_type, shadow_status) + if inp.type_str == "block": + arr = inp.json[0] + else: + arr = inp.json[1][-1] + + super().__init__(array=arr, pos=pos) + + class ListContents(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_listcontents", _shadow=shadow, pos=pos) + + def set_list(self, value: str | List = "my list", value_id: str = None): + return self.add_field(Field("LIST", value, value_id)) + + class AddToList(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_addtolist", _shadow=shadow, pos=pos) + + def set_item(self, value="thing", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("ITEM", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_list(self, value: str | List = "list", value_id: str = None): + return self.add_field(Field("LIST", value, value_id)) + + class DeleteOfList(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_deleteoflist", _shadow=shadow, pos=pos) + + def set_index(self, value="random", input_type: str | int = "positive integer", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("INDEX", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_list(self, value: str | List = "list", value_id: str = None): + return self.add_field(Field("LIST", value, value_id)) + + class InsertAtList(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_insertatlist", _shadow=shadow, pos=pos) + + def set_item(self, value="thing", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("ITEM", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_index(self, value="random", input_type: str | int = "positive integer", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("INDEX", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_list(self, value: str | List = "list", value_id: str = None): + return self.add_field(Field("LIST", value, value_id)) + + class DeleteAllOfList(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_deletealloflist", _shadow=shadow, pos=pos) + + def set_list(self, value: str | List = "list", value_id: str = None): + return self.add_field(Field("LIST", value, value_id)) + + class ReplaceItemOfList(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_replaceitemoflist", _shadow=shadow, pos=pos) + + def set_item(self, value="thing", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("ITEM", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_index(self, value="random", input_type: str | int = "positive integer", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("INDEX", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_list(self, value: str | List = "list", value_id: str = None): + return self.add_field(Field("LIST", value, value_id)) + + class ItemOfList(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_itemoflist", _shadow=shadow, pos=pos) + + def set_index(self, value="random", input_type: str | int = "positive integer", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("INDEX", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_list(self, value: str | List = "list", value_id: str = None): + return self.add_field(Field("LIST", value, value_id)) + + class ItemNumOfList(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_itemnumoflist", _shadow=shadow, pos=pos) + + def set_item(self, value="thing", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("ITEM", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_list(self, value: str | List = "list", value_id: str = None): + return self.add_field(Field("LIST", value, value_id)) + + class LengthOfList(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_lengthoflist", _shadow=shadow, pos=pos) + + def set_list(self, value: str | List = "list", value_id: str = None): + return self.add_field(Field("LIST", value, value_id)) + + class ListContainsItem(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_listcontainsitem", _shadow=shadow, pos=pos) + + def set_item(self, value="thing", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("ITEM", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_list(self, value: str | List = "list", value_id: str = None): + return self.add_field(Field("LIST", value, value_id)) + + class ShowList(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_showlist", _shadow=shadow, pos=pos) + + def set_list(self, value: str | List = "list", value_id: str = None): + return self.add_field(Field("LIST", value, value_id)) + + class HideList(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_hidelist", _shadow=shadow, pos=pos) + + def set_list(self, value: str | List = "list", value_id: str = None): + return self.add_field(Field("LIST", value, value_id)) + + class ListIndexAll(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_listindexall", _shadow=shadow, pos=pos) + + class ListIndexRandom(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "data_listindexrandom", _shadow=shadow, pos=pos) + + +class Proc: + class Definition(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "procedures_definition", _shadow=shadow, pos=pos) + + def set_custom_block(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("custom_block", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Call(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "procedures_call", _shadow=shadow, pos=pos, mutation=Mutation()) + + def set_proc_code(self, proc_code: str = ''): + self.mutation.proc_code = proc_code + return self + + def set_argument_ids(self, *argument_ids: list[str]): + self.mutation.argument_ids = argument_ids + return self + + def set_warp(self, warp: bool = True): + self.mutation.warp = warp + return self + + def set_arg(self, arg, value='', input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input(arg, value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class Declaration(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "procedures_declaration", _shadow=shadow, pos=pos, mutation=Mutation()) + + def set_proc_code(self, proc_code: str = ''): + self.mutation.proc_code = proc_code + return self + + class Prototype(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "procedures_prototype", _shadow=shadow, pos=pos, mutation=Mutation()) + + def set_proc_code(self, proc_code: str = ''): + self.mutation.proc_code = proc_code + return self + + def set_argument_ids(self, *argument_ids: list[str]): + self.mutation.argument_ids = argument_ids + return self + + def set_argument_names(self, *argument_names: list[str]): + self.mutation.argument_names = list(argument_names) + return self + + def set_argument_defaults(self, *argument_defaults: list[str]): + self.mutation.argument_defaults = argument_defaults + return self + + def set_warp(self, warp: bool = True): + self.mutation.warp = warp + return self + + def set_arg(self, arg, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input(arg, value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + +class Args: + class EditorBoolean(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "argument_editor_boolean", _shadow=shadow, pos=pos, mutation=Mutation()) + + def set_text(self, value: str = "foo", value_id: str = None): + return self.add_field(Field("TEXT", value, value_id)) + + class EditorStringNumber(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "argument_editor_string_number", _shadow=shadow, pos=pos, mutation=Mutation()) + + def set_text(self, value: str = "foo", value_id: str = None): + return self.add_field(Field("TEXT", value, value_id)) + + class ReporterBoolean(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "argument_reporter_boolean", _shadow=shadow, pos=pos, mutation=Mutation()) + + def set_value(self, value: str = "boolean", value_id: str = None): + return self.add_field(Field("VALUE", value, value_id)) + + class ReporterStringNumber(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "argument_reporter_string_number", _shadow=shadow, pos=pos, mutation=Mutation()) + + def set_value(self, value: str = "boolean", value_id: str = None): + return self.add_field(Field("VALUE", value, value_id)) + + +class Addons: + class IsTurbowarp(Args.ReporterBoolean): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(_shadow=shadow, pos=pos) + self.set_value("is turbowarp?") + + class IsCompiled(Args.ReporterBoolean): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(_shadow=shadow, pos=pos) + self.set_value("is compiled?") + + class IsForkphorus(Args.ReporterBoolean): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(_shadow=shadow, pos=pos) + self.set_value("is forkphorus?") + + class Breakpoint(Proc.Call): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(_shadow=shadow, pos=pos) + self.set_proc_code("​​breakpoint​​") + + class Log(Proc.Call): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(_shadow=shadow, pos=pos) + self.set_proc_code("​​log​​ %s") + self.set_argument_ids("arg0") + + def set_message(self, value='', input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + return self.set_arg("arg0", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer) + + class Warn(Proc.Call): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(_shadow=shadow, pos=pos) + self.set_proc_code("​​warn​​ %s") + self.set_argument_ids("arg0") + + def set_message(self, value='', input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + return self.set_arg("arg0", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer) + + class Error(Proc.Call): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(_shadow=shadow, pos=pos) + self.set_proc_code("​​error​​ %s") + self.set_argument_ids("arg0") + + def set_message(self, value='', input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + return self.set_arg("arg0", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer) + + +class Pen: + class Clear(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_clear", _shadow=shadow, pos=pos) + + class Stamp(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_stamp", _shadow=shadow, pos=pos) + + class PenDown(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_penDown", _shadow=shadow, pos=pos) + + class PenUp(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_penUp", _shadow=shadow, pos=pos) + + class SetPenColorToColor(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_setPenColorToColor", _shadow=shadow, pos=pos) + + def set_color(self, value="#FF0000", input_type: str | int = "color", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("COLOR", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class ChangePenParamBy(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_changePenColorParamBy", _shadow=shadow, pos=pos) + + def set_param(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("COLOR_PARAM", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_value(self, value="10", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("VALUE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class SetPenParamTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_setPenColorParamTo", _shadow=shadow, pos=pos) + + def set_param(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("COLOR_PARAM", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_value(self, value="10", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("VALUE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class ChangePenSizeBy(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_changePenSizeBy", _shadow=shadow, pos=pos) + + def set_size(self, value="1", input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("SIZE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class SetPenSizeTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_setPenSizeTo", _shadow=shadow, pos=pos) + + def set_size(self, value="1", input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("SIZE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class SetPenHueTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_setPenHueToNumber", _shadow=shadow, pos=pos) + + def set_hue(self, value="1", input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("HUE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class ChangePenHueBy(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_changePenHueBy", _shadow=shadow, pos=pos) + + def set_hue(self, value="1", input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("HUE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class SetPenShadeTo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_setPenShadeToNumber", _shadow=shadow, pos=pos) + + def set_shade(self, value="1", input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("SHADE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class ChangePenShadeBy(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_changePenShadeBy", _shadow=shadow, pos=pos) + + def set_shade(self, value="1", input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("SHADE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class ColorParamMenu(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "pen_menu_colorParam", _shadow=shadow, pos=pos) + + def set_color_param(self, value: str = "color", value_id: str = None): + return self.add_field(Field("colorParam", value, value_id)) + + +class Music: + class PlayDrumForBeats(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "music_playDrumForBeats", _shadow=shadow, pos=pos) + + def set_drum(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("DRUM", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_beats(self, value="0.25", input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("BEATS", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class PlayNoteForBeats(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "music_playDrumForBeats", _shadow=shadow, pos=pos) + + def set_note(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("NOTE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_beats(self, value="0.25", input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("BEATS", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class RestForBeats(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "music_restForBeats", _shadow=shadow, pos=pos) + + def set_beats(self, value="0.25", input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("BEATS", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class SetTempo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "music_setTempo", _shadow=shadow, pos=pos) + + def set_beats(self, value="60", input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("TEMPO", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class ChangeTempo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "music_changeTempo", _shadow=shadow, pos=pos) + + def set_beats(self, value="60", input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("TEMPO", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class GetTempo(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "music_getTempo", _shadow=shadow, pos=pos) + + class SetInstrument(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "music_setInstrument", _shadow=shadow, pos=pos) + + def set_instrument(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("INSTRUMENT", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class MidiPlayDrumForBeats(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "music_midiPlayDrumForBeats", _shadow=shadow, pos=pos) + + def set_drum(self, value="123", input_type: str | int = "positive integer", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("DRUM", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_beats(self, value="1", input_type: str | int = "positive number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("BEATS", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class MidiSetInstrument(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "music_midiSetInstrument", _shadow=shadow, pos=pos) + + def set_instrument(self, value="6", input_type: str | int = "positive integer", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("INSTRUMENT", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class MenuDrum(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "music_menu_DRUM", _shadow=shadow, pos=pos) + + def set_drum(self, value: str = "1", value_id: str = None): + return self.add_field(Field("DRUM", value, value_id)) + + class MenuInstrument(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "music_menu_INSTRUMENT", _shadow=shadow, pos=pos) + + def set_instrument(self, value: str = "1", value_id: str = None): + return self.add_field(Field("INSTRUMENT", value, value_id)) + + +class VideoSensing: + class WhenMotionGreaterThan(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "videoSensing_whenMotionGreaterThan", _shadow=shadow, pos=pos) + + def set_reference(self, value="10", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("REFERENCE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class VideoOn(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "videoSensing_videoOn", _shadow=shadow, pos=pos) + + def set_attribute(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("ATTRIBUTE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_subject(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("SUBJECT", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class MenuAttribute(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "videoSensing_menu_ATTRIBUTE", _shadow=shadow, pos=pos) + + def set_attribute(self, value: str = "motion", value_id: str = None): + return self.add_field(Field("ATTRIBUTE", value, value_id)) + + class MenuSubject(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "videoSensing_menu_SUBJECT", _shadow=shadow, pos=pos) + + def set_subject(self, value: str = "this sprite", value_id: str = None): + return self.add_field(Field("SUBJECT", value, value_id)) + + class VideoToggle(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "videoSensing_videoToggle", _shadow=shadow, pos=pos) + + def set_video_state(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("VIDEO_STATE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class MenuVideoState(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "videoSensing_menu_VIDEO_STATE", _shadow=shadow, pos=pos) + + def set_video_state(self, value: str = "on", value_id: str = None): + return self.add_field(Field("VIDEO_STATE", value, value_id)) + + class SetVideoTransparency(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "videoSensing_setVideoTransparency", _shadow=shadow, pos=pos) + + def set_transparency(self, value: str = "50", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("TRANSPARENCY", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + +class Text2Speech: + class SpeakAndWait(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "text2speech_speakAndWait", _shadow=shadow, pos=pos) + + def set_words(self, value: str = "50", input_type: str | int = "number", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("WORDS", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class SetVoice(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "text2speech_setVoice", _shadow=shadow, pos=pos) + + def set_voice(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("VOICE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class MenuVoices(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "text2speech_menu_voices", _shadow=shadow, pos=pos) + + def set_voices(self, value: str = "ALTO", value_id: str = None): + return self.add_field(Field("voices", value, value_id)) + + class SetLanguage(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "text2speech_setLanguage", _shadow=shadow, pos=pos) + + def set_language(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("LANGUAGE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class MenuLanguages(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "text2speech_menu_languages", _shadow=shadow, pos=pos) + + def set_languages(self, value: str = "en", value_id: str = None): + return self.add_field(Field("languages", value, value_id)) + + +class Translate: + class GetTranslate(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "translate_getTranslate", _shadow=shadow, pos=pos) + + def set_words(self, value="hello!", input_type: str | int = "string", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("WORDS", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + def set_language(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("LANGUAGE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class MenuLanguages(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "translate_menu_languages", _shadow=shadow, pos=pos) + + def set_languages(self, value: str = "sv", value_id: str = None): + return self.add_field(Field("languages", value, value_id)) + + class GetViewerLanguage(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "translate_getViewerLanguage", _shadow=shadow, pos=pos) + + +class MakeyMakey: + class WhenMakeyKeyPressed(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "makeymakey_whenMakeyKeyPressed", _shadow=shadow, pos=pos) + + def set_key(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("KEY", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class MenuKey(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "makeymakey_menu_KEY", _shadow=shadow, pos=pos) + + def set_key(self, value: str = "SPACE", value_id: str = None): + return self.add_field(Field("KEY", value, value_id)) + + class WhenCodePressed(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "makeymakey_whenCodePressed", _shadow=shadow, pos=pos) + + def set_sequence(self, value, input_type: str | int = "block", shadow_status: int = 1, *, + input_id: str = None, obscurer: str | Block = None): + + if isinstance(value, Block): + value = self.target.add_block(value) + elif isinstance(value, list) or isinstance(value, tuple): + if isinstance(value[0], Block): + value = self.target.link_chain(value) + return self.add_input( + Input("SEQUENCE", value, input_type, shadow_status, input_id=input_id, obscurer=obscurer)) + + class MenuSequence(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "makeymakey_menu_SEQUENCE", _shadow=shadow, pos=pos) + + def set_key(self, value: str = "LEFT UP RIGHT", value_id: str = None): + return self.add_field(Field("SEQUENCE", value, value_id)) + + +class CoreExample: + class ExampleOpcode(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "coreExample_exampleOpcode", _shadow=shadow, pos=pos) + + class ExampleWithInlineImage(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "coreExample_exampleWithInlineImage", _shadow=shadow, pos=pos) + + +class OtherBlocks: + class Note(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "note", _shadow=shadow, pos=pos) + + def set_note(self, value: str = "60", value_id: str = None): + return self.add_field(Field("NOTE", value, value_id)) + + class Matrix(Block): + def __init__(self, *, shadow: bool = True, pos: tuple[int | float, int | float] = (0, 0)): + super().__init__(None, "matrix", _shadow=shadow, pos=pos) + + def set_note(self, value: str = "0101010101100010101000100", value_id: str = None): + return self.add_field(Field("MATRIX", value, value_id)) + + class RedHatBlock(Block): + def __init__(self, *, shadow: bool = False, pos: tuple[int | float, int | float] = (0, 0)): + # Note: There is no single opcode for the red hat block as the block is simply the result of an error + # The opcode here has been set to 'redhatblock' to make it obvious what is going on + + # (It's not called red_hat_block because then TurboWarp thinks that it's supposed to find an extension + # called red) + + # Appendix: You **CAN** actually add comments to this block, however it will make the block misbehave in the + # editor. The link between the comment and the block will not be visible, but will be visible with the + # corresponding TurboWarp addon + super().__init__(None, "redhatblock", _shadow=shadow, pos=pos, can_next=False) diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py new file mode 100644 index 00000000..5cfd1ec4 --- /dev/null +++ b/scratchattach/editor/sprite.py @@ -0,0 +1,586 @@ +from __future__ import annotations + +import json +import warnings +from io import BytesIO, TextIOWrapper +from typing import Any, BinaryIO +from zipfile import ZipFile +from typing import Iterable, TYPE_CHECKING +from . import base, project, vlb, asset, comment, prim, block, commons, build_defaulting +if TYPE_CHECKING: + from . import asset + +class Sprite(base.ProjectSubcomponent, base.JSONExtractable): + def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int = 1, _layer_order: int = None, + _volume: int = 100, + _broadcasts: list[vlb.Broadcast] = None, + _variables: list[vlb.Variable] = None, _lists: list[vlb.List] = None, + _costumes: list[asset.Costume] = None, _sounds: list[asset.Sound] = None, + _comments: list[comment.Comment] = None, _prims: dict[str, prim.Prim] = None, + _blocks: dict[str, block.Block] = None, + # Stage only: + _tempo: int | float = 60, _video_state: str = "off", _video_transparency: int | float = 50, + _text_to_speech_language: str = "en", _visible: bool = True, + # Sprite only: + _x: int | float = 0, _y: int | float = 0, _size: int | float = 100, _direction: int | float = 90, + _draggable: bool = False, _rotation_style: str = "all around", + + *, _project: project.Project = None): + """ + Represents a sprite or the stage (known internally as a Target) + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets + """ + # Defaulting for list parameters + if _broadcasts is None: + _broadcasts = [] + if _variables is None: + _variables = [] + if _lists is None: + _lists = [] + if _costumes is None: + _costumes = [] + if _sounds is None: + _sounds = [] + if _comments is None: + _comments = [] + if _prims is None: + _prims = {} + if _blocks is None: + _blocks = {} + + self.is_stage = is_stage + self.name = name + self.current_costume = _current_costume + self.layer_order = _layer_order + self.volume = _volume + + self.broadcasts = _broadcasts + self.variables = _variables + self.lists = _lists + self._local_globals = [] + + self.costumes = _costumes + self.sounds = _sounds + + self.comments = _comments + self.prims = _prims + self.blocks = _blocks + + self.tempo = _tempo + self.video_state = _video_state + self.video_transparency = _video_transparency + self.text_to_speech_language = _text_to_speech_language + self.visible = _visible + self.x, self.y = _x, _y + self.size = _size + self.direction = _direction + self.draggable = _draggable + self.rotation_style = _rotation_style + + self.asset_data = [] + + super().__init__(_project) + + # Assign sprite + for iterable in (self.vlbs, self.comments, self.assets, self.prims.values(), self.blocks.values()): + for sub_component in iterable: + sub_component.sprite = self + + def __repr__(self): + return f"Sprite<{self.name}>" + + def __enter__(self): + build_defaulting.stack_add_sprite(self) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + build_defaulting.pop_sprite(self) + + def link_subcomponents(self): + self.link_prims() + self.link_blocks() + self.link_comments() + + def link_prims(self): + """ + Link primitives to corresponding VLB objects (requires project attribute) + """ + for _prim in self.prims.values(): + _prim.link_using_sprite() + + def link_blocks(self): + """ + Link blocks to sprite/to other blocks + """ + for _block_id, _block in self.blocks.items(): + _block.link_using_sprite() + + def link_comments(self): + for _comment in self.comments: + _comment.link_using_sprite() + + def add_local_global(self, _vlb: base.NamedIDComponent): + self._local_globals.append(_vlb) + _vlb.sprite = self + + def add_variable(self, _variable: vlb.Variable): + self.variables.append(_variable) + _variable.sprite = self + + def add_list(self, _list: vlb.List): + self.variables.append(_list) + _list.sprite = self + + def add_broadcast(self, _broadcast: vlb.Broadcast): + self.variables.append(_broadcast) + _broadcast.sprite = self + + def add_vlb(self, _vlb: base.NamedIDComponent): + if isinstance(_vlb, vlb.Variable): + self.add_variable(_vlb) + elif isinstance(_vlb, vlb.List): + self.add_list(_vlb) + elif isinstance(_vlb, vlb.Broadcast): + self.add_broadcast(_vlb) + else: + warnings.warn(f"Invalid 'VLB' {_vlb} of type: {type(_vlb)}") + + def add_block(self, _block: block.Block | prim.Prim) -> block.Block | prim.Prim: + if _block.sprite is self: + if _block in self.blocks.values(): + return _block + + _block.sprite = self + + if isinstance(_block, block.Block): + self.blocks[self.new_id] = _block + _block.link_using_sprite() + + elif isinstance(_block, prim.Prim): + self.prims[self.new_id] = _block + _block.link_using_sprite() + + return _block + + def add_chain(self, *chain: Iterable[block.Block | prim.Prim]) -> block.Block | prim.Prim: + """ + Adds a list of blocks to the sprite **AND RETURNS THE FIRST BLOCK** + :param chain: + :return: + """ + chain = tuple(chain) + + _prev = self.add_block(chain[0]) + + for _block in chain[1:]: + _prev = _prev.attach_block(_block) + + return chain[0] + + def add_comment(self, _comment: comment.Comment) -> comment.Comment: + _comment.sprite = self + if _comment.id is None: + _comment.id = self.new_id + + self.comments.append(_comment) + _comment.link_using_sprite() + + return _comment + + def remove_block(self, _block: block.Block): + for key, val in self.blocks.items(): + if val is _block: + del self.blocks[key] + return + + @property + def folder(self): + return commons.get_folder_name(self.name) + + @property + def name_nfldr(self): + return commons.get_name_nofldr(self.name) + + @property + def vlbs(self) -> list[base.NamedIDComponent]: + """ + :return: All vlbs associated with the sprite. No local globals are added + """ + return self.variables + self.lists + self.broadcasts + + @property + def assets(self) -> list[asset.Costume | asset.Sound]: + return self.costumes + self.sounds + + @property + def stage(self) -> Sprite: + return self.project.stage + + @property + def new_id(self): + return commons.gen_id() + + @staticmethod + def from_json(data: dict): + _is_stage = data["isStage"] + _name = data["name"] + _current_costume = data.get("currentCostume", 1) + _layer_order = data.get("layerOrder", 1) + _volume = data.get("volume", 100) + + # Read VLB + def read_idcomponent(attr_name: str, cls: type[base.IDComponent]): + _vlbs = [] + for _vlb_id, _vlb_data in data.get(attr_name, {}).items(): + _vlbs.append(cls.from_json((_vlb_id, _vlb_data))) + return _vlbs + + _variables = read_idcomponent("variables", vlb.Variable) + _lists = read_idcomponent("lists", vlb.List) + _broadcasts = read_idcomponent("broadcasts", vlb.Broadcast) + + # Read assets + _costumes = [] + for _costume_data in data.get("costumes", []): + _costumes.append(asset.Costume.from_json(_costume_data)) + _sounds = [] + for _sound_data in data.get("sounds", []): + _sounds.append(asset.Sound.from_json(_sound_data)) + + # Read comments + _comments = read_idcomponent("comments", comment.Comment) + + # Read blocks/prims + _prims = {} + _blocks = {} + for _block_id, _block_data in data.get("blocks", {}).items(): + if isinstance(_block_data, list): + # Prim + _prims[_block_id] = prim.Prim.from_json(_block_data) + else: + # Block + _blocks[_block_id] = block.Block.from_json(_block_data) + + # Stage/sprite specific vars + _tempo, _video_state, _video_transparency, _text_to_speech_language = (None,) * 4 + _visible, _x, _y, _size, _direction, _draggable, _rotation_style = (None,) * 7 + if _is_stage: + _tempo = data["tempo"] + _video_state = data["videoState"] + _video_transparency = data["videoTransparency"] + _text_to_speech_language = data["textToSpeechLanguage"] + else: + _visible = data["visible"] + _x = data["x"] + _y = data["y"] + _size = data["size"] + _direction = data["direction"] + _draggable = data["draggable"] + _rotation_style = data["rotationStyle"] + + return Sprite(_is_stage, _name, _current_costume, _layer_order, _volume, _broadcasts, _variables, _lists, + _costumes, + _sounds, _comments, _prims, _blocks, + + _tempo, _video_state, _video_transparency, _text_to_speech_language, + _visible, _x, _y, _size, _direction, _draggable, _rotation_style + ) + + def to_json(self) -> dict: + _json = { + "isStage": self.is_stage, + "name": self.name, + "currentCostume": self.current_costume, + "volume": self.volume, + "layerOrder": self.layer_order, + + "variables": {_variable.id: _variable.to_json() for _variable in self.variables}, + "lists": {_list.id: _list.to_json() for _list in self.lists}, + "broadcasts": {_broadcast.id: _broadcast.to_json() for _broadcast in self.broadcasts}, + + "blocks": {_block_id: _block.to_json() for _block_id, _block in (self.blocks | self.prims).items()}, + "comments": {_comment.id: _comment.to_json() for _comment in self.comments}, + + "costumes": [_costume.to_json() for _costume in self.costumes], + "sounds": [_sound.to_json() for _sound in self.sounds] + } + + if self.is_stage: + _json.update({ + "tempo": self.tempo, + "videoTransparency": self.video_transparency, + "videoState": self.video_state, + "textToSpeechLanguage": self.text_to_speech_language + }) + else: + _json.update({ + "visible": self.visible, + + "x": self.x, "y": self.y, + "size": self.size, + "direction": self.direction, + + "draggable": self.draggable, + "rotationStyle": self.rotation_style + }) + + return _json + + # Finding/getting from list/dict attributes + def find_asset(self, value: str, by: str = "name", multiple: bool = False, a_type: type=None) -> asset.Asset | asset.Sound | asset.Costume | list[asset.Asset | asset.Sound | asset.Costume]: + if a_type is None: + a_type = asset.Asset + + _ret = [] + by = by.lower() + for _asset in self.assets: + if not isinstance(_asset, a_type): + continue + + # Defaulting + compare = getattr(_asset, by) + + if compare == value: + if multiple: + _ret.append(_asset) + else: + return _asset + + if multiple: + return _ret + + def find_variable(self, value: str, by: str = "name", multiple: bool = False) -> vlb.Variable | list[vlb.Variable]: + _ret = [] + by = by.lower() + for _variable in self.variables + self._local_globals: + if not isinstance(_variable, vlb.Variable): + continue + + if by == "id": + compare = _variable.id + else: + # Defaulting + compare = _variable.name + if compare == value: + if multiple: + _ret.append(_variable) + else: + return _variable + # Search in stage for global variables + if self.project: + if not self.is_stage: + if multiple: + _ret += self.stage.find_variable(value, by, True) + else: + return self.stage.find_variable(value, by) + + if multiple: + return _ret + + def find_list(self, value: str, by: str = "name", multiple: bool = False) -> vlb.List | list[vlb.List]: + _ret = [] + by = by.lower() + for _list in self.lists + self._local_globals: + if not isinstance(_list, vlb.List): + continue + if by == "id": + compare = _list.id + else: + # Defaulting + compare = _list.name + if compare == value: + if multiple: + _ret.append(_list) + else: + return _list + # Search in stage for global lists + if self.project: + if not self.is_stage: + if multiple: + _ret += self.stage.find_list(value, by, True) + else: + return self.stage.find_list(value, by) + + if multiple: + return _ret + + def find_broadcast(self, value: str, by: str = "name", multiple: bool = False) -> vlb.Broadcast | list[ + vlb.Broadcast]: + _ret = [] + by = by.lower() + for _broadcast in self.broadcasts + self._local_globals: + if not isinstance(_broadcast, vlb.Broadcast): + continue + if by == "id": + compare = _broadcast.id + else: + # Defaulting + compare = _broadcast.name + if compare == value: + if multiple: + _ret.append(_broadcast) + else: + return _broadcast + # Search in stage for global broadcasts + if self.project: + if not self.is_stage: + if multiple: + _ret += self.stage.find_broadcast(value, by, True) + else: + return self.stage.find_broadcast(value, by) + + if multiple: + return _ret + + def find_vlb(self, value: str, by: str = "name", + multiple: bool = False) -> vlb.Variable | vlb.List | vlb.Broadcast | list[ + vlb.Variable | vlb.List | vlb.Broadcast]: + if multiple: + return self.find_variable(value, by, True) + \ + self.find_list(value, by, True) + \ + self.find_broadcast(value, by, True) + else: + _ret = self.find_variable(value, by) + if _ret is not None: + return _ret + _ret = self.find_list(value, by) + if _ret is not None: + return _ret + return self.find_broadcast(value, by) + + def find_block(self, value: str | Any, by: str, multiple: bool = False) -> block.Block | prim.Prim | list[ + block.Block | prim.Prim]: + _ret = [] + by = by.lower() + for _block_id, _block in (self.blocks | self.prims).items(): + _block: block.Block | prim.Prim + + is_block = isinstance(_block, block.Block) + is_prim = isinstance(_block, prim.Prim) + + compare = None + if by == "id": + compare = _block_id + elif by == "argument ids": + if is_prim: + continue + + if _block.mutation is not None: + compare = _block.mutation.argument_ids + elif by == "opcode": + if is_prim: + continue + + # Defaulting + compare = _block.opcode + else: + if is_block: + compare = _block.opcode + else: + compare = _block.value + + if compare == value: + if multiple: + _ret.append(_block) + else: + return _block + # Search in stage for global variables + if self.project: + if not self.is_stage: + if multiple: + _ret += self.stage.find_block(value, by, True) + else: + return self.stage.find_block(value, by) + + if multiple: + return _ret + + def export(self, fp: str = None, *, export_as_zip: bool = True): + if fp is None: + fp = commons.sanitize_fn(f"{self.name}.sprite3") + + data = self.to_json() + + if export_as_zip: + with ZipFile(fp, "w") as archive: + for _asset in self.assets: + asset_file = _asset.asset_file + if asset_file.filename not in archive.namelist(): + archive.writestr(asset_file.filename, asset_file.data) + + archive.writestr("sprite.json", json.dumps(data)) + else: + with open(fp, "w") as json_file: + json.dump(data, json_file) + + @property + def all_ids(self): + ret = [] + for _vlb in self.vlbs + self._local_globals: + ret.append(_vlb.id) + for iterator in self.blocks.keys(), self.prims.keys(): + ret += list(iterator) + + return ret + @staticmethod + def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: str = None): + _dir_for_name = None + + if _name is None: + if hasattr(data, "name"): + _dir_for_name = data.name + + if not isinstance(_name, str) and _name is not None: + _name = str(_name) + + if isinstance(data, bytes): + data = BytesIO(data) + + elif isinstance(data, str): + _dir_for_name = data + data = open(data, "rb") + + if _name is None and _dir_for_name is not None: + # Remove any directory names and the file extension + _name = _dir_for_name.split('/')[-1] + _name = '.'.join(_name.split('.')[:-1]) + + asset_data = [] + with data: + # For if the sprite3 is just JSON (e.g. if it's exported from scratchattach) + if commons.is_valid_json(data): + json_str = data + + else: + with ZipFile(data) as archive: + json_str = archive.read("sprite.json") + + # Also load assets + if load_assets: + + for filename in archive.namelist(): + if filename != "sprite.json": + md5_hash = filename.split('.')[0] + + asset_data.append( + asset.AssetFile(filename, archive.read(filename), md5_hash) + ) + else: + warnings.warn( + "Loading sb3 without loading assets. When exporting the project, there may be errors due to assets not being uploaded to the Scratch website") + + return _name, asset_data, json_str + + @classmethod + def from_sprite3(cls, data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: str = None): + """ + Load a project from an .sb3 file/bytes/file path + """ + _name, asset_data, json_str = cls.load_json(data, load_assets, _name) + data = json.loads(json_str) + + sprite = cls.from_json(data) + # Sprites already have names, so we probably don't want to set it + # sprite.name = _name + sprite.asset_data = asset_data + return sprite diff --git a/scratchattach/editor/todo.md b/scratchattach/editor/todo.md new file mode 100644 index 00000000..203a81e6 --- /dev/null +++ b/scratchattach/editor/todo.md @@ -0,0 +1,105 @@ +# Things to add to scratchattach.editor (sbeditor v2) + +## All + +- [ ] Docstrings +- [x] Dealing with stuff from the backpack (it's in a weird format): This may require a whole separate module +- [ ] Getter functions (`@property`) instead of directly editing attrs (make them protected attrs) +- [ ] Check if whitespace chars break IDs +- [ ] Maybe blockchain should be renamed to 'script' +- [ ] Perhaps use sprites as blockchain wrappers due to their existing utility (loading of local globals etc) +- [ ] bs4 styled search function +- [ ] ScratchJR project parser (lol) +- [ ] Error checking (for when you need to specify sprite etc) +- [x] Split json unpacking and the use of .from_json method so that it is easy to just extract json data (but not parse + it) + +## Project + +- [x] Asset list +- [ ] Obfuscation +- [x] Detection for twconfig +- [x] Edit twconfig +- [ ] Find targets + +## Block + +### Finding blocks/attrs + +- [x] Top level block (stack parent) +- [x] Previous chain +- [x] Attached chain +- [x] Complete chain +- [x] Block categories +- [x] Block shape attr aka stack type (Stack/hat/c-mouth/end/reporter/boolean detection) +- [x] `can_next` property +- [x] `is_input` property: Check if block is an input obscurer +- [x] `parent_input` property: Get input that this block obscures +- [x] `stack_tree` old 'subtree' property: Get the 'ast' of this blockchain (a 'tree' structure - well actually a list + of lists) +- [x] `children` property - list of all blocks with this block as a parent except next block (any input obscurers) +- [x] Detection for turbowarp debug blocks + (proc codes: + `"​​log​​ %s", + "​​breakpoint​​", + "​​error​​ %s", + "​​warn​​ %s"` - note: they all have ZWSPs) +- [x] Detection for `` and `` and `` booleans + +### Adding/removing blocks + +- [x] Add block to sprite +- [x] Duplicating (single) block +- [x] Attach block +- [x] Duplicating blockchain +- [x] Slot above (if possible - raise error if not) +- [x] Attach blockchain +- [x] Delete block +- [x] Delete blockchain +- [x] Add/edit inputs +- [x] Add/edit fields +- [x] Add mutation +- [x] Add comment +- [x] Get comment + +## Mutation + +- [ ] Proc code builder +- [x] get type of argument (bool/str) inside argument class +- [ ] to/from json for args? + +## Sprite + +### Finding ID components + +- [x] Find var/list/broadcast +- [x] Find block/prim +- [ ] Add costume/sound +- [ ] Add var/list/broadcast +- [ ] Add arbitrary block/blockchain +- [ ] Asset count +- [ ] Obfuscation +- [ ] Var/list/broadcast/block/comment/whole id list (like `_EnumWrapper.all_of`) +- [ ] Get custom blocks list +- [ ] With statements for sprite to allow for choosing default sprite + +## Vars/lists/broadcasts + +- [ ] idk + +## Monitors + +- [ ] Get relevant var/list if applicable +- [ ] Generate from block + +## Assets + +- [x] Download assets +- [ ] Upload asset +- [ ] Load from file (auto-detect type) + +## Pallet + +- [ ] Add all block defaults (like sbuild.py) +- [ ] Actions (objects that behave like blocks but add multiple blocks - e.g. a 'superjoin' block that you can use to + join more than 2 strings with one block (by actually building multiple join blocks)) \ No newline at end of file diff --git a/scratchattach/editor/twconfig.py b/scratchattach/editor/twconfig.py new file mode 100644 index 00000000..858bd005 --- /dev/null +++ b/scratchattach/editor/twconfig.py @@ -0,0 +1,113 @@ +""" +Parser for TurboWarp settings configuration +""" + +from __future__ import annotations + +import json +import math +from dataclasses import dataclass +from typing import Any + +from . import commons, base + +_START = """Configuration for https://turbowarp.org/ +You can move, resize, and minimize this comment, but don't edit it by hand. This comment can be deleted to remove the stored settings. +""" +_END = " // _twconfig_" + + +@dataclass(init=True, repr=True) +class TWConfig(base.JSONSerializable): + framerate: int = None, + interpolation: bool = False, + hq_pen: bool = False, + max_clones: float | int | None = None, + misc_limits: bool = True, + fencing: bool = True + width: int = None + height: int = None + + @staticmethod + def from_json(data: dict) -> TWConfig: + # Non-runtime options + _framerate = data.get("framerate") + _interpolation = data.get("interpolation", False) + _hq_pen = data.get("hq", False) + + # Runtime options + _runtime_options = data.get("runtimeOptions", {}) + + # Luckily for us, the JSON module actually accepts the 'Infinity' literal. Otherwise, it would be a right pain + _max_clones = _runtime_options.get("maxClones") + _misc_limits = _runtime_options.get("miscLimits", True) + _fencing = _runtime_options.get("fencing", True) + + # Custom stage size + _width = data.get("width") + _height = data.get("height") + + return TWConfig(_framerate, _interpolation, _hq_pen, _max_clones, _misc_limits, _fencing, _width, _height) + + def to_json(self) -> dict: + runtime_options = {} + commons.noneless_update( + runtime_options, + { + "maxClones": self.max_clones, + "miscLimits": none_if_eq(self.misc_limits, True), + "fencing": none_if_eq(self.fencing, True) + }) + + data = {} + commons.noneless_update(data, { + "framerate": self.framerate, + "runtimeOptions": runtime_options, + "interpolation": none_if_eq(self.interpolation, False), + "hq": none_if_eq(self.hq_pen, False), + "width": self.width, + "height": self.height + }) + return data + + @property + def infinite_clones(self): + return self.max_clones == math.inf + + @staticmethod + def from_str(string: str): + return TWConfig.from_json(get_twconfig_data(string)) + + +def is_valid_twconfig(string: str) -> bool: + """ + Checks if some text is TWConfig (does not check the JSON itself) + :param string: text (from a comment) + :return: Boolean whether it is TWConfig + """ + + if string.startswith(_START) and string.endswith(_END): + json_part = string[len(_START):-len(_END)] + if commons.is_valid_json(json_part): + return True + return False + + +def get_twconfig_data(string: str) -> dict | None: + try: + return json.loads(string[len(_START):-len(_END)]) + except ValueError: + return None + + +def none_if_eq(data, compare) -> Any | None: + """ + Returns None if data and compare are the same + :param data: Original data + :param compare: Data to compare + :return: Either the original data or None + """ + if data == compare: + return None + else: + return data diff --git a/scratchattach/editor/vlb.py b/scratchattach/editor/vlb.py new file mode 100644 index 00000000..174f0d89 --- /dev/null +++ b/scratchattach/editor/vlb.py @@ -0,0 +1,134 @@ +""" +Variables, lists & broadcasts +""" +# Perhaps ids should not be stored in these objects, but in the sprite, similarly +# to how blocks/prims are stored + +from __future__ import annotations + +from typing import Literal + +from . import base, sprite, build_defaulting +from ..utils import exceptions + + +class Variable(base.NamedIDComponent): + def __init__(self, _id: str, _name: str, _value: str | int | float = None, _is_cloud: bool = False, + _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): + """ + Class representing a variable. + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets:~:text=variables,otherwise%20not%20present + """ + if _value is None: + _value = 0 + + self.value = _value + self.is_cloud = _is_cloud + + super().__init__(_id, _name, _sprite) + + @property + def is_global(self): + """ + Works out whethere a variable is global based on whether the sprite is a stage + :return: Whether this variable is a global variable. + """ + return self.sprite.is_stage + + @staticmethod + def from_json(data: tuple[str, tuple[str, str | int | float] | tuple[str, str | int | float, bool]]): + """ + Read data in format: (variable id, variable JSON) + """ + assert len(data) == 2 + _id, data = data + + assert len(data) in (2, 3) + _name, _value = data[:2] + + if len(data) == 3: + _is_cloud = data[2] + else: + _is_cloud = False + + return Variable(_id, _name, _value, _is_cloud) + + def to_json(self) -> tuple[str, str | int | float, bool] | tuple[str, str | int | float]: + """ + Returns Variable data as a tuple + """ + if self.is_cloud: + _ret = self.name, self.value, True + else: + _ret = self.name, self.value + + return _ret + + +class List(base.NamedIDComponent): + def __init__(self, _id: str, _name: str, _value: list[str | int | float] = None, + _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): + """ + Class representing a list. + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets:~:text=lists,as%20an%20array + """ + if _value is None: + _value = [] + + self.value = _value + super().__init__(_id, _name, _sprite) + + @staticmethod + def from_json(data: tuple[str, tuple[str, str | int | float] | tuple[str, str | int | float, bool]]): + """ + Read data in format: (variable id, variable JSON) + """ + assert len(data) == 2 + _id, data = data + + assert len(data) == 2 + _name, _value = data + + return List(_id, _name, _value) + + def to_json(self) -> tuple[str, tuple[str, str | int | float, bool] | tuple[str, str | int | float]]: + """ + Returns List data as a tuple + """ + return self.name, self.value + + +class Broadcast(base.NamedIDComponent): + def __init__(self, _id: str, _name: str, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): + """ + Class representing a broadcast. + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets:~:text=broadcasts,in%20the%20stage + """ + super().__init__(_id, _name, _sprite) + + @staticmethod + def from_json(data: tuple[str, str]): + assert len(data) == 2 + _id, _name = data + + return Broadcast(_id, _name) + + def to_json(self) -> str: + """ + :return: Broadcast as JSON (just a string of its name) + """ + return self.name + + +def construct(vlb_type: Literal["variable", "list", "broadcast"], _id: str = None, _name: str = None, + _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT) -> Variable | List | Broadcast: + if vlb_type == "variable": + vlb_type = Variable + elif vlb_type == "list": + vlb_type = List + elif vlb_type == "broadcast": + vlb_type = Broadcast + else: + raise exceptions.InvalidVLBName(f"Bad VLB {vlb_type!r}") + + return vlb_type(_id, _name, _sprite) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index b94b6886..5b906b44 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from ..utils.requests import Requests as requests from threading import Thread diff --git a/scratchattach/eventhandlers/cloud_events.py b/scratchattach/eventhandlers/cloud_events.py index 87b7586d..6f2c27fd 100644 --- a/scratchattach/eventhandlers/cloud_events.py +++ b/scratchattach/eventhandlers/cloud_events.py @@ -1,4 +1,5 @@ """CloudEvents class""" +from __future__ import annotations from ..cloud import cloud from ._base import BaseEventHandler diff --git a/scratchattach/eventhandlers/cloud_recorder.py b/scratchattach/eventhandlers/cloud_recorder.py index 6a2474a7..14eb1dcc 100644 --- a/scratchattach/eventhandlers/cloud_recorder.py +++ b/scratchattach/eventhandlers/cloud_recorder.py @@ -1,21 +1,25 @@ """CloudRecorder class (used by ScratchCloud, TwCloud and other classes inheriting from BaseCloud to deliver cloud var values)""" +from __future__ import annotations from .cloud_events import CloudEvents + class CloudRecorder(CloudEvents): + def __init__(self, cloud, *, initial_values: dict = None): + if initial_values is None: + initial_values = {} - def __init__(self, cloud, *, initial_values={}): super().__init__(cloud) self.cloud_values = initial_values self.event(self.on_set) def get_var(self, var): - if not var in self.cloud_values: + if var not in self.cloud_values: return None return self.cloud_values[var] - + def get_all_vars(self): return self.cloud_values - + def on_set(self, activity): self.cloud_values[activity.var] = activity.value diff --git a/scratchattach/eventhandlers/cloud_requests.py b/scratchattach/eventhandlers/cloud_requests.py index d3fcfa9b..ff0888e7 100644 --- a/scratchattach/eventhandlers/cloud_requests.py +++ b/scratchattach/eventhandlers/cloud_requests.py @@ -1,4 +1,5 @@ """CloudRequests class (threading.Event version)""" +from __future__ import annotations from .cloud_events import CloudEvents from ..site import project @@ -167,8 +168,8 @@ def _parse_output(self, request_name, output, request_id): def _set_FROM_HOST_var(self, value): try: self.cloud.set_var(f"FROM_HOST_{self.used_cloud_vars[self.current_var]}", value) - except exceptions.ConnectionError: - self.call_even("on_disconnect") + except exceptions.CloudConnectionError: + self.call_event("on_disconnect") except Exception as e: print("scratchattach: internal error while responding (please submit a bug report on GitHub):", e) self.current_var += 1 diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 60e66ed3..cf0af26c 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket from threading import Thread from ..utils import exceptions @@ -223,7 +225,7 @@ def _check_value(self, value): return False return True - def _update(self): + def _updater(self): try: # Function called when .start() is executed (.start is inherited from BaseEventHandler) print(f"Serving websocket server: ws://{hostname}:{port}") @@ -241,4 +243,4 @@ def stop(self): self.running = False self.close() - return TwCloudServer(hostname, port=port, websocketclass=TwCloudSocket) \ No newline at end of file + return TwCloudServer(hostname, port=port, websocketclass=TwCloudSocket) diff --git a/scratchattach/eventhandlers/cloud_storage.py b/scratchattach/eventhandlers/cloud_storage.py index 199bb179..3a52bedb 100644 --- a/scratchattach/eventhandlers/cloud_storage.py +++ b/scratchattach/eventhandlers/cloud_storage.py @@ -1,4 +1,5 @@ """CloudStorage class""" +from __future__ import annotations from .cloud_requests import CloudRequests import json diff --git a/scratchattach/eventhandlers/combine.py b/scratchattach/eventhandlers/combine.py index 2ac3fc3b..3abd86d9 100644 --- a/scratchattach/eventhandlers/combine.py +++ b/scratchattach/eventhandlers/combine.py @@ -1,3 +1,5 @@ +from __future__ import annotations + class MultiEventHandler: def __init__(self, *handlers): diff --git a/scratchattach/eventhandlers/filterbot.py b/scratchattach/eventhandlers/filterbot.py index 829b5382..73498dd6 100644 --- a/scratchattach/eventhandlers/filterbot.py +++ b/scratchattach/eventhandlers/filterbot.py @@ -1,4 +1,5 @@ """FilterBot class""" +from __future__ import annotations from .message_events import MessageEvents import time diff --git a/scratchattach/eventhandlers/message_events.py b/scratchattach/eventhandlers/message_events.py index ec548b54..574f2360 100644 --- a/scratchattach/eventhandlers/message_events.py +++ b/scratchattach/eventhandlers/message_events.py @@ -1,4 +1,5 @@ """MessageEvents class""" +from __future__ import annotations from ..site import user from ._base import BaseEventHandler diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index df8d4235..2bc9ed03 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -1,38 +1,53 @@ """Other Scratch API-related functions""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field from ..utils import commons +from ..utils.enums import Languages, Language, TTSVoices, TTSVoice +from ..utils.exceptions import BadRequest, InvalidLanguage, InvalidTTSGender from ..utils.requests import Requests as requests -import json + # --- Front page --- def get_news(*, limit=10, offset=0): - return commons.api_iterative("https://api.scratch.mit.edu/news", limit = limit, offset = offset) + return commons.api_iterative("https://api.scratch.mit.edu/news", limit=limit, offset=offset) + def featured_data(): return requests.get("https://api.scratch.mit.edu/proxy/featured").json() + def featured_projects(): return featured_data()["community_featured_projects"] + def featured_studios(): return featured_data()["community_featured_studios"] + def top_loved(): return featured_data()["community_most_loved_projects"] + def top_remixed(): return featured_data()["community_most_remixed_projects"] + def newest_projects(): return featured_data()["community_newest_projects"] + def curated_projects(): return featured_data()["curator_top_projects"] + def design_studio_projects(): return featured_data()["scratch_design_studio"] + # --- Statistics --- def total_site_stats(): @@ -40,14 +55,17 @@ def total_site_stats(): data.pop("_TS") return data + def monthly_site_traffic(): data = requests.get("https://scratch.mit.edu/statistics/data/monthly-ga/").json() data.pop("_TS") return data + def country_counts(): return requests.get("https://scratch.mit.edu/statistics/data/monthly/").json()["country_distribution"] + def age_distribution(): data = requests.get("https://scratch.mit.edu/statistics/data/monthly/").json()["age_distribution_data"][0]["values"] return_data = {} @@ -55,18 +73,23 @@ def age_distribution(): return_data[value["x"]] = value["y"] return return_data + def monthly_comment_activity(): return requests.get("https://scratch.mit.edu/statistics/data/monthly/").json()["comment_data"] + def monthly_project_shares(): return requests.get("https://scratch.mit.edu/statistics/data/monthly/").json()["project_data"] + def monthly_active_users(): return requests.get("https://scratch.mit.edu/statistics/data/monthly/").json()["active_user_data"] + def monthly_activity_trends(): return requests.get("https://scratch.mit.edu/statistics/data/monthly/").json()["activity_data"] + # --- CSRF Token Generation API --- def get_csrf_token(): @@ -80,32 +103,103 @@ def get_csrf_token(): "https://scratch.mit.edu/csrf_token/" ).headers["set-cookie"].split(";")[3][len(" Path=/, scratchcsrftoken="):] + # --- Various other api.scratch.mit.edu API endpoints --- def get_health(): return requests.get("https://api.scratch.mit.edu/health").json() + def get_total_project_count() -> int: return requests.get("https://api.scratch.mit.edu/projects/count/all").json()["count"] + def check_username(username): return requests.get(f"https://api.scratch.mit.edu/accounts/checkusername/{username}").json()["msg"] + def check_password(password): - return requests.post("https://api.scratch.mit.edu/accounts/checkpassword/", json={"password":password}).json()["msg"] + return requests.post("https://api.scratch.mit.edu/accounts/checkpassword/", json={"password": password}).json()[ + "msg"] + # --- April fools endpoints --- def aprilfools_get_counter() -> int: return requests.get("https://api.scratch.mit.edu/surprise").json()["surprise"] + def aprilfools_increment_counter() -> int: return requests.post("https://api.scratch.mit.edu/surprise").json()["surprise"] + # --- Resources --- def get_resource_urls(): return requests.get("https://resources.scratch.mit.edu/localized-urls.json").json() + +# --- ScratchTools endpoints --- +def scratchtools_online_status(username: str) -> bool | None: + """ + Get the online status of an account. + :return: Boolean whether the account is online; if they do not use scratchtools, return None. + """ + data = requests.get(f"https://data.scratchtools.app/isonline/{username}").json() + + if data["scratchtools"]: + return data["online"] + else: + return None + + +def scratchtools_beta_user(username: str) -> bool: + """ + Get whether a user is a scratchtools beta tester (I think that's what it means) + """ + return requests.get(f"https://data.scratchtools.app/isbeta/{username}").json()["beta"] + + +def scratchtools_display_name(username: str) -> str | None: + """ + Get the display name of a user for scratchtools. Returns none if there is no display name or the username is invalid + """ + return requests.get(f"https://data.scratchtools.app/name/{username}").json().get("displayName") + + +@dataclass(init=True, repr=True) +class ScratchToolsTutorial: + title: str + description: str = field(repr=False) + id: str + + @classmethod + def from_json(cls, data: dict[str, str]) -> ScratchToolsTutorial: + return cls(**data) + + @property + def yt_link(self): + return f"https://www.youtube.com/watch?v={self.id}" + + +def scratchtools_tutorials() -> list[ScratchToolsTutorial]: + """ + Returns a list of scratchtools tutorials (just yt videos) + """ + data_list = requests.get("https://data.scratchtools.app/tutorials/").json() + return [ScratchToolsTutorial.from_json(data) for data in data_list] + + +def scratchtools_emoji_status(username: str) -> str | None: + return requests.get(f"https://data.scratchtools.app/status/{username}").json().get("status", + '🍪') # Cookie is the default status, even if the user does not use ScratchTools + + +def scratchtools_pinned_comment(project_id: int) -> dict[str, str | int]: + data = requests.get(f"https://data.scratchtools.app/pinned/{project_id}/").json() + # Maybe use this info to instantiate a partial comment object? + return data + + # --- Misc --- # I'm not sure what to label this as def scratch_team_members() -> dict: @@ -115,3 +209,75 @@ def scratch_team_members() -> dict: text = text.split("\"}]')")[0] + "\"}]" return json.loads(text) + + +def send_password_reset_email(username: str = None, email: str = None): + requests.post("https://scratch.mit.edu/accounts/password_reset/", data={ + "username": username, + "email": email, + }, headers=commons.headers, cookies={"scratchcsrftoken": 'a'}) + + +def translate(language: str | Languages, text: str = "hello"): + if isinstance(language, str): + lang = Languages.find_by_attrs(language.lower(), ["code", "tts_locale", "name"], str.lower) + elif isinstance(language, Languages): + lang = language.value + else: + lang = language + + if not isinstance(lang, Language): + raise InvalidLanguage(f"{language} is not a language") + + if lang.code is None: + raise InvalidLanguage(f"{lang} is not a valid translate language") + + response_json = requests.get( + f"https://translate-service.scratch.mit.edu/translate?language={lang.code}&text={text}").json() + + if "result" in response_json: + return response_json["result"] + else: + raise BadRequest(f"Language '{language}' does not seem to be valid.\nResponse: {response_json}") + + +def text2speech(text: str = "hello", voice_name: str = "female", language: str = "en-US"): + """ + Sends a request to Scratch's TTS synthesis service. + Returns: + - The TTS audio (mp3) as bytes + - The playback rate (e.g. for giant it would be 0.84) + """ + if isinstance(voice_name, str): + voice = TTSVoices.find_by_attrs(voice_name.lower(), ["name", "gender"], str.lower) + elif isinstance(voice_name, TTSVoices): + voice = voice_name.value + else: + voice = voice_name + + if not isinstance(voice, TTSVoice): + raise InvalidTTSGender(f"TTS Gender {voice_name} is not supported.") + + # If it's kitten, make sure to change everything to just meows + if voice.name == "kitten": + text = '' + for word in text.split(' '): + if word.strip() != '': + text += "meow " + + if isinstance(language, str): + lang = Languages.find_by_attrs(language.lower(), ["code", "tts_locale", "name"], str.lower) + elif isinstance(language, Languages): + lang = language.value + else: + lang = language + + if not isinstance(lang, Language): + raise InvalidLanguage(f"Language '{language}' is not a language") + + if lang.tts_locale is None: + raise InvalidLanguage(f"Language '{language}' is not a valid TTS language") + + response = requests.get(f"https://synthesis-service.scratch.mit.edu/synth" + f"?locale={lang.tts_locale}&gender={voice.gender}&text={text}") + return response.content, voice.playback_rate diff --git a/scratchattach/other/project_json_capabilities.py b/scratchattach/other/project_json_capabilities.py index 06d65c29..4e114591 100644 --- a/scratchattach/other/project_json_capabilities.py +++ b/scratchattach/other/project_json_capabilities.py @@ -1,89 +1,77 @@ """Project JSON reading and editing capabilities. This code is still in BETA, there are still bugs and potential consistency issues to be fixed. New features will be added.""" +# Note: You may want to make this into multiple files for better organisation +from __future__ import annotations + +import hashlib +import json import random -import zipfile import string +import zipfile from abc import ABC, abstractmethod from ..utils import exceptions -from ..utils.requests import Requests as requests from ..utils.commons import empty_project_json -import json -import hashlib - -def load_components(json_data:list, ComponentClass, target_list): +from ..utils.requests import Requests as requests +# noinspection PyPep8Naming +def load_components(json_data: list, ComponentClass: type, target_list: list): for element in json_data: component = ComponentClass() component.from_json(element) target_list.append(component) - class ProjectBody: - class BaseProjectBodyComponent(ABC): - def __init__(self, **entries): # Attributes every object needs to have: self.id = None # Update attributes from entries dict: self.__dict__.update(entries) - @abstractmethod - def from_json(self, data:dict): + def from_json(self, data: dict): pass - @abstractmethod def to_json(self): pass - def _generate_new_id(self): """ Generates a new id and updates the id. - Warning: 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. """ self.id = ''.join(random.choices(string.ascii_letters + string.digits, k=20)) - - class Block(BaseProjectBodyComponent): - # Thanks to @MonkeyBean2 for some scripts - def from_json(self, data: dict): - self.opcode = data["opcode"] # The name of the block - self.next_id = data.get("next", None) # The id of the block attached below this block - self.parent_id = data.get("parent", None) # The id of the block that this block is attached to - 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) - self.fields = data.get("fields", None) # The values inside the block's inputs - self.shadow = data.get("shadow", False) # Whether the block is displayed with a shadow - self.topLevel = data.get("topLevel", False) # Whether the block has no parent - self.mutation = data.get("mutation", None) # For custom blocks - self.x = data.get("x", None) # x position if topLevel - self.y = data.get("y", None) # y position if topLevel - + self.opcode = data["opcode"] # The name of the block + self.next_id = data.get("next", None) # The id of the block attached below this block + self.parent_id = data.get("parent", None) # The id of the block that this block is attached to + 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) + self.fields = data.get("fields", None) # The values inside the block's inputs + self.shadow = data.get("shadow", False) # Whether the block is displayed with a shadow + self.topLevel = data.get("topLevel", False) # Whether the block has no parent + self.mutation = data.get("mutation", None) # For custom blocks + self.x = data.get("x", None) # x position if topLevel + self.y = data.get("y", None) # y position if topLevel def to_json(self): - 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} + 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} return {k: v for k, v in output.items() if v} - def attached_block(self): return self.sprite.block_by_id(self.next_id) - def previous_block(self): return self.sprite.block_by_id(self.parent_id) - def top_level_block(self): block = self return block - def previous_chain(self): # to implement: a method that detects circular block chains (to make sure this method terminates) chain = [] block = self while block.parent_id is not None: block = block.previous_block() - chain.insert(0,block) + chain.insert(0, block) return chain - def attached_chain(self): chain = [] block = self @@ -91,10 +79,8 @@ def attached_chain(self): block = block.attached_block() chain.append(block) return chain - def complete_chain(self): return self.previous_chain() + [self] + self.attached_chain() - def duplicate_single_block(self): new_block = ProjectBody.Block(**self.__dict__) new_block.parent_id = None @@ -102,7 +88,6 @@ def duplicate_single_block(self): new_block._generate_new_id() self.sprite.blocks.append(new_block) return new_block - def duplicate_chain(self): blocks_to_dupe = [self] + self.attached_chain() duped = [] @@ -112,95 +97,79 @@ def duplicate_chain(self): new_block.next_id = None new_block._generate_new_id() if i != 0: - new_block.parent_id = duped[i-1].id - duped[i-1].next_id = new_block.id + new_block.parent_id = duped[i - 1].id + duped[i - 1].next_id = new_block.id duped.append(new_block) self.sprite.blocks += duped return duped - def _reattach(self, new_parent_id, new_next_id_of_old_parent): if self.parent_id is not None: old_parent_block = self.sprite.block_by_id(self.parent_id) self.sprite.blocks.remove(old_parent_block) old_parent_block.next_id = new_next_id_of_old_parent self.sprite.blocks.append(old_parent_block) - self.parent_id = new_parent_id - if self.parent_id is not None: new_parent_block = self.sprite.block_by_id(self.parent_id) self.sprite.blocks.remove(new_parent_block) new_parent_block.next_id = self.id self.sprite.blocks.append(new_parent_block) - self.topLevel = new_parent_id is None - def reattach_single_block(self, new_parent_id): old_parent_id = str(self.parent_id) self._reattach(new_parent_id, self.next_id) - if self.next_id is not None: old_next_block = self.sprite.block_by_id(self.next_id) self.sprite.blocks.remove(old_next_block) old_next_block.parent_id = old_parent_id self.sprite.blocks.append(old_next_block) - self.next_id = None - def reattach_chain(self, new_parent_id): self._reattach(new_parent_id, None) - def delete_single_block(self): self.sprite.blocks.remove(self) - self.reattach_single_block(None, self.next_id) - def delete_chain(self): self.sprite.blocks.remove(self) - self.reattach_chain(None) - to_delete = self.attached_chain() for block in to_delete: self.sprite.blocks.remove(block) - def inputs_as_blocks(self): if self.input_data is None: return None inputs = [] for input in self.input_data: inputs.append(self.sprite.block_by_id(self.input_data[input][1])) - - class Sprite(BaseProjectBodyComponent): - - def from_json(self, data:dict): + def from_json(self, data: dict): self.isStage = data["isStage"] self.name = data["name"] - self.id = self.name # Sprites are uniquely identifiable through their name + self.id = self.name # Sprites are uniquely identifiable through their name self.variables = [] - for variable_id in data["variables"]: #self.lists is a dict with the list_id as key and info as value + for variable_id in data["variables"]: # self.lists is a dict with the list_id as key and info as value pvar = ProjectBody.Variable(id=variable_id) pvar.from_json(data["variables"][variable_id]) self.variables.append(pvar) self.lists = [] - for list_id in data["lists"]: #self.lists is a dict with the list_id as key and info as value + for list_id in data["lists"]: # self.lists is a dict with the list_id as key and info as value plist = ProjectBody.List(id=list_id) plist.from_json(data["lists"][list_id]) self.lists.append(plist) self.broadcasts = data["broadcasts"] self.blocks = [] - for block_id in data["blocks"]: #self.blocks is a dict with the block_id as key and block content as value - if isinstance(data["blocks"][block_id], dict): # Sometimes there is a weird list at the end of the blocks list. This list is ignored + for block_id in data["blocks"]: # self.blocks is a dict with the block_id as key and block content as value + if isinstance(data["blocks"][block_id], + dict): # Sometimes there is a weird list at the end of the blocks list. This list is ignored block = ProjectBody.Block(id=block_id, sprite=self) block.from_json(data["blocks"][block_id]) self.blocks.append(block) self.comments = data["comments"] self.currentCostume = data["currentCostume"] self.costumes = [] - load_components(data["costumes"], ProjectBody.Asset, self.costumes) # load lists + load_components(data["costumes"], ProjectBody.Asset, self.costumes) # load lists self.sounds = [] - load_components(data["sounds"], ProjectBody.Asset, self.sounds) # load lists + load_components(data["sounds"], ProjectBody.Asset, self.sounds) # load lists self.volume = data["volume"] self.layerOrder = data["layerOrder"] if self.isStage: @@ -216,7 +185,6 @@ def from_json(self, data:dict): self.direction = data.get("direction", None) self.draggable = data.get("draggable", None) self.rotationStyle = data.get("rotationStyle", None) - def to_json(self): return_data = dict(self.__dict__) if "projectBody" in return_data: @@ -234,136 +202,119 @@ def to_json(self): return_data["costumes"] = [custome.to_json() for custome in self.costumes] return_data["sounds"] = [sound.to_json() for sound in self.sounds] return return_data - def variable_by_id(self, variable_id): - matching = list(filter(lambda x : x.id == variable_id, self.variables)) + matching = list(filter(lambda x: x.id == variable_id, self.variables)) if matching == []: return None return matching[0] - def list_by_id(self, list_id): - matching = list(filter(lambda x : x.id == list_id, self.lists)) + matching = list(filter(lambda x: x.id == list_id, self.lists)) if matching == []: return None return matching[0] - def variable_by_name(self, variable_name): - matching = list(filter(lambda x : x.name == variable_name, self.variables)) + matching = list(filter(lambda x: x.name == variable_name, self.variables)) if matching == []: return None return matching[0] - def list_by_name(self, list_name): - matching = list(filter(lambda x : x.name == list_name, self.lists)) + matching = list(filter(lambda x: x.name == list_name, self.lists)) if matching == []: return None return matching[0] - def block_by_id(self, block_id): - matching = list(filter(lambda x : x.id == block_id, self.blocks)) + matching = list(filter(lambda x: x.id == block_id, self.blocks)) if matching == []: return None return matching[0] - # -- Functions to modify project contents -- - def create_sound(self, asset_content, *, name="new sound", dataFormat="mp3", rate=4800, sampleCount=4800): data = asset_content if isinstance(asset_content, bytes) else open(asset_content, "rb").read() - new_asset_id = hashlib.md5(data).hexdigest() - 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) + 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) self.sounds.append(new_asset) if not hasattr(self, "projectBody"): - print("Warning: Since there's no project body connected to this object, the new sound asset won't be uploaded to Scratch") + print( + "Warning: Since there's no project body connected to this object, the new sound asset won't be uploaded to Scratch") elif self.projectBody._session is None: - print("Warning: Since there's no login connected to this object, the new sound asset won't be uploaded to Scratch") + print( + "Warning: Since there's no login connected to this object, the new sound asset won't be uploaded to Scratch") else: self._session.upload_asset(data, asset_id=new_asset_id, file_ext=dataFormat) return new_asset - - def create_costume(self, asset_content, *, name="new costume", dataFormat="svg", rotationCenterX=0, rotationCenterY=0): + def create_costume(self, asset_content, *, name="new costume", dataFormat="svg", rotationCenterX=0, + rotationCenterY=0): data = asset_content if isinstance(asset_content, bytes) else open(asset_content, "rb").read() - new_asset_id = hashlib.md5(data).hexdigest() - 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) + 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) self.costumes.append(new_asset) if not hasattr(self, "projectBody"): - print("Warning: Since there's no project body connected to this object, the new costume asset won't be uploaded to Scratch") + print( + "Warning: Since there's no project body connected to this object, the new costume asset won't be uploaded to Scratch") elif self.projectBody._session is None: - print("Warning: Since there's no login connected to this object, the new costume asset won't be uploaded to Scratch") + print( + "Warning: Since there's no login connected to this object, the new costume asset won't be uploaded to Scratch") else: self._session.upload_asset(data, asset_id=new_asset_id, file_ext=dataFormat) return new_asset - def create_variable(self, name, *, value=0, is_cloud=False): new_var = ProjectBody.Variable(name=name, value=value, is_cloud=is_cloud) self.variables.append(new_var) return new_var - def create_list(self, name, *, value=[]): new_list = ProjectBody.List(name=name, value=value) self.lists.append(new_list) return new_list - def add_block(self, block, *, parent_id=None): block.parent_id = None block.next_id = None if parent_id is not None: block.reattach_single_block(parent_id) self.blocks.append(block) - def add_block_chain(self, block_chain, *, parent_id=None): parent = parent_id for block in block_chain: self.add_block(block, parent_id=parent) parent = str(block.id) - class Variable(BaseProjectBodyComponent): - def __init__(self, **entries): super().__init__(**entries) if self.id is None: self._generate_new_id() - - def from_json(self, data:list): + def from_json(self, data: list): self.name = data[0] self.saved_value = data[1] self.is_cloud = len(data) == 3 - def to_json(self): if self.is_cloud: return [self.name, self.saved_value, True] else: return [self.name, self.saved_value] - def make_cloud_variable(self): self.is_cloud = True - class List(BaseProjectBodyComponent): - def __init__(self, **entries): super().__init__(**entries) if self.id is None: self._generate_new_id() - - def from_json(self, data:list): + def from_json(self, data: list): self.name = data[0] self.saved_content = data[1] - def to_json(self): return [self.name, self.saved_content] - class Monitor(BaseProjectBodyComponent): - - def from_json(self, data:dict): + def from_json(self, data: dict): self.__dict__.update(data) - def to_json(self): return_data = dict(self.__dict__) if "projectBody" in return_data: return_data.pop("projectBody") return return_data - def target(self): if not hasattr(self, "projectBody"): print("Can't get represented object because the origin projectBody of this monitor is not saved") @@ -372,25 +323,21 @@ def target(self): return self.projectBody.sprite_by_name(self.spriteName).variable_by_name(self.params["VARIABLE"]) if "LIST" in self.params: return self.projectBody.sprite_by_name(self.spriteName).list_by_name(self.params["LIST"]) - class Asset(BaseProjectBodyComponent): - - def from_json(self, data:dict): + def from_json(self, data: dict): self.__dict__.update(data) self.id = self.assetId self.filename = self.md5ext self.download_url = f"https://assets.scratch.mit.edu/internalapi/asset/{self.filename}" - def to_json(self): return_data = dict(self.__dict__) return_data.pop("filename") return_data.pop("id") return_data.pop("download_url") return return_data - def download(self, *, filename=None, dir=""): if not (dir.endswith("/") or dir.endswith("\\")): - dir = dir+"/" + dir = dir + "/" try: if filename is None: filename = str(self.filename) @@ -405,16 +352,14 @@ def download(self, *, filename=None, dir=""): "Failed to download asset" ) ) - - def __init__(self, *, sprites=[], monitors=[], extensions=[], meta=[{"agent":None}], _session=None): + def __init__(self, *, sprites=[], monitors=[], extensions=[], meta=[{"agent": None}], _session=None): # sprites are called "targets" in the initial API response self.sprites = sprites self.monitors = monitors self.extensions = extensions self.meta = meta self._session = _session - - def from_json(self, data:dict): + def from_json(self, data: dict): """ Imports the project data from a dict that contains the raw project json """ @@ -423,17 +368,16 @@ def from_json(self, data:dict): load_components(data["targets"], ProjectBody.Sprite, self.sprites) # Save origin of sprite in Sprite object: for sprite in self.sprites: - sprite.projectBody = self - # Load monitors: + sprite.projectBody = self + # Load monitors: self.monitors = [] load_components(data["monitors"], ProjectBody.Monitor, self.monitors) # Save origin of monitor in Monitor object: for monitor in self.monitors: monitor.projectBody = self - # Set extensions and meta attributs: + # Set extensions and meta attributs: self.extensions = data["extensions"] self.meta = data["meta"] - def to_json(self): """ Returns a valid project JSON dict with the contents of this project @@ -444,46 +388,36 @@ def to_json(self): return_data["extensions"] = self.extensions return_data["meta"] = self.meta return return_data - # -- Functions to get info -- - def blocks(self): return [block for sprite in self.sprites for block in sprite.blocks] - def block_count(self): return len(self.blocks()) - def assets(self): - return [sound for sprite in self.sprites for sound in sprite.sounds] + [costume for sprite in self.sprites for costume in sprite.costumes] - + return [sound for sprite in self.sprites for sound in sprite.sounds] + [costume for sprite in self.sprites for + costume in sprite.costumes] def asset_count(self): return len(self.assets()) - def variable_by_id(self, variable_id): for sprite in self.sprites: r = sprite.variable_by_id(variable_id) if r is not None: return r - def list_by_id(self, list_id): for sprite in self.sprites: r = sprite.list_by_id(list_id) if r is not None: return r - def sprite_by_name(self, sprite_name): - matching = list(filter(lambda x : x.name == sprite_name, self.sprites)) + matching = list(filter(lambda x: x.name == sprite_name, self.sprites)) if matching == []: return None return matching[0] - def user_agent(self): return self.meta["agent"] - def save(self, *, filename=None, dir=""): """ Saves the project body to the given directory. - Args: filename (str): The name that will be given to the downloaded file. dir (str): The path of the directory the file will be saved in. @@ -495,17 +429,14 @@ def save(self, *, filename=None, dir=""): filename = filename.replace(".sb3", "") with open(f"{dir}{filename}.sb3", "w") as d: json.dump(self.to_json(), d, indent=4) - def get_empty_project_pb(): pb = ProjectBody() pb.from_json(empty_project_json) return pb - -def get_pb_from_dict(project_json:dict): +def get_pb_from_dict(project_json: dict): pb = ProjectBody() pb.from_json(project_json) return pb - def _load_sb3_file(path_to_file): try: with open(path_to_file, "r") as r: @@ -519,20 +450,18 @@ def _load_sb3_file(path_to_file): return json.loads(file.read()) else: raise ValueError("specified sb3 archive doesn't contain project.json") - def read_sb3_file(path_to_file): pb = ProjectBody() pb.from_json(_load_sb3_file(path_to_file)) return pb - def download_asset(asset_id_with_file_ext, *, filename=None, dir=""): if not (dir.endswith("/") or dir.endswith("\\")): - dir = dir+"/" + dir = dir + "/" try: if filename is None: filename = str(asset_id_with_file_ext) response = requests.get( - "https://assets.scratch.mit.edu/"+str(asset_id_with_file_ext), + "https://assets.scratch.mit.edu/" + str(asset_id_with_file_ext), timeout=10, ) open(f"{dir}{filename}", "wb").write(response.content) @@ -543,4 +472,4 @@ def download_asset(asset_id_with_file_ext, *, filename=None, dir=""): ) ) -# The method for uploading an asset by id requires authentication and can be found in the site.session.Session class \ No newline at end of file +# The method for uploading an asset by id requires authentication and can be found in the site.session.Session class diff --git a/scratchattach/site/_base.py b/scratchattach/site/_base.py index 29514653..86c542e4 100644 --- a/scratchattach/site/_base.py +++ b/scratchattach/site/_base.py @@ -1,9 +1,19 @@ +from __future__ import annotations + from abc import ABC, abstractmethod + import requests -from threading import Thread from ..utils import exceptions, commons +from types import FunctionType + class BaseSiteComponent(ABC): + @abstractmethod + def __init__(self): + self._session = None + self._cookies = None + self._headers = None + self.update_API = None def update(self): """ @@ -11,18 +21,22 @@ def update(self): """ response = self.update_function( self.update_API, - headers = self._headers, - cookies = self._cookies, timeout=10 + headers=self._headers, + cookies=self._cookies, timeout=10 ) # Check for 429 error: + # Note, this is a bit naïve if "429" in str(response): return "429" + if response.text == '{\n "response": "Too many requests"\n}': return "429" + # If no error: Parse JSON: response = response.json() if "code" in response: return False + return self._update_from_dict(response) @abstractmethod @@ -37,10 +51,14 @@ def _assert_auth(self): raise exceptions.Unauthenticated( "You need to use session.connect_xyz (NOT get_xyz) in order to perform this operation.") - def _make_linked_object(self, identificator_id, identificator, Class, NotFoundException): + def _make_linked_object(self, identificator_id, identificator, Class, NotFoundException) -> BaseSiteComponent: """ Internal function for making a linked object (authentication kept) based on an identificator (like a project id or username) Class must inherit from BaseSiteComponent """ return commons._get_object(identificator_id, identificator, Class, NotFoundException, self._session) + update_function: FunctionType = requests.get + """ + Internal function run on update. Function is a method of the 'requests' module/class + """ diff --git a/scratchattach/site/activity.py b/scratchattach/site/activity.py index 6fcc26d4..6d90e7c3 100644 --- a/scratchattach/site/activity.py +++ b/scratchattach/site/activity.py @@ -1,26 +1,20 @@ """Activity and CloudActivity class""" +from __future__ import annotations -import json -import re -import time +from bs4 import PageElement -from . import user -from . import session -from . import project -from . import studio -from . import forum, comment -from ..utils import exceptions +from . import user, project, studio from ._base import BaseSiteComponent -from ..utils.commons import headers -from bs4 import BeautifulSoup +from ..utils import exceptions -from ..utils.requests import Requests as requests class Activity(BaseSiteComponent): - - ''' + """ Represents a Scratch activity (message or other user page activity) - ''' + """ + + def __repr__(self): + return repr(self.raw) def str(self): return str(self.raw) @@ -31,44 +25,339 @@ def __init__(self, **entries): self._session = None self.raw = None + # Possible attributes + self.project_id = None + self.gallery_id = None + + self.username = None + self.followed_username = None + self.recipient_username = None + + self.comment_type = None + self.comment_obj_id = None + self.comment_obj_title = None + self.comment_id = None + + self.datetime_created = None + self.time = None + self.type = None + # Update attributes from entries dict: self.__dict__.update(entries) - def update(): + def update(self): print("Warning: Activity objects can't be updated") - return False # Objects of this type cannot be updated + return False # Objects of this type cannot be updated def _update_from_dict(self, data): self.raw = data self.__dict__.update(data) return True - def _update_from_html(self, data): + def _update_from_json(self, data: dict): + """ + Update using JSON, used in the classroom API. + """ + activity_type = data["type"] + + _time = data["datetime_created"] if "datetime_created" in data else None + + if "actor" in data: + username = data["actor"]["username"] + elif "actor_username" in data: + username = data["actor_username"] + else: + username = None + + if data.get("recipient") is not None: + recipient_username = data["recipient"]["username"] + + elif data.get("recipient_username") is not None: + recipient_username = data["recipient_username"] + + elif data.get("project_creator") is not None: + recipient_username = data["project_creator"]["username"] + else: + recipient_username = None + + default_case = False + """Whether this is 'blank'; it will default to 'user performed an action'""" + if activity_type == 0: + # follow + followed_username = data["followed_username"] + + self.raw = f"{username} followed user {followed_username}" + + self.datetime_created = _time + self.type = "followuser" + self.username = username + self.followed_username = followed_username + + elif activity_type == 1: + # follow studio + studio_id = data["gallery"] + + raw = f"{username} followed studio https://scratch.mit.edu/studios/{studio_id}" + + self.raw = raw + self.datetime_created = _time + self.type = "followstudio" + + self.username = username + self.gallery_id = studio_id + + elif activity_type == 2: + # love project + project_id = data["project"] + + raw = f"{username} loved project https://scratch.mit.edu/projects/{project_id}" + + self.raw = raw + self.datetime_created = _time, + self.type = "loveproject" + + self.username = username + self.project_id = project_id + self.recipient_username = recipient_username + + elif activity_type == 3: + # Favorite project + project_id = data["project"] + + raw = f"{username} favorited project https://scratch.mit.edu/projects/{project_id}" + + self.raw = raw + self.datetime_created = _time + self.type = "favoriteproject" + + self.username = username + self.project_id = project_id + self.recipient_username = recipient_username + + elif activity_type == 7: + # Add project to studio + + project_id = data["project"] + studio_id = data["gallery"] + + raw = f"{username} added the project https://scratch.mit.edu/projects/{project_id} to studio https://scratch.mit.edu/studios/{studio_id}" + + self.raw = raw + self.datetime_created = _time + self.type = "addprojecttostudio" + + self.username = username + self.project_id = project_id + self.recipient_username = recipient_username + + elif activity_type == 8: + default_case = True + + elif activity_type == 9: + default_case = True + + elif activity_type == 10: + # Share/Reshare project + project_id = data["project"] + is_reshare = data["is_reshare"] + + raw_reshare = "reshared" if is_reshare else "shared" + + raw = f"{username} {raw_reshare} the project https://scratch.mit.edu/projects/{project_id}" + + self.raw = raw + self.datetime_created = _time + self.type = "shareproject" + + self.username = username + self.project_id = project_id + self.recipient_username = recipient_username + + elif activity_type == 11: + # Remix + parent_id = data["parent"] + + raw = f"{username} remixed the project https://scratch.mit.edu/projects/{parent_id}" + + self.raw = raw + self.datetime_created = _time + self.type = "remixproject" + + self.username = username + self.project_id = parent_id + self.recipient_username = recipient_username + + elif activity_type == 12: + default_case = True + + elif activity_type == 13: + # Create ('add') studio + studio_id = data["gallery"] + + raw = f"{username} created the studio https://scratch.mit.edu/studios/{studio_id}" + + self.raw = raw + self.datetime_created = _time + self.type = "createstudio" + + self.username = username + self.gallery_id = studio_id + + elif activity_type == 15: + # Update studio + studio_id = data["gallery"] + + raw = f"{username} updated the studio https://scratch.mit.edu/studios/{studio_id}" + + self.raw = raw + self.datetime_created = _time + self.type = "updatestudio" + + self.username = username + self.gallery_id = studio_id + + elif activity_type == 16: + default_case = True + + elif activity_type == 17: + default_case = True + + elif activity_type == 18: + default_case = True + + elif activity_type == 19: + # Remove project from studio + + project_id = data["project"] + studio_id = data["gallery"] + + raw = f"{username} removed the project https://scratch.mit.edu/projects/{project_id} from studio https://scratch.mit.edu/studios/{studio_id}" + + self.raw = raw + self.datetime_created = _time + self.type = "removeprojectfromstudio" + + self.username = username + self.project_id = project_id + + elif activity_type == 20: + default_case = True + + elif activity_type == 21: + default_case = True + + elif activity_type == 22: + # Was promoted to manager for studio + studio_id = data["gallery"] + + raw = f"{recipient_username} was promoted to manager by {username} for studio https://scratch.mit.edu/studios/{studio_id}" + + self.raw = raw + self.datetime_created = _time + self.type = "promotetomanager" + + self.username = username + self.recipient_username = recipient_username + self.gallery_id = studio_id + + elif activity_type == 23: + default_case = True + + elif activity_type == 24: + default_case = True + + elif activity_type == 25: + # Update profile + raw = f"{username} made a profile update" + + self.raw = raw + self.datetime_created = _time + self.type = "updateprofile" + + self.username = username + + elif activity_type == 26: + default_case = True + + elif activity_type == 27: + # Comment (quite complicated) + comment_type: int = data["comment_type"] + fragment = data["comment_fragment"] + comment_id = data["comment_id"] + comment_obj_id = data["comment_obj_id"] + comment_obj_title = data["comment_obj_title"] + + if comment_type == 0: + # Project comment + raw = f"{username} commented on project https://scratch.mit.edu/projects/{comment_obj_id}/#comments-{comment_id} {fragment!r}" + + elif comment_type == 1: + # Profile comment + raw = f"{username} commented on user https://scratch.mit.edu/users/{comment_obj_title}/#comments-{comment_id} {fragment!r}" + + elif comment_type == 2: + # Studio comment + # Scratch actually provides an incorrect link, but it is fixed here: + raw = f"{username} commented on studio https://scratch.mit.edu/studios/{comment_obj_id}/comments/#comments-{comment_id} {fragment!r}" + + else: + raw = f"{username} commented {fragment!r}" # This should never happen + + self.raw = raw + self.datetime_created = _time + self.type = "addcomment" + + self.username = username + + self.comment_type = comment_type + self.comment_obj_id = comment_obj_id + self.comment_obj_title = comment_obj_title + self.comment_id = comment_id + + else: + default_case = True + + if default_case: + # This is coded in the scratch HTML, haven't found an example of it though + raw = f"{username} performed an action" + + self.raw = raw + self.datetime_created = _time + self.type = "performaction" + + self.username = username + + def _update_from_html(self, data: PageElement): self.raw = data - time=data.find('div').find('span').findNext().findNext().text.strip() + _time = data.find('div').find('span').findNext().findNext().text.strip() - if '\xa0' in time: - while '\xa0' in time: time=time.replace('\xa0', ' ') + if '\xa0' in _time: + while '\xa0' in _time: + _time = _time.replace('\xa0', ' ') - self.time = time - self.actor_username=(data.find('div').find('span').text) + self.datetime_created = _time + self.actor_username = data.find('div').find('span').text - self.target_name=(data.find('div').find('span').findNext().text) - self.target_link=(data.find('div').find('span').findNext()["href"]) - self.target_id=(data.find('div').find('span').findNext()["href"].split("/")[-2]) + self.target_name = data.find('div').find('span').findNext().text + self.target_link = data.find('div').find('span').findNext()["href"] + self.target_id = data.find('div').find('span').findNext()["href"].split("/")[-2] - self.type=data.find('div').find_all('span')[0].next_sibling.strip() + self.type = data.find('div').find_all('span')[0].next_sibling.strip() if self.type == "loved": self.type = "loveproject" - if self.type == "favorited": + + elif self.type == "favorited": self.type = "favoriteproject" - if "curator" in self.type: + + elif "curator" in self.type: self.type = "becomecurator" - if "shared" in self.type: + + elif "shared" in self.type: self.type = "shareproject" - if "is now following" in self.type: + + elif "is now following" in self.type: if "users" in self.target_link: self.type = "followuser" else: @@ -87,14 +376,14 @@ def target(self): Returns the activity's target (depending on the activity, this is either a User, Project, Studio or Comment object). May also return None if the activity type is unknown. """ - - if "project" in self.type: # target is a project + + if "project" in self.type: # target is a project if "target_id" in self.__dict__: return self._make_linked_object("id", self.target_id, project.Project, exceptions.ProjectNotFound) if "project_id" in self.__dict__: return self._make_linked_object("id", self.project_id, project.Project, exceptions.ProjectNotFound) - - if self.type == "becomecurator" or self.type == "followstudio": # target is a studio + + if self.type == "becomecurator" or self.type == "followstudio": # target is a studio if "target_id" in self.__dict__: return self._make_linked_object("id", self.target_id, studio.Studio, exceptions.StudioNotFound) if "gallery_id" in self.__dict__: @@ -102,21 +391,24 @@ def target(self): # NOTE: the "becomecurator" type is ambigous - if it is inside the studio activity tab, the target is the user who joined if "username" in self.__dict__: return self._make_linked_object("username", self.username, user.User, exceptions.UserNotFound) - - if self.type == "followuser" or "curator" in self.type: # target is a user + + if self.type == "followuser" or "curator" in self.type: # target is a user if "target_name" in self.__dict__: return self._make_linked_object("username", self.target_name, user.User, exceptions.UserNotFound) if "followed_username" in self.__dict__: return self._make_linked_object("username", self.followed_username, user.User, exceptions.UserNotFound) - if "recipient_username" in self.__dict__: # the recipient_username field always indicates the target is a user + if "recipient_username" in self.__dict__: # the recipient_username field always indicates the target is a user return self._make_linked_object("username", self.recipient_username, user.User, exceptions.UserNotFound) - - if self.type == "addcomment": # target is a comment + + if self.type == "addcomment": # target is a comment if self.comment_type == 0: - _c = project.Project(id=self.comment_obj_id, author_name=self._session.username, _session=self._session).comment_by_id(self.comment_id) + _c = project.Project(id=self.comment_obj_id, author_name=self._session.username, + _session=self._session).comment_by_id(self.comment_id) if self.comment_type == 1: _c = user.User(username=self.comment_obj_title, _session=self._session).comment_by_id(self.comment_id) if self.comment_type == 2: - _c = user.User(id=self.comment_obj_id, _session=self._session).comment_by_id(self.comment_id) + _c = user.User(id=self.comment_obj_id, _session=self._session).comment_by_id(self.comment_id) + else: + raise ValueError(f"{self.comment_type} is an invalid comment type") + return _c - \ No newline at end of file diff --git a/scratchattach/site/backpack_asset.py b/scratchattach/site/backpack_asset.py index fae2768b..48b65aac 100644 --- a/scratchattach/site/backpack_asset.py +++ b/scratchattach/site/backpack_asset.py @@ -1,7 +1,14 @@ +from __future__ import annotations + +import json import time +import logging + from ._base import BaseSiteComponent -from ..utils.requests import Requests as requests from ..utils import exceptions +from ..utils.requests import Requests as requests + + class BackpackAsset(BaseSiteComponent): """ @@ -33,52 +40,79 @@ def __init__(self, **entries): def update(self): print("Warning: BackpackAsset objects can't be updated") - return False # Objects of this type cannot be updated + return False # Objects of this type cannot be updated + def _update_from_dict(self, data) -> bool: - try: self.id = data["id"] - except Exception: pass - try: self.type = data["type"] - except Exception: pass - try: self.mime = data["mime"] - except Exception: pass - try: self.name = data["name"] - except Exception: pass - try: self.filename = data["body"] - except Exception: pass - try: self.thumbnail_url = "https://backpack.scratch.mit.edu/"+data["thumbnail"] - except Exception: pass - try: self.download_url = "https://backpack.scratch.mit.edu/"+data["body"] - except Exception: pass + try: + self.id = data["id"] + except Exception: + pass + try: + self.type = data["type"] + except Exception: + pass + try: + self.mime = data["mime"] + except Exception: + pass + try: + self.name = data["name"] + except Exception: + pass + try: + self.filename = data["body"] + except Exception: + pass + try: + self.thumbnail_url = "https://backpack.scratch.mit.edu/" + data["thumbnail"] + except Exception: + pass + try: + self.download_url = "https://backpack.scratch.mit.edu/" + data["body"] + except Exception: + pass return True - def download(self, *, dir=""): + @property + def _data_bytes(self) -> bytes: + try: + return requests.get(self.download_url).content + except Exception as e: + raise exceptions.FetchError(f"Failed to download asset: {e}") + + @property + def file_ext(self): + return self.filename.split(".")[-1] + + @property + def is_json(self): + return self.file_ext == "json" + + @property + def data(self) -> dict | list | int | None | str | bytes | float: + if self.is_json: + return json.loads(self._data_bytes) + else: + # It's either a zip + return self._data_bytes + + def download(self, *, fp: str = ''): """ Downloads the asset content to the given directory. The given filename is equal to the value saved in the .filename attribute. Args: - dir (str): The path of the directory the file will be saved in. + fp (str): The path of the directory the file will be saved in. """ - if not (dir.endswith("/") or dir.endswith("\\")): - dir = dir+"/" - try: - response = requests.get( - self.download_url, - timeout=10, - ) - open(f"{dir}{self.filename}", "wb").write(response.content) - except Exception as e: - raise ( - exceptions.FetchError( - "Failed to download asset: "+str(e) - ) - ) + if not (fp.endswith("/") or fp.endswith("\\")): + fp = fp + "/" + open(f"{fp}{self.filename}", "wb").write(self._data_bytes) def delete(self): self._assert_auth() return requests.delete( f"https://backpack.scratch.mit.edu/{self._session.username}/{self.id}", - headers = self._session._headers, - timeout = 10, + headers=self._session._headers, + timeout=10, ).json() diff --git a/scratchattach/site/classroom.py b/scratchattach/site/classroom.py index 0744d30a..50212745 100644 --- a/scratchattach/site/classroom.py +++ b/scratchattach/site/classroom.py @@ -1,23 +1,37 @@ +from __future__ import annotations + import datetime -import requests -from . import user, session -from ..utils.commons import api_iterative, headers -from ..utils import exceptions, commons +import warnings +from typing import TYPE_CHECKING, Any + +import bs4 + +if TYPE_CHECKING: + from ..site.session import Session + +from ..utils.commons import requests +from . import user, activity from ._base import BaseSiteComponent +from ..utils import exceptions, commons +from ..utils.commons import headers + +from bs4 import BeautifulSoup + class Classroom(BaseSiteComponent): def __init__(self, **entries): # Info on how the .update method has to fetch the data: + # NOTE: THIS DOESN'T WORK WITH CLOSED CLASSES! self.update_function = requests.get if "id" in entries: self.update_API = f"https://api.scratch.mit.edu/classrooms/{entries['id']}" elif "classtoken" in entries: self.update_API = f"https://api.scratch.mit.edu/classtoken/{entries['classtoken']}" else: - raise KeyError + raise KeyError(f"No class id or token provided! Entries: {entries}") # Set attributes every Project object needs to have: - self._session = None + self._session: Session = None self.id = None self.classtoken = None @@ -35,33 +49,92 @@ def __init__(self, **entries): self._json_headers = dict(self._headers) self._json_headers["accept"] = "application/json" self._json_headers["Content-Type"] = "application/json" + self.is_closed = False + + def __repr__(self) -> str: + return f"classroom called {self.title!r}" + + def update(self): + try: + success = super().update() + except exceptions.ClassroomNotFound: + success = False + + if not success: + response = requests.get(f"https://scratch.mit.edu/classes/{self.id}/") + soup = BeautifulSoup(response.text, "html.parser") + + headings = soup.find_all("h1") + for heading in headings: + if heading.text == "Whoops! Our server is Scratch'ing its head": + raise exceptions.ClassroomNotFound(f"Classroom id {self.id} is not closed and cannot be found.") + + # id, title, description, status, date_start (iso format), educator/username + + title = soup.find("title").contents[0][:-len(" on Scratch")] + + overviews = soup.find_all("p", {"class": "overview"}) + description, status = overviews[0].text, overviews[1].text + + educator_username = None + pfx = "Scratch.INIT_DATA.PROFILE = {\n model: {\n id: '" + sfx = "',\n userId: " + for script in soup.find_all("script"): + if pfx in script.text: + educator_username = commons.webscrape_count(script.text, pfx, sfx, str) + + ret = {"id": self.id, + "title": title, + "description": description, + "status": status, + "educator": {"username": educator_username}, + "is_closed": True + } + + return self._update_from_dict(ret) + return success def _update_from_dict(self, classrooms): - try: self.id = int(classrooms["id"]) - except Exception: pass - try: self.title = classrooms["title"] - except Exception: pass - try: self.about_class = classrooms["description"] - except Exception: pass - try: self.working_on = classrooms["status"] - except Exception: pass - try: self.datetime = datetime.datetime.fromisoformat(classrooms["date_start"]) - except Exception: pass - try: self.author = user.User(username=classrooms["educator"]["username"],_session=self._session) - except Exception: pass - try: self.author._update_from_dict(classrooms["educator"]) - except Exception: pass + try: + self.id = int(classrooms["id"]) + except Exception: + pass + try: + self.title = classrooms["title"] + except Exception: + pass + try: + self.about_class = classrooms["description"] + except Exception: + pass + try: + self.working_on = classrooms["status"] + except Exception: + pass + try: + self.datetime = datetime.datetime.fromisoformat(classrooms["date_start"]) + except Exception: + pass + try: + self.author = user.User(username=classrooms["educator"]["username"], _session=self._session) + except Exception: + pass + try: + self.author._update_from_dict(classrooms["educator"]) + except Exception: + pass + self.is_closed = classrooms.get("is_closed", False) return True - - def student_count(self): + + def student_count(self) -> int: # student count text = requests.get( f"https://scratch.mit.edu/classes/{self.id}/", - headers = self._headers + headers=self._headers ).text return commons.webscrape_count(text, "Students (", ")") - - def student_names(self, *, page=1): + + def student_names(self, *, page=1) -> list[str]: """ Returns the student on the class. @@ -71,22 +144,37 @@ def student_names(self, *, page=1): Returns: list: The usernames of the class students """ + if self.is_closed: + ret = [] + response = requests.get(f"https://scratch.mit.edu/classes/{self.id}/") + soup = BeautifulSoup(response.text, "html.parser") + + for scrollable in soup.find_all("ul", {"class": "scroll-content"}): + for item in scrollable.contents: + if not isinstance(item, bs4.NavigableString): + if "user" in item.attrs["class"]: + anchors = item.find_all("a") + if len(anchors) == 2: + ret.append(anchors[1].text.strip()) + + return ret + text = requests.get( f"https://scratch.mit.edu/classes/{self.id}/students/?page={page}", - headers = self._headers + headers=self._headers ).text textlist = [i.split('/">')[0] for i in text.split(' list[int]: """ Returns the class studio on the class. @@ -94,18 +182,194 @@ def class_studio_ids(self, *, page=1): page: The page of the students that should be returned. Returns: - list: The id of the class studios + list: The id of the class studios """ + if self.is_closed: + ret = [] + response = requests.get(f"https://scratch.mit.edu/classes/{self.id}/") + soup = BeautifulSoup(response.text, "html.parser") + + for scrollable in soup.find_all("ul", {"class": "scroll-content"}): + for item in scrollable.contents: + if not isinstance(item, bs4.NavigableString): + if "gallery" in item.attrs["class"]: + anchor = item.find("a") + if "href" in anchor.attrs: + ret.append(commons.webscrape_count(anchor.attrs["href"], "/studios/", "/")) + return ret + text = requests.get( f"https://scratch.mit.edu/classes/{self.id}/studios/?page={page}", - headers = self._headers + headers=self._headers ).text textlist = [int(i.split('/">')[0]) for i in text.split('\n None: + self._check_session() + requests.post(f"https://scratch.mit.edu/site-api/classrooms/all/{self.id}/", + headers=self._headers, cookies=self._cookies, + files={"file": thumbnail}) + + def set_description(self, desc: str) -> None: + self._check_session() + response = requests.put(f"https://scratch.mit.edu/site-api/classrooms/all/{self.id}/", + headers=self._headers, cookies=self._cookies, + json={"description": desc}) + + try: + data = response.json() + if data["description"] == desc: + # Success! + return + else: + warnings.warn(f"{self._session} may not be authenticated to edit {self}") + + except Exception as e: + warnings.warn(f"{self._session} may not be authenticated to edit {self}") + raise e + + def set_working_on(self, status: str) -> None: + self._check_session() + response = requests.put(f"https://scratch.mit.edu/site-api/classrooms/all/{self.id}/", + headers=self._headers, cookies=self._cookies, + json={"status": status}) + + try: + data = response.json() + if data["status"] == status: + # Success! + return + else: + warnings.warn(f"{self._session} may not be authenticated to edit {self}") + + except Exception as e: + warnings.warn(f"{self._session} may not be authenticated to edit {self}") + raise e + + def set_title(self, title: str) -> None: + self._check_session() + response = requests.put(f"https://scratch.mit.edu/site-api/classrooms/all/{self.id}/", + headers=self._headers, cookies=self._cookies, + json={"title": title}) + try: + data = response.json() + if data["title"] == title: + # Success! + return + else: + warnings.warn(f"{self._session} may not be authenticated to edit {self}") -def get_classroom(class_id) -> Classroom: + except Exception as e: + warnings.warn(f"{self._session} may not be authenticated to edit {self}") + raise e + + def add_studio(self, name: str, description: str = '') -> None: + self._check_session() + requests.post("https://scratch.mit.edu/classes/create_classroom_gallery/", + json={ + "classroom_id": str(self.id), + "classroom_token": self.classtoken, + "title": name, + "description": description}, + headers=self._headers, cookies=self._cookies) + + def reopen(self) -> None: + self._check_session() + response = requests.put(f"https://scratch.mit.edu/site-api/classrooms/all/{self.id}/", + headers=self._headers, cookies=self._cookies, + json={"visibility": "visible"}) + + try: + response.json() + + except Exception as e: + warnings.warn(f"{self._session} may not be authenticated to edit {self}") + raise e + + def close(self) -> None: + self._check_session() + response = requests.post(f"https://scratch.mit.edu/site-api/classrooms/close_classroom/{self.id}/", + headers=self._headers, cookies=self._cookies) + + try: + response.json() + + except Exception as e: + warnings.warn(f"{self._session} may not be authenticated to edit {self}") + raise e + + def register_student(self, username: str, password: str = '', birth_month: int = None, birth_year: int = None, + gender: str = None, country: str = None, is_robot: bool = False) -> None: + return register_by_token(self.id, self.classtoken, username, password, birth_month, birth_year, gender, country, + is_robot) + + def generate_signup_link(self): + if self.classtoken is not None: + return f"https://scratch.mit.edu/signup/{self.classtoken}" + + self._check_session() + + response = requests.get(f"https://scratch.mit.edu/site-api/classrooms/generate_registration_link/{self.id}/", + headers=self._headers, cookies=self._cookies) + # Should really check for '404' page + data = response.json() + if "reg_link" in data: + return data["reg_link"] + else: + raise exceptions.Unauthorized(f"{self._session} is not authorised to generate a signup link of {self}") + + def public_activity(self, *, limit=20): + """ + Returns: + list: The user's activity data as parsed list of scratchattach.activity.Activity objects + """ + if limit > 20: + warnings.warn("The limit is set to more than 20. There may be an error") + soup = BeautifulSoup( + requests.get(f"https://scratch.mit.edu/site-api/classrooms/activity/public/{self.id}/?limit={limit}").text, + 'html.parser') + + activities = [] + source = soup.find_all("li") + + for data in source: + _activity = activity.Activity(_session=self._session, raw=data) + _activity._update_from_html(data) + activities.append(_activity) + + return activities + + def activity(self, student: str = "all", mode: str = "Last created", page: int = None) -> list[dict[str, Any]]: + """ + Get a list of private activity, only available to the class owner. + Returns: + list The private activity of users in the class + """ + + self._check_session() + + ascsort, descsort = commons.get_class_sort_mode(mode) + + data = requests.get(f"https://scratch.mit.edu/site-api/classrooms/activity/{self.id}/{student}/", + params={"page": page, "ascsort": ascsort, "descsort": descsort}, + headers=self._headers, cookies=self._cookies).json() + + _activity = [] + for activity_json in data: + _activity.append(activity.Activity(_session=self._session)) + _activity[-1]._update_from_json(activity_json) + + return _activity + + +def get_classroom(class_id: str) -> Classroom: """ Gets a class without logging in. @@ -120,9 +384,10 @@ def get_classroom(class_id) -> Classroom: If you want to use these, get the user with :meth:`scratchattach.session.Session.connect_classroom` instead. """ - print("Warning: For methods that require authentication, use session.connect_classroom instead of get_classroom") + warnings.warn("For methods that require authentication, use session.connect_classroom instead of get_classroom") return commons._get_object("id", class_id, Classroom, exceptions.ClassroomNotFound) + def get_classroom_from_token(class_token) -> Classroom: """ Gets a class without logging in. @@ -138,5 +403,28 @@ def get_classroom_from_token(class_token) -> Classroom: If you want to use these, get the user with :meth:`scratchattach.session.Session.connect_classroom` instead. """ - print("Warning: For methods that require authentication, use session.connect_classroom instead of get_classroom") - return commons._get_object("classtoken", class_token, Classroom, exceptions.ClassroomNotFound) \ No newline at end of file + warnings.warn("For methods that require authentication, use session.connect_classroom instead of get_classroom") + return commons._get_object("classtoken", class_token, Classroom, exceptions.ClassroomNotFound) + + +def register_by_token(class_id: int, class_token: str, username: str, password: str, birth_month: int, birth_year: int, + gender: str, country: str, is_robot: bool = False) -> None: + data = {"classroom_id": class_id, + "classroom_token": class_token, + + "username": username, + "password": password, + "birth_month": birth_month, + "birth_year": birth_year, + "gender": gender, + "country": country, + "is_robot": is_robot} + + response = requests.post("https://scratch.mit.edu/classes/register_new_student/", + data=data, headers=headers, cookies={"scratchcsrftoken": 'a'}) + ret = response.json()[0] + + if "username" in ret: + return + else: + raise exceptions.Unauthorized(f"Can't create account: {response.text}") diff --git a/scratchattach/site/cloud_activity.py b/scratchattach/site/cloud_activity.py index 296a14c2..63b64e0f 100644 --- a/scratchattach/site/cloud_activity.py +++ b/scratchattach/site/cloud_activity.py @@ -1,6 +1,10 @@ +from __future__ import annotations + import time from ._base import BaseSiteComponent + + class CloudActivity(BaseSiteComponent): """ Represents a cloud activity (a cloud variable set / creation / deletion). diff --git a/scratchattach/site/comment.py b/scratchattach/site/comment.py index 6fa456a9..3c66ece8 100644 --- a/scratchattach/site/comment.py +++ b/scratchattach/site/comment.py @@ -1,27 +1,15 @@ """Comment class""" +from __future__ import annotations -import json -import re - -from ..utils import commons - -from . import user -from . import session -from . import project -from . import studio -from . import forum -from ..utils import exceptions +from . import user, project, studio from ._base import BaseSiteComponent -from ..utils.commons import headers -from bs4 import BeautifulSoup +from ..utils import exceptions -from ..utils.requests import Requests as requests class Comment(BaseSiteComponent): - - ''' + """ Represents a Scratch comment (on a profile, studio or project) - ''' + """ def str(self): return str(self.content) @@ -31,52 +19,75 @@ def __init__(self, **entries): # Set attributes every Comment object needs to have: self.id = None self._session = None - self.source=None + self.source = None self.source_id = None self.cached_replies = None self.parent_id = None self.cached_parent_comment = None - if not "source" in entries: - "source" == "Unknown" # Update attributes from entries dict: self.__dict__.update(entries) + if "source" not in entries: + self.source = "Unknown" + def update(self): print("Warning: Comment objects can't be updated") - return False # Objects of this type cannot be updated + return False # Objects of this type cannot be updated def _update_from_dict(self, data): - try: self.id = data["id"] - except Exception: pass - try: self.parent_id = data["parent_id"] - except Exception: pass - try: self.commentee_id = data["commentee_id"] - except Exception: pass - try: self.content = data["content"] - except Exception: pass - try: self.datetime_created = data["datetime_created"] - except Exception: pass - try: self.author_name = data["author"]["username"] - except Exception: pass - try: self.author_id = data["author"]["id"] - except Exception: pass - try: self.written_by_scratchteam = data["author"]["scratchteam"] - except Exception: pass - try: self.reply_count = data["reply_count"] - except Exception: pass - try: self.source = data["source"] - except Exception: pass - try: self.source_id = data["source_id"] - except Exception: pass + try: + self.id = data["id"] + except Exception: + pass + try: + self.parent_id = data["parent_id"] + except Exception: + pass + try: + self.commentee_id = data["commentee_id"] + except Exception: + pass + try: + self.content = data["content"] + except Exception: + pass + try: + self.datetime_created = data["datetime_created"] + except Exception: + pass + try: + self.author_name = data["author"]["username"] + except Exception: + pass + try: + self.author_id = data["author"]["id"] + except Exception: + pass + try: + self.written_by_scratchteam = data["author"]["scratchteam"] + except Exception: + pass + try: + self.reply_count = data["reply_count"] + except Exception: + pass + try: + self.source = data["source"] + except Exception: + pass + try: + self.source_id = data["source_id"] + except Exception: + pass return True # Methods for getting related entities - def author(self): + def author(self) -> user.User: return self._make_linked_object("username", self.author_name, user.User, exceptions.UserNotFound) - def place(self): + def place(self) -> user.User | studio.Studio | project.Project: """ Returns the place (the project, profile or studio) where the comment was posted as Project, User, or Studio object. @@ -89,37 +100,49 @@ def place(self): if self.source == "project": return self._make_linked_object("id", self.source_id, project.Project, exceptions.UserNotFound) - def parent_comment(self): + def parent_comment(self) -> Comment | None: if self.parent_id is None: return None + if self.cached_parent_comment is not None: return self.cached_parent_comment + if self.source == "profile": - self.cached_parent_comment = user.User(username=self.source_id, _session=self._session).comment_by_id(self.parent_id) - if self.source == "project": + self.cached_parent_comment = user.User(username=self.source_id, _session=self._session).comment_by_id( + self.parent_id) + + elif self.source == "project": p = project.Project(id=self.source_id, _session=self._session) p.update() self.cached_parent_comment = p.comment_by_id(self.parent_id) - if self.source == "studio": - self.cached_parent_comment = studio.Studio(id=self.source_id, _session=self._session).comment_by_id(self.parent_id) + + elif self.source == "studio": + self.cached_parent_comment = studio.Studio(id=self.source_id, _session=self._session).comment_by_id( + self.parent_id) + return self.cached_parent_comment - - def replies(self, *, use_cache=True, limit=40, offset=0): + + def replies(self, *, use_cache: bool = True, limit=40, offset=0): """ Keyword Arguments: use_cache (bool): Returns the replies cached on the first reply fetch. This makes it SIGNIFICANTLY faster for profile comments. Warning: For profile comments, the replies are retrieved and cached on object creation. """ - if (self.cached_replies is None) or (use_cache is False): + if (self.cached_replies is None) or (not use_cache): if self.source == "profile": - self.cached_replies = user.User(username=self.source_id, _session=self._session).comment_by_id(self.id).cached_replies[offset:offset+limit] - if self.source == "project": + self.cached_replies = user.User(username=self.source_id, _session=self._session).comment_by_id( + self.id).cached_replies[offset:offset + limit] + + elif self.source == "project": p = project.Project(id=self.source_id, _session=self._session) p.update() self.cached_replies = p.comment_replies(comment_id=self.id, limit=limit, offset=offset) - if self.source == "studio": - self.cached_replies = studio.Studio(id=self.source_id, _session=self._session).comment_replies(comment_id=self.id, limit=limit, offset=offset) + + elif self.source == "studio": + self.cached_replies = studio.Studio(id=self.source_id, _session=self._session).comment_replies( + comment_id=self.id, limit=limit, offset=offset) + return self.cached_replies - + # Methods for dealing with the comment def reply(self, content, *, commentee_id=None): @@ -152,14 +175,17 @@ def reply(self, content, *, commentee_id=None): else: commentee_id = "" if self.source == "profile": - return user.User(username=self.source_id, _session=self._session).reply_comment(content, parent_id=str(parent_id), commentee_id=commentee_id) + return user.User(username=self.source_id, _session=self._session).reply_comment(content, + parent_id=str(parent_id), + commentee_id=commentee_id) if self.source == "project": p = project.Project(id=self.source_id, _session=self._session) p.update() return p.reply_comment(content, parent_id=str(parent_id), commentee_id=commentee_id) if self.source == "studio": - return studio.Studio(id=self.source_id, _session=self._session).reply_comment(content, parent_id=str(parent_id), commentee_id=commentee_id) - + return studio.Studio(id=self.source_id, _session=self._session).reply_comment(content, + parent_id=str(parent_id), + commentee_id=commentee_id) def delete(self): """ @@ -168,11 +194,13 @@ def delete(self): self._assert_auth() if self.source == "profile": user.User(username=self.source_id, _session=self._session).delete_comment(comment_id=self.id) - if self.source == "project": + + elif self.source == "project": p = project.Project(id=self.source_id, _session=self._session) p.update() p.delete_comment(comment_id=self.id) - if self.source == "studio": + + elif self.source == "studio": studio.Studio(id=self.source_id, _session=self._session).delete_comment(comment_id=self.id) def report(self): @@ -182,9 +210,11 @@ def report(self): self._assert_auth() if self.source == "profile": user.User(username=self.source_id, _session=self._session).report_comment(comment_id=self.id) - if self.source == "project": + + elif self.source == "project": p = project.Project(id=self.source_id, _session=self._session) p.update() p.report_comment(comment_id=self.id) - if self.source == "studio": - studio.Studio(id=self.source_id, _session=self._session).report_comment(comment_id=self.id) \ No newline at end of file + + elif self.source == "studio": + studio.Studio(id=self.source_id, _session=self._session).report_comment(comment_id=self.id) diff --git a/scratchattach/site/forum.py b/scratchattach/site/forum.py index 510b10a8..5605b92e 100644 --- a/scratchattach/site/forum.py +++ b/scratchattach/site/forum.py @@ -1,4 +1,5 @@ """ForumTopic and ForumPost classes""" +from __future__ import annotations from . import user from ..utils.commons import headers @@ -32,8 +33,8 @@ class ForumTopic(BaseSiteComponent): :.update(): Updates the attributes ''' - def __init__(self, **entries): + def __init__(self, **entries): # Info on how the .update method has to fetch the data: self.update_function = requests.get self.update_API = f"https://scratch.mit.edu/discuss/feeds/topic/{entries['id']}/" diff --git a/scratchattach/site/project.py b/scratchattach/site/project.py index 47e8b465..00e900c4 100644 --- a/scratchattach/site/project.py +++ b/scratchattach/site/project.py @@ -1,4 +1,5 @@ """Project and PartialProject classes""" +from __future__ import annotations import json import random @@ -267,6 +268,25 @@ def download(self, *, filename=None, dir=""): ) ) + def get_json(self) -> str: + """ + Downloads the project json and returns it as a string + """ + try: + self.update() + response = requests.get( + f"https://projects.scratch.mit.edu/{self.id}?token={self.project_token}", + timeout=10, + ) + return response.text + + except Exception: + raise ( + exceptions.FetchError( + "Method only works for projects created with Scratch 3" + ) + ) + def body(self): """ Method only works for project created with Scratch 3. @@ -324,7 +344,7 @@ def studios(self, *, limit=40, offset=0): f"https://api.scratch.mit.edu/users/{self.author_name}/projects/{self.id}/studios", limit=limit, offset=offset, add_params=f"&cachebust={random.randint(0,9999)}") return commons.parse_object_list(response, studio.Studio, self._session) - def comments(self, *, limit=40, offset=0): + def comments(self, *, limit=40, offset=0) -> list['comment.Comment']: """ Returns the comments posted on the project (except for replies. To get replies use :meth:`scratchattach.project.Project.comment_replies`). @@ -343,7 +363,6 @@ def comments(self, *, limit=40, offset=0): i["source_id"] = self.id return commons.parse_object_list(response, comment.Comment, self._session) - def comment_replies(self, *, comment_id, limit=40, offset=0): response = commons.api_iterative( f"https://api.scratch.mit.edu/users/{self.author_name}/projects/{self.id}/comments/{comment_id}/replies/", limit=limit, offset=offset, add_params=f"&cachebust={random.randint(0,9999)}") diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index c13dc3a7..676475ab 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -1,63 +1,60 @@ """Session class and login function""" +from __future__ import annotations +import base64 +import datetime +import hashlib import json -import re -import warnings import pathlib -import hashlib -import time import random -import base64 -import secrets -from typing import Type -import zipfile - -from . import forum +import re +import time +import warnings +# import secrets +# import zipfile +# from typing import Type +Type = type +try: + from warnings import deprecated +except ImportError: + deprecated = lambda x: (lambda y: y) -from ..utils import commons +from bs4 import BeautifulSoup +from . import activity, classroom, forum, studio, user, project, backpack_asset +# noinspection PyProtectedMember +from ._base import BaseSiteComponent from ..cloud import cloud, _base -from . import user, project, backpack_asset, classroom -from ..utils import exceptions -from . import studio -from . import classroom from ..eventhandlers import message_events, filterbot -from . import activity -from ._base import BaseSiteComponent -from ..utils.commons import headers, empty_project_json -from bs4 import BeautifulSoup from ..other import project_json_capabilities +from ..utils import commons +from ..utils import exceptions +from ..utils.commons import headers, empty_project_json, webscrape_count, get_class_sort_mode from ..utils.requests import Requests as requests CREATE_PROJECT_USES = [] +CREATE_STUDIO_USES = [] +CREATE_CLASS_USES = [] -class Session(BaseSiteComponent): - ''' +class Session(BaseSiteComponent): + """ Represents a Scratch log in / session. Stores authentication data (session id and xtoken). Attributes: + id: The session id associated with the login + username: The username associated with the login + xtoken: The xtoken associated with the login + email: The email address associated with the logged in account + new_scratcher: True if the associated account is a new Scratcher + mute_status: Information about commenting restrictions of the associated account + banned: Returns True if the associated account is banned + """ - :.id: The session id associated with the login - - :.username: The username associated with the login - - :.xtoken: The xtoken associated with the login - - :.email: The email address associated with the logged in account - - :.new_scratcher: Returns True if the associated account is a new Scratcher - - :.mute_status: Information about commenting restrictions of the associated account - - :.banned: Returns True if the associated account is banned - ''' - - def __str__(self): - return "Login for account: {self.username}" + def __str__(self) -> str: + return f"Login for account {self.username!r}" def __init__(self, **entries): - # Info on how the .update method has to fetch the data: self.update_function = requests.post self.update_API = "https://scratch.mit.edu/session" @@ -68,23 +65,26 @@ def __init__(self, **entries): self.xtoken = None self.new_scratcher = None + # Set attributes that Session object may get + self._user: user.User = None + # Update attributes from entries dict: self.__dict__.update(entries) # Set alternative attributes: - self._username = self.username # backwards compatibility with v1 + self._username = self.username # backwards compatibility with v1 # Base headers and cookies of every session: self._headers = dict(headers) self._cookies = { - "scratchsessionsid" : self.id, - "scratchcsrftoken" : "a", - "scratchlanguage" : "en", + "scratchsessionsid": self.id, + "scratchcsrftoken": "a", + "scratchlanguage": "en", "accept": "application/json", "Content-Type": "application/json", } - def _update_from_dict(self, data): + def _update_from_dict(self, data: dict): # Note: there are a lot more things you can get from this data dict. # Maybe it would be a good idea to also store the dict itself? # self.data = data @@ -97,6 +97,8 @@ def _update_from_dict(self, data): self.email = data["user"]["email"] self.new_scratcher = data["permissions"]["new_scratcher"] + self.is_teacher = data["permissions"]["educator"] + self.mute_status = data["permissions"]["mute_status"] self.username = data["user"]["username"] @@ -104,30 +106,44 @@ def _update_from_dict(self, data): self.banned = data["user"]["banned"] if self.banned: - warnings.warn(f"Warning: The account {self._username} you logged in to is BANNED. Some features may not work properly.") + warnings.warn(f"Warning: The account {self._username} you logged in to is BANNED. " + f"Some features may not work properly.") if self.has_outstanding_email_confirmation: - warnings.warn(f"Warning: The account {self._username} you logged is not email confirmed. Some features may not work properly.") + warnings.warn(f"Warning: The account {self._username} you logged is not email confirmed. " + f"Some features may not work properly.") return True - def connect_linked_user(self) -> 'user.User': - ''' - Gets the user associated with the log in / session. + def connect_linked_user(self) -> user.User: + """ + Gets the user associated with the login / session. Warning: The returned User object is cached. To ensure its attribute are up to date, you need to run .update() on it. Returns: - scratchattach.user.User: Object representing the user associated with the log in / session. - ''' - if not hasattr(self, "_user"): + scratchattach.user.User: Object representing the user associated with the session. + """ + cached = hasattr(self, "_user") + if cached: + cached = self._user is not None + + if not cached: self._user = self.connect_user(self._username) return self._user - def get_linked_user(self): + def get_linked_user(self) -> 'user.User': # backwards compatibility with v1 - return self.connect_linked_user() # To avoid inconsistencies with "connect" and "get", this function was renamed - def set_country(self, country: str="Antarctica"): + # To avoid inconsistencies with "connect" and "get", this function was renamed + return self.connect_linked_user() + + def set_country(self, country: str = "Antarctica"): + """ + Sets the profile country of the session's associated user + + Arguments: + country (str): The country to relocate to + """ requests.post("https://scratch.mit.edu/accounts/settings/", data={"country": country}, headers=self._headers, cookies=self._cookies) @@ -143,10 +159,12 @@ def resend_email(self, password: str): data={"email_address": self.new_email_address, "password": password}, headers=self._headers, cookies=self._cookies) + @property - def new_email_address(self) -> str | None: + def new_email_address(self) -> str: """ - Gets the (unconfirmed) email address that this session has requested to transfer to, if any, otherwise the current address. + Gets the (unconfirmed) email address that this session has requested to transfer to, if any, + otherwise the current address. Returns: str: The email that this session wants to switch to @@ -160,20 +178,21 @@ def new_email_address(self) -> str | None: for label_span in soup.find_all("span", {"class": "label"}): if label_span.contents[0] == "New Email Address": return label_span.parent.contents[-1].text.strip("\n ") + elif label_span.contents[0] == "Current Email Address": email = label_span.parent.contents[-1].text.strip("\n ") return email - + def logout(self): """ - Sends a logout request to scratch. Might not do anything, might log out this account on other ips/sessions? I am not sure + Sends a logout request to scratch. (Might not do anything, might log out this account on other ips/sessions.) """ requests.post("https://scratch.mit.edu/accounts/logout/", headers=self._headers, cookies=self._cookies) - def messages(self, *, limit=40, offset=0, date_limit=None, filter_by=None): - ''' + def messages(self, *, limit: int = 40, offset: int = 0, date_limit=None, filter_by=None) -> list[activity.Activity]: + """ Returns the messages. Keyword arguments: @@ -182,98 +201,118 @@ def messages(self, *, limit=40, offset=0, date_limit=None, filter_by=None): Returns: list: List that contains all messages as Activity objects. - ''' + """ add_params = "" if date_limit is not None: add_params += f"&dateLimit={date_limit}" if filter_by is not None: add_params += f"&filter={filter_by}" + data = commons.api_iterative( f"https://api.scratch.mit.edu/users/{self._username}/messages", - limit = limit, offset = offset, headers = self._headers, cookies = self._cookies, add_params=add_params + limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies, add_params=add_params ) return commons.parse_object_list(data, activity.Activity, self) - def admin_messages(self, *, limit=40, offset=0): + def admin_messages(self, *, limit=40, offset=0) -> list[dict]: """ Returns your messages sent by the Scratch team (alerts). """ return commons.api_iterative( f"https://api.scratch.mit.edu/users/{self._username}/messages/admin", - limit = limit, offset = offset, headers = self._headers, cookies = self._cookies + limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies ) + def classroom_alerts(self, _classroom: classroom.Classroom | int = None, mode: str = "Last created", + page: int = None): + if isinstance(_classroom, classroom.Classroom): + _classroom = _classroom.id + + if _classroom is None: + _classroom = '' + else: + _classroom = f"{_classroom}/" + + ascsort, descsort = get_class_sort_mode(mode) + + data = requests.get(f"https://scratch.mit.edu/site-api/classrooms/alerts/{_classroom}", + params={"page": page, "ascsort": ascsort, "descsort": descsort}, + headers=self._headers, cookies=self._cookies).json() + + return data def clear_messages(self): - ''' + """ Clears all messages. - ''' + """ return requests.post( "https://scratch.mit.edu/site-api/messages/messages-clear/", - headers = self._headers, - cookies = self._cookies, - timeout = 10, + headers=self._headers, + cookies=self._cookies, + timeout=10, ).text - def message_count(self): - ''' + def message_count(self) -> int: + """ Returns the message count. Returns: int: message count - ''' + """ return json.loads(requests.get( f"https://scratch.mit.edu/messages/ajax/get-message-count/", - headers = self._headers, - cookies = self._cookies, - timeout = 10, + headers=self._headers, + cookies=self._cookies, + timeout=10, ).text)["msg_count"] # Front-page-related stuff: - def feed(self, *, limit=20, offset=0, date_limit=None): - ''' + def feed(self, *, limit=20, offset=0, date_limit=None) -> list[activity.Activity]: + """ Returns the "What's happening" section (frontpage). Returns: list: List that contains all "What's happening" entries as Activity objects - ''' + """ add_params = "" if date_limit is not None: add_params = f"&dateLimit={date_limit}" data = commons.api_iterative( f"https://api.scratch.mit.edu/users/{self._username}/following/users/activity", - limit = limit, offset = offset, headers = self._headers, cookies = self._cookies, add_params=add_params + limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies, add_params=add_params ) return commons.parse_object_list(data, activity.Activity, self) def get_feed(self, *, limit=20, offset=0, date_limit=None): # for more consistent names, this method was renamed - return self.feed(limit=limit, offset=offset, date_limit=date_limit) # for backwards compatibility with v1 + return self.feed(limit=limit, offset=offset, date_limit=date_limit) # for backwards compatibility with v1 - def loved_by_followed_users(self, *, limit=40, offset=0): - ''' + def loved_by_followed_users(self, *, limit=40, offset=0) -> list[project.Project]: + """ Returns the "Projects loved by Scratchers I'm following" section (frontpage). Returns: - list: List that contains all "Projects loved by Scratchers I'm following" entries as Project objects - ''' + list: List that contains all "Projects loved by Scratchers I'm following" + entries as Project objects + """ data = commons.api_iterative( f"https://api.scratch.mit.edu/users/{self._username}/following/users/loves", - limit = limit, offset = offset, headers = self._headers, cookies = self._cookies + limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies ) return commons.parse_object_list(data, project.Project, self) """ These methods are disabled because it is unclear if there is any case in which the response is not empty. - def shared_by_followed_users(self, *, limit=40, offset=0): + def shared_by_followed_users(self, *, limit=40, offset=0) -> list[project.Project]: ''' Returns the "Projects by Scratchers I'm following" section (frontpage). This section is only visible to old accounts (according to the Scratch wiki). For newer users, this method will always return an empty list. Returns: - list: List that contains all "Projects loved by Scratchers I'm following" entries as Project objects + list: List that contains all "Projects loved by Scratchers I'm following" + entries as Project objects ''' data = commons.api_iterative( f"https://api.scratch.mit.edu/users/{self._username}/following/users/projects", @@ -281,14 +320,15 @@ def shared_by_followed_users(self, *, limit=40, offset=0): ) return commons.parse_object_list(data, project.Project, self) - def in_followed_studios(self, *, limit=40, offset=0): + def in_followed_studios(self, *, limit=40, offset=0) -> list['project.Project']: ''' Returns the "Projects in studios I'm following" section (frontpage). This section is only visible to old accounts (according to the Scratch wiki). For newer users, this method will always return an empty list. Returns: - list: List that contains all "Projects loved by Scratchers I'm following" entries as Project objects + list: List that contains all "Projects loved by Scratchers I'm following" + entries as Project objects ''' data = commons.api_iterative( f"https://api.scratch.mit.edu/users/{self._username}/following/studios/projects", @@ -297,33 +337,39 @@ def in_followed_studios(self, *, limit=40, offset=0): return commons.parse_object_list(data, project.Project, self)""" # -- Project JSON editing capabilities --- - - def connect_empty_project_pb() -> 'project_json_capabilities.ProjectBody': + # These are set to staticmethods right now, but they probably should not be + @staticmethod + def connect_empty_project_pb() -> project_json_capabilities.ProjectBody: pb = project_json_capabilities.ProjectBody() pb.from_json(empty_project_json) return pb - def connect_pb_from_dict(project_json:dict) -> 'project_json_capabilities.ProjectBody': + @staticmethod + def connect_pb_from_dict(project_json: dict) -> project_json_capabilities.ProjectBody: pb = project_json_capabilities.ProjectBody() pb.from_json(project_json) return pb - def connect_pb_from_file(path_to_file) -> 'project_json_capabilities.ProjectBody': + @staticmethod + def connect_pb_from_file(path_to_file) -> project_json_capabilities.ProjectBody: pb = project_json_capabilities.ProjectBody() + # noinspection PyProtectedMember + # _load_sb3_file starts with an underscore pb.from_json(project_json_capabilities._load_sb3_file(path_to_file)) return pb - def download_asset(asset_id_with_file_ext, *, filename=None, dir=""): - if not (dir.endswith("/") or dir.endswith("\\")): - dir = dir+"/" + @staticmethod + def download_asset(asset_id_with_file_ext, *, filename: str = None, fp=""): + if not (fp.endswith("/") or fp.endswith("\\")): + fp = fp + "/" try: if filename is None: filename = str(asset_id_with_file_ext) response = requests.get( - "https://assets.scratch.mit.edu/"+str(asset_id_with_file_ext), + "https://assets.scratch.mit.edu/" + str(asset_id_with_file_ext), timeout=10, ) - open(f"{dir}{filename}", "wb").write(response.content) + open(f"{fp}{filename}", "wb").write(response.content) except Exception: raise ( exceptions.FetchError( @@ -344,68 +390,79 @@ def upload_asset(self, asset_content, *, asset_id=None, file_ext=None): requests.post( f"https://assets.scratch.mit.edu/{asset_id}.{file_ext}", headers=self._headers, - cookies = self._cookies, + cookies=self._cookies, data=data, timeout=10, ) # --- Search --- - def search_projects(self, *, query="", mode="trending", language="en", limit=40, offset=0): - ''' + def search_projects(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40, + offset: int = 0) -> list[project.Project]: + """ Uses the Scratch search to search projects. Keyword arguments: query (str): The query that will be searched. mode (str): Has to be one of these values: "trending", "popular" or "recent". Defaults to "trending". - language (str): A language abbreviation, defaults to "en". (Depending on the language used on the Scratch website, Scratch displays you different results.) + language (str): A language abbreviation, defaults to "en". + (Depending on the language used on the Scratch website, Scratch displays you different results.) limit (int): Max. amount of returned projects. offset (int): Offset of the first returned project. Returns: list: List that contains the search results. - ''' + """ response = commons.api_iterative( - f"https://api.scratch.mit.edu/search/projects", limit=limit, offset=offset, add_params=f"&language={language}&mode={mode}&q={query}") + f"https://api.scratch.mit.edu/search/projects", limit=limit, offset=offset, + add_params=f"&language={language}&mode={mode}&q={query}") return commons.parse_object_list(response, project.Project, self) - def explore_projects(self, *, query="*", mode="trending", language="en", limit=40, offset=0): - ''' + def explore_projects(self, *, query: str = "*", mode: str = "trending", language: str = "en", limit: int = 40, + offset: int = 0) -> list[project.Project]: + """ Gets projects from the explore page. Keyword arguments: - query (str): Specifies the tag of the explore page. To get the projects from the "All" tag, set this argument to "*". - mode (str): Has to be one of these values: "trending", "popular" or "recent". Defaults to "trending". - language (str): A language abbreviation, defaults to "en". (Depending on the language used on the Scratch website, Scratch displays you different explore pages.) + query (str): Specifies the tag of the explore page. + To get the projects from the "All" tag, set this argument to "*". + mode (str): Has to be one of these values: "trending", "popular" or "recent". + Defaults to "trending". + language (str): A language abbreviation, defaults to "en". + (Depending on the language used on the Scratch website, Scratch displays you different explore pages.) limit (int): Max. amount of returned projects. offset (int): Offset of the first returned project. Returns: list: List that contains the explore page projects. - ''' + """ response = commons.api_iterative( - f"https://api.scratch.mit.edu/explore/projects", limit=limit, offset=offset, add_params=f"&language={language}&mode={mode}&q={query}") + f"https://api.scratch.mit.edu/explore/projects", limit=limit, offset=offset, + add_params=f"&language={language}&mode={mode}&q={query}") return commons.parse_object_list(response, project.Project, self) - def search_studios(self, *, query="", mode="trending", language="en", limit=40, offset=0): + def search_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40, + offset: int = 0) -> list[studio.Studio]: if not query: raise ValueError("The query can't be empty for search") response = commons.api_iterative( - f"https://api.scratch.mit.edu/search/studios", limit=limit, offset=offset, add_params=f"&language={language}&mode={mode}&q={query}") + f"https://api.scratch.mit.edu/search/studios", limit=limit, offset=offset, + add_params=f"&language={language}&mode={mode}&q={query}") return commons.parse_object_list(response, studio.Studio, self) - - def explore_studios(self, *, query="", mode="trending", language="en", limit=40, offset=0): + def explore_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40, + offset: int = 0) -> list[studio.Studio]: if not query: raise ValueError("The query can't be empty for explore") response = commons.api_iterative( - f"https://api.scratch.mit.edu/explore/studios", limit=limit, offset=offset, add_params=f"&language={language}&mode={mode}&q={query}") + f"https://api.scratch.mit.edu/explore/studios", limit=limit, offset=offset, + add_params=f"&language={language}&mode={mode}&q={query}") return commons.parse_object_list(response, studio.Studio, self) - # --- Create project API --- - def create_project(self, *, title=None, project_json=empty_project_json, parent_id=None): # not working + def create_project(self, *, title: str = None, project_json: dict = empty_project_json, + parent_id=None) -> project.Project: # not working """ Creates a project on the Scratch website. @@ -420,8 +477,11 @@ def create_project(self, *, title=None, project_json=empty_project_json, parent_ if CREATE_PROJECT_USES[-1] < time.time() - 300: CREATE_PROJECT_USES.pop() else: - raise exceptions.BadRequest("Rate limit for creating 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.") - return + raise exceptions.BadRequest( + "Rate limit for creating Scratch projects exceeded.\n" + "This rate limit is enforced by scratchattach, not by the Scratch API.\n" + "For security reasons, it cannot be turned off.\n\n" + "Don't spam-create projects, it WILL get you banned.") CREATE_PROJECT_USES.insert(0, time.time()) if title is None: @@ -433,13 +493,85 @@ def create_project(self, *, title=None, project_json=empty_project_json, parent_ 'title': title, } - response = requests.post('https://projects.scratch.mit.edu/', params=params, cookies=self._cookies, headers=self._headers, json=project_json).json() + response = requests.post('https://projects.scratch.mit.edu/', params=params, cookies=self._cookies, + headers=self._headers, json=project_json).json() return self.connect_project(response["content-name"]) + def create_studio(self, *, title: str = None, description: str = None) -> studio.Studio: + """ + Create a studio on the scratch website + + Warning: + Don't spam this method - it WILL get you banned from Scratch. + To prevent accidental spam, a rate limit (5 studios per minute) is implemented for this function. + """ + global CREATE_STUDIO_USES + if len(CREATE_STUDIO_USES) < 5: + CREATE_STUDIO_USES.insert(0, time.time()) + else: + if CREATE_STUDIO_USES[-1] < time.time() - 300: + CREATE_STUDIO_USES.pop() + else: + raise exceptions.BadRequest( + "Rate limit for creating Scratch studios exceeded.\n" + "This rate limit is enforced by scratchattach, not by the Scratch API.\n" + "For security reasons, it cannot be turned off.\n\n" + "Don't spam-create studios, it WILL get you banned.") + CREATE_STUDIO_USES.insert(0, time.time()) + + if self.new_scratcher: + raise exceptions.Unauthorized(f"\nNew scratchers (like {self.username}) cannot create studios.") + + response = requests.post("https://scratch.mit.edu/studios/create/", + cookies=self._cookies, headers=self._headers) + + studio_id = webscrape_count(response.json()["redirect"], "/studios/", "/") + new_studio = self.connect_studio(studio_id) + + if title is not None: + new_studio.set_title(title) + if description is not None: + new_studio.set_description(description) + + return new_studio + + def create_class(self, title: str, desc: str = '') -> classroom.Classroom: + """ + Create a class on the scratch website + + Warning: + Don't spam this method - it WILL get you banned from Scratch. + To prevent accidental spam, a rate limit (5 classes per minute) is implemented for this function. + """ + global CREATE_CLASS_USES + if len(CREATE_CLASS_USES) < 5: + CREATE_CLASS_USES.insert(0, time.time()) + else: + if CREATE_CLASS_USES[-1] < time.time() - 300: + CREATE_CLASS_USES.pop() + else: + raise exceptions.BadRequest( + "Rate limit for creating Scratch classes exceeded.\n" + "This rate limit is enforced by scratchattach, not by the Scratch API.\n" + "For security reasons, it cannot be turned off.\n\n" + "Don't spam-create classes, it WILL get you banned.") + CREATE_CLASS_USES.insert(0, time.time()) + + if not self.is_teacher: + raise exceptions.Unauthorized(f"{self.username} is not a teacher; can't create class") + + data = requests.post("https://scratch.mit.edu/classes/create_classroom/", + json={"title": title, "description": desc}, + headers=self._headers, cookies=self._cookies).json() + + class_id = data[0]["id"] + return self.connect_classroom(class_id) + # --- My stuff page --- - def mystuff_projects(self, filter_arg="all", *, page=1, sort_by="", descending=True): - ''' + def mystuff_projects(self, filter_arg: str = "all", *, page: int = 1, sort_by: str = '', descending: bool = True) \ + -> list[project.Project]: + """ Gets the projects from the "My stuff" page. Args: @@ -452,7 +584,7 @@ def mystuff_projects(self, filter_arg="all", *, page=1, sort_by="", descending=T Returns: list: A list with the projects from the "My Stuff" page, each project is represented by a Project object. - ''' + """ if descending: ascsort = "" descsort = sort_by @@ -462,19 +594,19 @@ def mystuff_projects(self, filter_arg="all", *, page=1, sort_by="", descending=T try: targets = requests.get( f"https://scratch.mit.edu/site-api/projects/{filter_arg}/?page={page}&ascsort={ascsort}&descsort={descsort}", - headers = headers, - cookies = self._cookies, - timeout = 10, + headers=headers, + cookies=self._cookies, + timeout=10, ).json() projects = [] for target in targets: projects.append(project.Project( - id = target["pk"], _session=self, author_name=self._username, + id=target["pk"], _session=self, author_name=self._username, comments_allowed=None, instructions=None, notes=None, created=target["fields"]["datetime_created"], last_modified=target["fields"]["datetime_modified"], share_date=target["fields"]["datetime_shared"], - thumbnail_url="https:"+target["fields"]["thumbnail_url"], + thumbnail_url="https:" + target["fields"]["thumbnail_url"], favorites=target["fields"]["favorite_count"], loves=target["fields"]["love_count"], remixes=target["fields"]["remixers_count"], @@ -484,9 +616,10 @@ def mystuff_projects(self, filter_arg="all", *, page=1, sort_by="", descending=T )) return projects except Exception: - raise(exceptions.FetchError) + raise exceptions.FetchError() - def mystuff_studios(self, filter_arg="all", *, page=1, sort_by="", descending=True): + def mystuff_studios(self, filter_arg: str = "all", *, page: int = 1, sort_by: str = "", descending: bool = True) \ + -> list[studio.Studio]: if descending: ascsort = "" descsort = sort_by @@ -495,107 +628,160 @@ def mystuff_studios(self, filter_arg="all", *, page=1, sort_by="", descending=Tr descsort = "" try: targets = requests.get( - f"https://scratch.mit.edu/site-api/galleries/{filter_arg}/?page={page}&ascsort={ascsort}&descsort={descsort}", - headers = headers, - cookies = self._cookies, - timeout = 10, + f"https://scratch.mit.edu/site-api/galleries/{filter_arg}/", + params={"page": page, "ascsort": ascsort, "descsort": descsort}, + headers=headers, + cookies=self._cookies, + timeout=10 ).json() studios = [] for target in targets: studios.append(studio.Studio( - id = target["pk"], _session=self, - title = target["fields"]["title"], - description = None, - host_id = target["fields"]["owner"]["pk"], - host_name = target["fields"]["owner"]["username"], - open_to_all = None, comments_allowed=None, - image_url = "https:"+target["fields"]["thumbnail_url"], - created = target["fields"]["datetime_created"], - modified = target["fields"]["datetime_modified"], - follower_count = None, manager_count = None, - curator_count = target["fields"]["curators_count"], - project_count = target["fields"]["projecters_count"] + id=target["pk"], _session=self, + title=target["fields"]["title"], + description=None, + host_id=target["fields"]["owner"]["pk"], + host_name=target["fields"]["owner"]["username"], + open_to_all=None, comments_allowed=None, + image_url="https:" + target["fields"]["thumbnail_url"], + created=target["fields"]["datetime_created"], + modified=target["fields"]["datetime_modified"], + follower_count=None, manager_count=None, + curator_count=target["fields"]["curators_count"], + project_count=target["fields"]["projecters_count"] )) return studios except Exception: - raise(exceptions.FetchError) - - - def backpack(self,limit=20, offset=0): - ''' + raise exceptions.FetchError() + + def mystuff_classes(self, mode: str = "Last created", page: int = None) -> list[classroom.Classroom]: + if not self.is_teacher: + raise exceptions.Unauthorized(f"{self.username} is not a teacher; can't have classes") + ascsort, descsort = get_class_sort_mode(mode) + + classes_data = requests.get("https://scratch.mit.edu/site-api/classrooms/all/", + params={"page": page, "ascsort": ascsort, "descsort": descsort}, + headers=self._headers, cookies=self._cookies).json() + classes = [] + for data in classes_data: + fields = data["fields"] + educator_pf = fields["educator_profile"] + classes.append(classroom.Classroom( + id=data["pk"], + title=fields["title"], + classtoken=fields["token"], + datetime=datetime.datetime.fromisoformat(fields["datetime_created"]), + author=user.User( + username=educator_pf["user"]["username"], id=educator_pf["user"]["pk"], _session=self), + _session=self)) + return classes + + def mystuff_ended_classes(self, mode: str = "Last created", page: int = None) -> list[classroom.Classroom]: + if not self.is_teacher: + raise exceptions.Unauthorized(f"{self.username} is not a teacher; can't have (deleted) classes") + ascsort, descsort = get_class_sort_mode(mode) + + classes_data = requests.get("https://scratch.mit.edu/site-api/classrooms/closed/", + params={"page": page, "ascsort": ascsort, "descsort": descsort}, + headers=self._headers, cookies=self._cookies).json() + classes = [] + for data in classes_data: + fields = data["fields"] + educator_pf = fields["educator_profile"] + classes.append(classroom.Classroom( + id=data["pk"], + title=fields["title"], + classtoken=fields["token"], + datetime=datetime.datetime.fromisoformat(fields["datetime_created"]), + author=user.User( + username=educator_pf["user"]["username"], id=educator_pf["user"]["pk"], _session=self), + _session=self)) + return classes + + def backpack(self, limit: int = 20, offset: int = 0) -> list[backpack_asset.BackpackAsset]: + """ Lists the assets that are in the backpack of the user associated with the session. Returns: - list: List that contains the backpack items as dicts - ''' + list: List that contains the backpack items + """ data = commons.api_iterative( f"https://backpack.scratch.mit.edu/{self._username}", - limit = limit, offset = offset, headers = self._headers + limit=limit, offset=offset, _headers=self._headers ) return commons.parse_object_list(data, backpack_asset.BackpackAsset, self) - def delete_from_backpack(self, backpack_asset_id): - ''' + def delete_from_backpack(self, backpack_asset_id) -> backpack_asset.BackpackAsset: + """ Deletes an asset from the backpack. Args: backpack_asset_id: ID of the backpack asset that will be deleted - ''' + """ return backpack_asset.BackpackAsset(id=backpack_asset_id, _session=self).delete() - - def become_scratcher_invite(self): + def become_scratcher_invite(self) -> dict: """ - If you are a new Scratcher and have been invited for becoming a Scratcher, this API endpoint will provide more info on the invite. + If you are a new Scratcher and have been invited for becoming a Scratcher, this API endpoint will provide + more info on the invite. """ - return requests.get(f"https://api.scratch.mit.edu/users/{self.username}/invites", headers=self._headers, cookies=self._cookies).json() + return requests.get(f"https://api.scratch.mit.edu/users/{self.username}/invites", headers=self._headers, + cookies=self._cookies).json() # --- Connect classes inheriting from BaseCloud --- - def connect_cloud(self, project_id, *, CloudClass:Type[_base.BaseCloud]=cloud.ScratchCloud) -> Type[_base.BaseCloud]: + # noinspection PyPep8Naming + def connect_cloud(self, project_id, *, CloudClass: Type[_base.BaseCloud] = cloud.ScratchCloud) \ + -> Type[_base.BaseCloud]: """ - Connects to a cloud (by default Scratch's cloud) as logged in user. + Connects to a cloud (by default Scratch's cloud) as logged-in user. Args: project_id: - Keyword arguments: - CloudClass: The class that the returned object should be of. By default this class is scratchattach.cloud.ScratchCloud. + Keyword arguments: CloudClass: The class that the returned object should be of. By default, this class is + scratchattach.cloud.ScratchCloud. - Returns: - Type[scratchattach._base.BaseCloud]: An object representing the cloud of a project. Can be of any class inheriting from BaseCloud. + Returns: Type[scratchattach._base.BaseCloud]: An object representing the cloud of a project. Can be of any + class inheriting from BaseCloud. """ return CloudClass(project_id=project_id, _session=self) - def connect_scratch_cloud(self, project_id) -> 'cloud.ScratchCloud': + def connect_scratch_cloud(self, project_id) -> cloud.ScratchCloud: """ Returns: scratchattach.cloud.ScratchCloud: An object representing the Scratch cloud of a project. """ return cloud.ScratchCloud(project_id=project_id, _session=self) - def connect_tw_cloud(self, project_id, *, purpose="", contact="", cloud_host="wss://clouddata.turbowarp.org") -> 'cloud.TwCloud': + def connect_tw_cloud(self, project_id, *, purpose="", contact="", + cloud_host="wss://clouddata.turbowarp.org") -> cloud.TwCloud: """ Returns: scratchattach.cloud.TwCloud: An object representing the TurboWarp cloud of a project. """ - return cloud.TwCloud(project_id=project_id, purpose=purpose, contact=contact, cloud_host=cloud_host, _session=self) + return cloud.TwCloud(project_id=project_id, purpose=purpose, contact=contact, cloud_host=cloud_host, + _session=self) # --- Connect classes inheriting from BaseSiteComponent --- - def _make_linked_object(self, identificator_name, identificator, Class, NotFoundException): + # noinspection PyPep8Naming + # Class is camelcase here + def _make_linked_object(self, identificator_name, identificator, Class: BaseSiteComponent, + NotFoundException: Exception) -> BaseSiteComponent: """ The Session class doesn't save the login in a ._session attribute, but IS the login ITSELF. - Therefore the _make_linked_object method has to be adjusted + Therefore, the _make_linked_object method has to be adjusted to get it to work for in the Session class. Class must inherit from BaseSiteComponent """ + # noinspection PyProtectedMember + # _get_object is protected return commons._get_object(identificator_name, identificator, Class, NotFoundException, self) - - def connect_user(self, username) -> 'user.User': + def connect_user(self, username: str) -> user.User: """ Gets a user using this session, connects the session to the User object to allow authenticated actions @@ -607,7 +793,8 @@ def connect_user(self, username) -> 'user.User': """ return self._make_linked_object("username", username, user.User, exceptions.UserNotFound) - def find_username_from_id(self, user_id:int): + @deprecated("Finding usernames by user ids has been fixed.") + def find_username_from_id(self, user_id: int) -> str: """ Warning: Every time this functions is run, a comment on your profile is posted and deleted. Therefore you shouldn't run this too often. @@ -619,7 +806,8 @@ def find_username_from_id(self, user_id:int): try: comment = you.post_comment("scratchattach", commentee_id=int(user_id)) except exceptions.CommentPostFailure: - raise exceptions.BadRequest("After posting a comment, you need to wait 10 seconds before you can connect users by id again.") + raise exceptions.BadRequest( + "After posting a comment, you need to wait 10 seconds before you can connect users by id again.") except exceptions.BadRequest: raise exceptions.UserNotFound("Invalid user id") except Exception as e: @@ -632,8 +820,8 @@ def find_username_from_id(self, user_id:int): raise exceptions.UserNotFound() return username - - def connect_user_by_id(self, user_id:int) -> 'user.User': + @deprecated("Finding usernames by user ids has been fixed.") + def connect_user_by_id(self, user_id: int) -> user.User: """ Gets a user using this session, connects the session to the User object to allow authenticated actions @@ -643,7 +831,7 @@ def connect_user_by_id(self, user_id:int) -> 'user.User': 3) fetches other information about the user using Scratch's api.scratch.mit.edu/users/username API. Warning: - Every time this functions is run, a comment on your profile is posted and deleted. Therefore you shouldn't run this too often. + Every time this functions is run, a comment on your profile is posted and deleted. Therefore, you shouldn't run this too often. Args: user_id (int): User ID of the requested user @@ -651,9 +839,10 @@ def connect_user_by_id(self, user_id:int) -> 'user.User': Returns: scratchattach.user.User: An object that represents the requested user and allows you to perform actions on the user (like user.follow) """ - return self._make_linked_object("username", self.find_username_from_id(user_id), user.User, exceptions.UserNotFound) + return self._make_linked_object("username", self.find_username_from_id(user_id), user.User, + exceptions.UserNotFound) - def connect_project(self, project_id) -> 'project.Project': + def connect_project(self, project_id) -> project.Project: """ Gets a project using this session, connects the session to the Project object to allow authenticated actions sess @@ -665,7 +854,7 @@ def connect_project(self, project_id) -> 'project.Project': """ return self._make_linked_object("id", int(project_id), project.Project, exceptions.ProjectNotFound) - def connect_studio(self, studio_id) -> 'studio.Studio': + def connect_studio(self, studio_id) -> studio.Studio: """ Gets a studio using this session, connects the session to the Studio object to allow authenticated actions @@ -677,7 +866,7 @@ def connect_studio(self, studio_id) -> 'studio.Studio': """ return self._make_linked_object("id", int(studio_id), studio.Studio, exceptions.StudioNotFound) - def connect_classroom(self, class_id) -> 'classroom.Classroom': + def connect_classroom(self, class_id) -> classroom.Classroom: """ Gets a class using this session. @@ -689,7 +878,7 @@ def connect_classroom(self, class_id) -> 'classroom.Classroom': """ return self._make_linked_object("id", int(class_id), classroom.Classroom, exceptions.ClassroomNotFound) - def connect_classroom_from_token(self, class_token) -> 'classroom.Classroom': + def connect_classroom_from_token(self, class_token) -> classroom.Classroom: """ Gets a class using this session. @@ -699,9 +888,10 @@ def connect_classroom_from_token(self, class_token) -> 'classroom.Classroom': Returns: scratchattach.classroom.Classroom: An object representing the requested classroom """ - return self._make_linked_object("classtoken", int(class_token), classroom.Classroom, exceptions.ClassroomNotFound) + return self._make_linked_object("classtoken", int(class_token), classroom.Classroom, + exceptions.ClassroomNotFound) - def connect_topic(self, topic_id) -> 'forum.ForumTopic': + def connect_topic(self, topic_id) -> forum.ForumTopic: """ Gets a forum topic using this session, connects the session to the ForumTopic object to allow authenticated actions Data is up-to-date. Data received from Scratch's RSS feed XML API. @@ -714,7 +904,6 @@ def connect_topic(self, topic_id) -> 'forum.ForumTopic': """ return self._make_linked_object("id", int(topic_id), forum.ForumTopic, exceptions.ForumContentNotFound) - def connect_topic_list(self, category_id, *, page=1): """ @@ -732,14 +921,15 @@ def connect_topic_list(self, category_id, *, page=1): """ try: - response = requests.get(f"https://scratch.mit.edu/discuss/{category_id}/?page={page}", headers=self._headers, cookies=self._cookies) + response = requests.get(f"https://scratch.mit.edu/discuss/{category_id}/?page={page}", + headers=self._headers, cookies=self._cookies) soup = BeautifulSoup(response.content, 'html.parser') except Exception as e: raise exceptions.FetchError(str(e)) try: category_name = soup.find('h4').find("span").get_text() - except Exception as e: + except Exception: raise exceptions.BadRequest("Invalid category id") try: @@ -760,37 +950,48 @@ def connect_topic_list(self, category_id, *, page=1): last_updated = columns[3].split(" ")[0] + " " + columns[3].split(" ")[1] - return_topics.append(forum.ForumTopic(_session=self, id=int(topic_id), title=title, category_name=category_name, last_updated=last_updated, reply_count=int(columns[1]), view_count=int(columns[2]))) + return_topics.append( + forum.ForumTopic(_session=self, id=int(topic_id), title=title, category_name=category_name, + last_updated=last_updated, reply_count=int(columns[1]), + view_count=int(columns[2]))) return return_topics except Exception as e: raise exceptions.ScrapeError(str(e)) # --- Connect classes inheriting from BaseEventHandler --- - def connect_message_events(self, *, update_interval=2) -> 'message_events.MessageEvents': + def connect_message_events(self, *, update_interval=2) -> message_events.MessageEvents: # shortcut for connect_linked_user().message_events() - return message_events.MessageEvents(user.User(username=self.username, _session=self), update_interval=update_interval) + return message_events.MessageEvents(user.User(username=self.username, _session=self), + update_interval=update_interval) - def connect_filterbot(self, *, log_deletions=True) -> 'filterbot.Filterbot': + def connect_filterbot(self, *, log_deletions=True) -> filterbot.Filterbot: return filterbot.Filterbot(user.User(username=self.username, _session=self), log_deletions=log_deletions) + # ------ # -def login_by_id(session_id, *, username=None, password=None, xtoken=None) -> Session: +def login_by_id(session_id: str, *, username: str = None, password: str = None, xtoken=None) -> Session: """ Creates a session / log in to the Scratch website with the specified session id. Structured similarly to Session._connect_object method. Args: session_id (str) - password (str) Keyword arguments: - timeout (int): Optional, but recommended. Specify this when the Python environment's IP address is blocked by Scratch's API, but you still want to use cloud variables. + username (str) + password (str) + xtoken (str) Returns: - scratchattach.session.Session: An object that represents the created log in / session + scratchattach.session.Session: An object that represents the created login / session """ + # Removed this from docstring since it doesn't exist: + # timeout (int): Optional, but recommended. + # Specify this when the Python environment's IP address is blocked by Scratch's API, + # but you still want to use cloud variables. + # Generate session_string (a scratchattach-specific authentication method) if password is not None: session_data = dict(session_id=session_id, username=username, password=password) @@ -798,21 +999,29 @@ def login_by_id(session_id, *, username=None, password=None, xtoken=None) -> Ses else: session_string = None _session = Session(id=session_id, username=username, session_string=session_string, xtoken=xtoken) + try: status = _session.update() except Exception as e: status = False - print(f"Key error at key "+str(e)+" when reading scratch.mit.edu/session API response") + warnings.warn(f"Key error at key {e} when reading scratch.mit.edu/session API response") + if status is not True: if _session.xtoken is None: if _session.username is None: - print(f"Warning: Logged in by id, but couldn't fetch XToken. Make sure the provided session id is valid. Setting cloud variables can still work if you provide a `username='username'` keyword argument to the sa.login_by_id function") + warnings.warn("Warning: Logged in by id, but couldn't fetch XToken. " + "Make sure the provided session id is valid. " + "Setting cloud variables can still work if you provide a " + "`username='username'` keyword argument to the sa.login_by_id function") else: - print(f"Warning: Logged in by id, but couldn't fetch XToken. Make sure the provided session id is valid.") + warnings.warn("Warning: Logged in by id, but couldn't fetch XToken. " + "Make sure the provided session id is valid.") else: - print(f"Warning: Logged in by id, but couldn't fetch session info. This won't affect any other features.") + warnings.warn("Warning: Logged in by id, but couldn't fetch session info. " + "This won't affect any other features.") return _session + def login(username, password, *, timeout=10) -> Session: """ Creates a session / log in to the Scratch website with the specified username and password. @@ -829,16 +1038,16 @@ def login(username, password, *, timeout=10) -> Session: timeout (int): Timeout for the request to Scratch's login API (in seconds). Defaults to 10. Returns: - scratchattach.session.Session: An object that represents the created log in / session + scratchattach.session.Session: An object that represents the created login / session """ # Post request to login API: - data = json.dumps({"username": username, "password": password}) - _headers = dict(headers) + _headers = headers.copy() _headers["Cookie"] = "scratchcsrftoken=a;scratchlanguage=en;" request = requests.post( - "https://scratch.mit.edu/login/", data=data, headers=_headers, - timeout = timeout, + "https://scratch.mit.edu/login/", json={"username": username, "password": password}, headers=_headers, + + timeout=timeout, errorhandling = False ) try: session_id = str(re.search('"(.*)"', request.headers["Set-Cookie"]).group()) @@ -850,12 +1059,13 @@ def login(username, password, *, timeout=10) -> Session: return login_by_id(session_id, username=username, password=password) -def login_by_session_string(session_string) -> Session: - session_string = base64.b64decode(session_string).decode() # unobfuscate +def login_by_session_string(session_string: str) -> Session: + session_string = base64.b64decode(session_string).decode() # unobfuscate session_data = json.loads(session_string) try: assert session_data.get("session_id") - return login_by_id(session_data["session_id"], username=session_data.get("username"), password=session_data.get("password")) + return login_by_id(session_data["session_id"], username=session_data.get("username"), + password=session_data.get("password")) except Exception: pass try: diff --git a/scratchattach/site/studio.py b/scratchattach/site/studio.py index 9c9e2861..b412dbe6 100644 --- a/scratchattach/site/studio.py +++ b/scratchattach/site/studio.py @@ -1,4 +1,5 @@ """Studio class""" +from __future__ import annotations import json import random @@ -9,6 +10,7 @@ from ..utils.requests import Requests as requests + class Studio(BaseSiteComponent): """ Represents a Scratch studio. @@ -120,7 +122,7 @@ def unfollow(self): timeout=10, ) - def comments(self, *, limit=40, offset=0): + def comments(self, *, limit=40, offset=0) -> list[comment.Comment]: """ Returns the comments posted on the studio (except for replies. To get replies use :meth:`scratchattach.studio.Studio.get_comment_replies`). @@ -138,7 +140,7 @@ def comments(self, *, limit=40, offset=0): i["source_id"] = self.id return commons.parse_object_list(response, comment.Comment, self._session) - def comment_replies(self, *, comment_id, limit=40, offset=0): + def comment_replies(self, *, comment_id, limit=40, offset=0) -> list[comment.Comment]: response = commons.api_iterative( f"https://api.scratch.mit.edu/studios/{self.id}/comments/{comment_id}/replies", limit=limit, offset=offset, add_params=f"&cachebust={random.randint(0,9999)}") for x in response: diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index 15b4849d..0e247e2d 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -1,4 +1,5 @@ -"""Session class and login function""" +"""User class""" +from __future__ import annotations import json import random @@ -71,10 +72,10 @@ def __init__(self, **entries): # Headers and cookies: if self._session is None: - self._headers = headers + self._headers :dict = headers self._cookies = {} else: - self._headers = self._session._headers + self._headers :dict = self._session._headers self._cookies = self._session._cookies # Headers for operations that require accept and Content-Type fields: @@ -107,7 +108,6 @@ def _assert_permission(self): raise exceptions.Unauthorized( "You need to be authenticated as the profile owner to do this.") - def does_exist(self): """ Returns: @@ -263,13 +263,13 @@ def studios(self, *, limit=40, offset=0): studios.append(_studio) return studios - def projects(self, *, limit=40, offset=0): + def projects(self, *, limit=40, offset=0) -> list[project.Project]: """ Returns: list: The user's shared projects """ _projects = commons.api_iterative( - f"https://api.scratch.mit.edu/users/{self.username}/projects/", limit=limit, offset=offset, headers = self._headers) + f"https://api.scratch.mit.edu/users/{self.username}/projects/", limit=limit, offset=offset, _headers= self._headers) for p in _projects: p["author"] = {"username":self.username} return commons.parse_object_list(_projects, project.Project, self._session) @@ -391,7 +391,7 @@ def favorites(self, *, limit=40, offset=0): list: The user's favorite projects """ _projects = commons.api_iterative( - f"https://api.scratch.mit.edu/users/{self.username}/favorites/", limit=limit, offset=offset, headers = self._headers) + f"https://api.scratch.mit.edu/users/{self.username}/favorites/", limit=limit, offset=offset, _headers= self._headers) return commons.parse_object_list(_projects, project.Project, self._session) def favorites_count(self): @@ -420,46 +420,44 @@ def viewed_projects(self, limit=24, offset=0): """ self._assert_permission() _projects = commons.api_iterative( - f"https://api.scratch.mit.edu/users/{self.username}/projects/recentlyviewed", limit=limit, offset=offset, headers = self._headers) + f"https://api.scratch.mit.edu/users/{self.username}/projects/recentlyviewed", limit=limit, offset=offset, _headers= self._headers) return commons.parse_object_list(_projects, project.Project, self._session) + def set_pfp(self, image: bytes): + """ + Sets the user's profile picture. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user` + """ + # Teachers can set pfp! - Should update this method to check for that + # self._assert_permission() + requests.post( + f"https://scratch.mit.edu/site-api/users/all/{self.username}/", + headers=self._headers, + cookies=self._cookies, + files={"file": image}) + def set_bio(self, text): """ 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` """ - self._assert_permission() + # Teachers can set bio! - Should update this method to check for that + # self._assert_permission() requests.put( f"https://scratch.mit.edu/site-api/users/all/{self.username}/", - headers = self._json_headers, - cookies = self._cookies, - data = json.dumps(dict( - comments_allowed = True, - id = self.username, - bio = text, - thumbnail_url = self.icon_url, - userId = self.id, - username = self.username - )) - ) + headers=self._json_headers, + cookies=self._cookies, + json={"bio": text}) def set_wiwo(self, text): """ 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` """ - self._assert_permission() + # Teachers can also change your wiwo + # self._assert_permission() requests.put( f"https://scratch.mit.edu/site-api/users/all/{self.username}/", - headers = self._json_headers, - cookies = self._cookies, - data = json.dumps(dict( - comments_allowed = True, - id = self.username, - status = text, - thumbnail_url = self.icon_url, - userId = self.id, - username = self.username - )) - ) + headers=self._json_headers, + cookies=self._cookies, + json={"status": text}) def set_featured(self, project_id, *, label=""): """ @@ -474,9 +472,9 @@ def set_featured(self, project_id, *, label=""): self._assert_permission() requests.put( f"https://scratch.mit.edu/site-api/users/all/{self.username}/", - headers = self._json_headers, - cookies = self._cookies, - data = json.dumps({"featured_project":int(project_id),"featured_project_label":label}) + headers=self._json_headers, + cookies=self._cookies, + json={"featured_project": int(project_id), "featured_project_label": label} ) def set_forum_signature(self, text): @@ -514,14 +512,14 @@ def post_comment(self, content, *, parent_id="", commentee_id=""): """ self._assert_auth() data = { - "commentee_id": commentee_id, - "content": str(content), - "parent_id": parent_id, + "commentee_id": commentee_id, + "content": str(content), + "parent_id": parent_id, } r = requests.post( f"https://scratch.mit.edu/site-api/comments/user/{self.username}/add/", - headers = headers, - cookies = self._cookies, + headers=headers, + cookies=self._cookies, data=json.dumps(data), ) if r.status_code != 200: @@ -534,7 +532,7 @@ def post_comment(self, content, *, parent_id="", commentee_id=""): text = r.text data = { 'id': text.split('

')[1].split('"
')[0], 'reply_count': 0, 'cached_replies': [] @@ -547,7 +545,7 @@ def post_comment(self, content, *, parent_id="", commentee_id=""): raise(exceptions.CommentPostFailure( "You are being rate-limited for running this operation too often. Implement a cooldown of about 10 seconds.")) else: - raise(exceptions.FetchError("Couldn't parse API response")) + raise(exceptions.FetchError(f"Couldn't parse API response: {r.text!r}")) def reply_comment(self, content, *, parent_id, commentee_id=""): """ @@ -713,7 +711,7 @@ def comments(self, *, page=1, limit=None): DATA.append(_comment) return DATA - def comment_by_id(self, comment_id): + def comment_by_id(self, comment_id) -> comment.Comment: """ Gets a comment on this user's profile by id. diff --git a/scratchattach/utils/commons.py b/scratchattach/utils/commons.py index a5ee3467..f08fdf39 100644 --- a/scratchattach/utils/commons.py +++ b/scratchattach/utils/commons.py @@ -1,17 +1,24 @@ """v2 ready: Common functions used by various internal modules""" +from __future__ import annotations + +from types import FunctionType +from typing import Final, Any, TYPE_CHECKING from . import exceptions -from threading import Thread from .requests import Requests as requests -headers = { - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36", +if TYPE_CHECKING: + # Having to do this is quite inelegant, but this is commons.py, so this is done to avoid cyclic imports + from ..site._base import BaseSiteComponent + +headers: Final = { + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36", "x-csrftoken": "a", "x-requested-with": "XMLHttpRequest", "referer": "https://scratch.mit.edu", -} # headers recommended for accessing API endpoints that don't require verification - -empty_project_json = { +} +empty_project_json: Final = { 'targets': [ { 'isStage': True, @@ -52,57 +59,68 @@ 'meta': { 'semver': '3.0.0', 'vm': '2.3.0', - 'agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/124.0.0.0 Safari/537.36', }, } -def api_iterative_data(fetch_func, limit, offset, max_req_limit=40, unpack=True): +def api_iterative_data(fetch_func: FunctionType, limit: int, offset: int, max_req_limit: int = 40, + unpack: bool = True): """ Iteratively gets data by calling fetch_func with a moving offset and a limit. Once fetch_func returns None, the retrieval is completed. """ if limit is None: limit = max_req_limit + end = offset + limit api_data = [] for offs in range(offset, end, max_req_limit): - d = fetch_func( - offs, max_req_limit - ) # Mimick actual scratch by only requesting the max amount - if d is None: + # Mimic actual scratch by only requesting the max amount + data = fetch_func(offs, max_req_limit) + if data is None: break + if unpack: - api_data.extend(d) + api_data.extend(data) else: - api_data.append(d) - if len(d) < max_req_limit: + api_data.append(data) + + if len(data) < max_req_limit: break + api_data = api_data[:limit] return api_data -def api_iterative( - url, *, limit, offset, max_req_limit=40, add_params="", headers=headers, cookies={} -): + +def api_iterative(url: str, *, limit: int, offset: int, max_req_limit: int = 40, add_params: str = "", + _headers: dict = None, cookies: dict = None): """ Function for getting data from one of Scratch's iterative JSON API endpoints (like /users//followers, or /users//projects) """ + if _headers is None: + _headers = headers.copy() + if cookies is None: + cookies = {} + if offset < 0: raise exceptions.BadRequest("offset parameter must be >= 0") if limit < 0: raise exceptions.BadRequest("limit parameter must be >= 0") - - def fetch(o, l): + + def fetch(off: int, lim: int): """ - Performs a singla API request + Performs a single API request """ resp = requests.get( - f"{url}?limit={l}&offset={o}{add_params}", headers=headers, cookies=cookies, timeout=10 + f"{url}?limit={lim}&offset={off}{add_params}", headers=_headers, cookies=cookies, timeout=10 ).json() + if not resp: return None if resp == {"code": "BadRequest", "message": ""}: - raise exceptions.BadRequest("the passed arguments are invalid") + raise exceptions.BadRequest("The passed arguments are invalid") return resp api_data = api_iterative_data( @@ -110,39 +128,65 @@ def fetch(o, l): ) return api_data -def _get_object(identificator_name, identificator, Class, NotFoundException, session=None): - # Interal function: Generalization of the process ran by get_user, get_studio etc. + +def _get_object(identificator_name, identificator, Class: type, NotFoundException, session=None) -> BaseSiteComponent: + # Internal function: Generalization of the process ran by get_user, get_studio etc. # Builds an object of class that is inheriting from BaseSiteComponent # # Class must inherit from BaseSiteComponent try: - _object = Class(**{identificator_name:identificator, "_session":session}) + _object = Class(**{identificator_name: identificator, "_session": session}) r = _object.update() if r == "429": - raise(exceptions.Response429("Your network is blocked or rate-limited by Scratch.\nIf you're using an online IDE like replit.com, try running the code on your computer.")) + raise exceptions.Response429( + "Your network is blocked or rate-limited by Scratch.\n" + "If you're using an online IDE like replit.com, try running the code on your computer.") if not r: # Target is unshared. The cases that this can happen in are hardcoded: from ..site import project - if Class is project.Project: # Case: Target is an unshared project. - return project.PartialProject(**{identificator_name:identificator, "shared":False, "_session":session}) + if Class is project.Project: # Case: Target is an unshared project. + return project.PartialProject(**{identificator_name: identificator, + "shared": False, "_session": session}) else: raise NotFoundException else: return _object except KeyError as e: - raise(NotFoundException("Key error at key "+str(e)+" when reading API response")) + raise NotFoundException(f"Key error at key {e} when reading API response") except Exception as e: - raise(e) + raise e + -def webscrape_count(raw, text_before, text_after): - return int(raw.split(text_before)[1].split(text_after)[0]) +def webscrape_count(raw, text_before, text_after, cls: type = int) -> int | Any: + return cls(raw.split(text_before)[1].split(text_after)[0]) -def parse_object_list(raw, Class, session=None, primary_key="id"): + +def parse_object_list(raw, Class, session=None, primary_key="id") -> list[BaseSiteComponent]: results = [] for raw_dict in raw: try: - _obj = Class(**{primary_key:raw_dict[primary_key], "_session":session}) + _obj = Class(**{primary_key: raw_dict[primary_key], "_session": session}) _obj._update_from_dict(raw_dict) results.append(_obj) except Exception as e: print("Warning raised by scratchattach: failed to parse ", raw_dict, "error", e) - return results \ No newline at end of file + return results + + +def get_class_sort_mode(mode: str) -> tuple[str, str]: + """ + Returns the sort mode for the given mode for classes only + """ + ascsort = '' + descsort = '' + + mode = mode.lower() + if mode == "last created": + pass + elif mode == "students": + descsort = "student_count" + elif mode == "a-z": + ascsort = "title" + elif mode == "z-a": + descsort = "title" + + return ascsort, descsort diff --git a/scratchattach/utils/encoder.py b/scratchattach/utils/encoder.py index dd063898..7df83e09 100644 --- a/scratchattach/utils/encoder.py +++ b/scratchattach/utils/encoder.py @@ -1,3 +1,4 @@ +from __future__ import annotations import math from . import exceptions diff --git a/scratchattach/utils/enums.py b/scratchattach/utils/enums.py new file mode 100644 index 00000000..bbb72462 --- /dev/null +++ b/scratchattach/utils/enums.py @@ -0,0 +1,197 @@ +""" +List of supported languages of scratch's translate and text2speech extensions. +Adapted from https://translate-service.scratch.mit.edu/supported?language=en +""" +from __future__ import annotations + +from enum import Enum +from dataclasses import dataclass + +from typing import Callable, Iterable + + +@dataclass(init=True, repr=True) +class Language: + name: str = None + code: str = None + locales: list[str] = None + tts_locale: str = None + single_gender: bool = None + + +class _EnumWrapper(Enum): + @classmethod + def find(cls, value, by: str, apply_func: Callable = None): + """ + Finds the enum item with the given attribute that is equal to the given value. + the apply_func will be applied to the attribute of each language object before comparison. + + i.e. Languages.find("ukranian", "name", str.lower) will return the Ukrainian language dataclass object + (even though Ukrainian was spelt lowercase, since str.lower will convert the "Ukrainian" string to lowercase) + """ + if apply_func is None: + def apply_func(x): + return x + + for item in cls: + item_obj = item.value + + try: + if by is None: + _val = item_obj + else: + _val = getattr(item_obj, by) + + if apply_func(_val) == value: + return item_obj + + except TypeError: + pass + + @classmethod + def all_of(cls, attr_name: str, apply_func: Callable = None) -> Iterable: + """ + Returns the list of each listed enum item's specified attribute by "attr_name" + + i.e. Languages.all_of("name") will return a list of names: + ["Albanian", "Amharic", ...] + + The apply_func function will be applied to every list item, + i.e. Languages.all_of("name", str.lower) will return the same except in lowercase: + ["albanian", "amharic", ...] + """ + if apply_func is None: + def apply_func(x): + return x + + for item in cls: + item_obj = item.value + attr = getattr(item_obj, attr_name) + try: + yield apply_func(attr) + + except TypeError: + yield attr + + @classmethod + def find_by_attrs(cls, value, bys: list[str], apply_func: Callable = None) -> list: + """ + Calls the EnumWrapper.by function multiple times until a match is found, using the provided 'by' attribute names + """ + for by in bys: + ret = cls.find(value, by, apply_func) + if ret is not None: + return ret + + +class Languages(_EnumWrapper): + Albanian = Language('Albanian', 'sq', None, None, None) + Amharic = Language('Amharic', 'am', None, None, None) + Arabic = Language('Arabic', 'ar', ['ar'], 'arb', True) + Armenian = Language('Armenian', 'hy', None, None, None) + Azerbaijani = Language('Azerbaijani', 'az', None, None, None) + Basque = Language('Basque', 'eu', None, None, None) + Belarusian = Language('Belarusian', 'be', None, None, None) + Bulgarian = Language('Bulgarian', 'bg', None, None, None) + Catalan = Language('Catalan', 'ca', None, None, None) + Chinese_Traditional = Language('Chinese (Traditional)', 'zh-tw', ['zh-cn', 'zh-tw'], 'cmn-CN', True) + Croatian = Language('Croatian', 'hr', None, None, None) + Czech = Language('Czech', 'cs', None, None, None) + Danish = Language('Danish', 'da', ['da'], 'da-DK', False) + Dutch = Language('Dutch', 'nl', ['nl'], 'nl-NL', False) + English = Language('English', 'en', ['en'], 'en-US', False) + Esperanto = Language('Esperanto', 'eo', None, None, None) + Estonian = Language('Estonian', 'et', None, None, None) + Finnish = Language('Finnish', 'fi', None, None, None) + French = Language('French', 'fr', ['fr'], 'fr-FR', False) + Galician = Language('Galician', 'gl', None, None, None) + German = Language('German', 'de', ['de'], 'de-DE', False) + Greek = Language('Greek', 'el', None, None, None) + Haitian_Creole = Language('Haitian Creole', 'ht', None, None, None) + Hindi = Language('Hindi', 'hi', ['hi'], 'hi-IN', True) + Hungarian = Language('Hungarian', 'hu', None, None, None) + Icelandic = Language('Icelandic', 'is', ['is'], 'is-IS', False) + Indonesian = Language('Indonesian', 'id', None, None, None) + Irish = Language('Irish', 'ga', None, None, None) + Italian = Language('Italian', 'it', ['it'], 'it-IT', False) + Japanese = Language('Japanese', 'ja', ['ja', 'ja-hira'], 'ja-JP', False) + Kannada = Language('Kannada', 'kn', None, None, None) + Korean = Language('Korean', 'ko', ['ko'], 'ko-KR', True) + Kurdish_Kurmanji = Language('Kurdish (Kurmanji)', 'ku', None, None, None) + Latin = Language('Latin', 'la', None, None, None) + Latvian = Language('Latvian', 'lv', None, None, None) + Lithuanian = Language('Lithuanian', 'lt', None, None, None) + Macedonian = Language('Macedonian', 'mk', None, None, None) + Malay = Language('Malay', 'ms', None, None, None) + Malayalam = Language('Malayalam', 'ml', None, None, None) + Maltese = Language('Maltese', 'mt', None, None, None) + Maori = Language('Maori', 'mi', None, None, None) + Marathi = Language('Marathi', 'mr', None, None, None) + Mongolian = Language('Mongolian', 'mn', None, None, None) + Myanmar_Burmese = Language('Myanmar (Burmese)', 'my', None, None, None) + Persian = Language('Persian', 'fa', None, None, None) + Polish = Language('Polish', 'pl', ['pl'], 'pl-PL', False) + Portuguese = Language('Portuguese', 'pt', ['pt'], 'pt-PT', False) + Romanian = Language('Romanian', 'ro', ['ro'], 'ro-RO', True) + Russian = Language('Russian', 'ru', ['ru'], 'ru-RU', False) + Scots_Gaelic = Language('Scots Gaelic', 'gd', None, None, None) + Serbian = Language('Serbian', 'sr', None, None, None) + Slovak = Language('Slovak', 'sk', None, None, None) + Slovenian = Language('Slovenian', 'sl', None, None, None) + Spanish = Language('Spanish', 'es', None, None, None) + Swedish = Language('Swedish', 'sv', ['sv'], 'sv-SE', True) + Telugu = Language('Telugu', 'te', None, None, None) + Thai = Language('Thai', 'th', None, None, None) + Turkish = Language('Turkish', 'tr', ['tr'], 'tr-TR', True) + Ukrainian = Language('Ukrainian', 'uk', None, None, None) + Uzbek = Language('Uzbek', 'uz', None, None, None) + Vietnamese = Language('Vietnamese', 'vi', None, None, None) + Welsh = Language('Welsh', 'cy', ['cy'], 'cy-GB', True) + Zulu = Language('Zulu', 'zu', None, None, None) + Hebrew = Language('Hebrew', 'he', None, None, None) + Chinese_Simplified = Language('Chinese (Simplified)', 'zh-cn', ['zh-cn', 'zh-tw'], 'cmn-CN', True) + Mandarin = Chinese_Simplified + + nb_NO = Language(None, None, ['nb', 'nn'], 'nb-NO', True) + pt_BR = Language(None, None, ['pt-br'], 'pt-BR', False) + Brazilian = pt_BR + es_ES = Language(None, None, ['es'], 'es-ES', False) + es_US = Language(None, None, ['es-419'], 'es-US', False) + + @classmethod + def find(cls, value, by: str = "name", apply_func: Callable = None) -> Language: + return super().find(value, by, apply_func) + + @classmethod + def all_of(cls, attr_name: str = "name", apply_func: Callable = None) -> list: + return super().all_of(attr_name, apply_func) + + +@dataclass(init=True, repr=True) +class TTSVoice: + name: str + gender: str + playback_rate: float | int = 1 + + +class TTSVoices(_EnumWrapper): + alto = TTSVoice("alto", "female") + # female is functionally equal to alto + female = TTSVoice("female", "female") + + tenor = TTSVoice("tenor", "male") + # male is functionally equal to tenor + male = TTSVoice("male", "male") + + squeak = TTSVoice("squeak", "female", 1.19) + giant = TTSVoice("giant", "male", .84) + kitten = TTSVoice("kitten", "female", 1.41) + + @classmethod + def find(cls, value, by: str = "name", apply_func: Callable = None) -> TTSVoice: + return super().find(value, by, apply_func) + + @classmethod + def all_of(cls, attr_name: str = "name", apply_func: Callable = None) -> Iterable: + return super().all_of(attr_name, apply_func) + diff --git a/scratchattach/utils/exceptions.py b/scratchattach/utils/exceptions.py index 10b756e8..63fd49d6 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -1,4 +1,5 @@ # Authentication / Authorization: +from __future__ import annotations class Unauthenticated(Exception): """ @@ -18,7 +19,6 @@ class Unauthenticated(Exception): def __init__(self, message=""): self.message = "No login / session connected.\n\nThe object on which the method was called was created using scratchattach.get_xyz()\nUse session.connect_xyz() instead (xyz is a placeholder for user / project / cloud / ...).\n\nMore information: https://scratchattach.readthedocs.io/en/latest/scratchattach.html#scratchattach.utils.exceptions.Unauthenticated" super().__init__(self.message) - pass class Unauthorized(Exception): @@ -29,10 +29,11 @@ class Unauthorized(Exception): """ def __init__(self, message=""): - self.message = "The user corresponding to the connected login / session is not allowed to perform this action." + self.message = ( + f"The user corresponding to the connected login / session is not allowed to perform this action. " + f"{message}") super().__init__(self.message) - pass class XTokenError(Exception): """ @@ -43,6 +44,7 @@ class XTokenError(Exception): pass + # Not found errors: class UserNotFound(Exception): @@ -60,6 +62,7 @@ class ProjectNotFound(Exception): pass + class ClassroomNotFound(Exception): """ Raised when a non-existent Classroom is requested. @@ -75,15 +78,32 @@ class StudioNotFound(Exception): pass + class ForumContentNotFound(Exception): """ Raised when a non-existent forum topic / post is requested. """ pass + class CommentNotFound(Exception): pass + +# Invalid inputs +class InvalidLanguage(Exception): + """ + Raised when an invalid language/language code/language object is provided, for TTS or Translate + """ + pass + + +class InvalidTTSGender(Exception): + """ + Raised when an invalid TTS gender is provided. + """ + pass + # API errors: class LoginFailure(Exception): @@ -95,6 +115,7 @@ class LoginFailure(Exception): pass + class FetchError(Exception): """ Raised when getting information from the Scratch API fails. This can have various reasons. Make sure all provided arguments are valid. @@ -102,6 +123,7 @@ class FetchError(Exception): pass + class BadRequest(Exception): """ Raised when the Scratch API responds with a "Bad Request" error message. This can have various reasons. Make sure all provided arguments are valid. @@ -117,6 +139,7 @@ class Response429(Exception): pass + class CommentPostFailure(Exception): """ Raised when a comment fails to post. This can have various reasons. @@ -124,12 +147,14 @@ class CommentPostFailure(Exception): pass + class APIError(Exception): """ For API errors that can't be classified into one of the above errors """ pass + class ScrapeError(Exception): """ Raised when something goes wrong while web-scraping a page with bs4. @@ -137,9 +162,10 @@ class ScrapeError(Exception): pass + # Cloud / encoding errors: -class ConnectionError(Exception): +class CloudConnectionError(Exception): """ Raised when connecting to Scratch's cloud server fails. This can have various reasons. """ @@ -172,12 +198,63 @@ class RequestNotFound(Exception): pass + # Websocket server errors: class WebsocketServerError(Exception): - """ Raised when the self-hosted cloud websocket server fails to start. """ - pass \ No newline at end of file + pass + + +# Editor errors: + +class UnclosedJSONError(Exception): + """ + Raised when a JSON string is never closed. + """ + pass + + +class BadVLBPrimitiveError(Exception): + """ + Raised when a Primitive claiming to be a variable/list/broadcast actually isn't + """ + pass + + +class UnlinkedVLB(Exception): + """ + Raised when a Primitive cannot be linked to variable/list/broadcast because the provided ID does not have an associated variable/list/broadcast + """ + pass + + +class InvalidStageCount(Exception): + """ + Raised when a project has too many or too few Stage sprites + """ + pass + + +class InvalidVLBName(Exception): + """ + Raised when an invalid VLB name is provided (not variable, list or broadcast) + """ + pass + + +class BadBlockShape(Exception): + """ + Raised when the block shape cannot allow for the operation + """ + pass + + +class BadScript(Exception): + """ + Raised when the block script cannot allow for the operation + """ + pass diff --git a/scratchattach/utils/requests.py b/scratchattach/utils/requests.py index 1c90a749..c015cfe6 100644 --- a/scratchattach/utils/requests.py +++ b/scratchattach/utils/requests.py @@ -1,17 +1,20 @@ +from __future__ import annotations + import requests from . import exceptions proxies = None + class Requests: """ Centralized HTTP request handler (for better error handling and proxies) """ @staticmethod - def check_response(r : requests.Response): + def check_response(r: requests.Response): if r.status_code == 403 or r.status_code == 401: - raise exceptions.Unauthorized + raise exceptions.Unauthorized(f"Request content: {r.content}") if r.status_code == 500: raise exceptions.APIError("Internal Scratch server error") if r.status_code == 429: @@ -24,25 +27,29 @@ def check_response(r : requests.Response): @staticmethod def get(url, *, data=None, json=None, headers=None, cookies=None, timeout=None, params=None): try: - r = requests.get(url, data=data, json=json, headers=headers, cookies=cookies, params=params, timeout=timeout, proxies=proxies) + r = requests.get(url, data=data, json=json, headers=headers, cookies=cookies, params=params, + timeout=timeout, proxies=proxies) except Exception as e: raise exceptions.FetchError(e) Requests.check_response(r) return r - + @staticmethod - def post(url, *, data=None, json=None, headers=None, cookies=None, timeout=None, params=None): + def post(url, *, data=None, json=None, headers=None, cookies=None, timeout=None, params=None, files=None, errorhandling=True, ): try: - r = requests.post(url, data=data, json=json, headers=headers, cookies=cookies, params=params, timeout=timeout, proxies=proxies) + r = requests.post(url, data=data, json=json, headers=headers, cookies=cookies, params=params, + timeout=timeout, proxies=proxies, files=files) except Exception as e: raise exceptions.FetchError(e) - Requests.check_response(r) + if errorhandling: + Requests.check_response(r) return r @staticmethod def delete(url, *, data=None, json=None, headers=None, cookies=None, timeout=None, params=None): try: - r = requests.delete(url, data=data, json=json, headers=headers, cookies=cookies, params=params, timeout=timeout, proxies=proxies) + r = requests.delete(url, data=data, json=json, headers=headers, cookies=cookies, params=params, + timeout=timeout, proxies=proxies) except Exception as e: raise exceptions.FetchError(e) Requests.check_response(r) @@ -51,8 +58,10 @@ def delete(url, *, data=None, json=None, headers=None, cookies=None, timeout=Non @staticmethod def put(url, *, data=None, json=None, headers=None, cookies=None, timeout=None, params=None): try: - r = requests.put(url, data=data, json=json, headers=headers, cookies=cookies, params=params, timeout=timeout, proxies=proxies) + r = requests.put(url, data=data, json=json, headers=headers, cookies=cookies, params=params, + timeout=timeout, proxies=proxies) except Exception as e: raise exceptions.FetchError(e) Requests.check_response(r) - return r \ No newline at end of file + return r + diff --git a/setup.py b/setup.py index ed290bcc..cc2d7469 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import codecs import os -VERSION = '2.1.6' +VERSION = '2.1.9' DESCRIPTION = 'A Scratch API Wrapper' LONG_DESCRIPTION = DESCRIPTION @@ -18,7 +18,7 @@ packages=find_packages(), install_requires=["websocket-client","requests","bs4","SimpleWebSocketServer"], keywords=['scratch api', 'scratchattach', 'scratch api python', 'scratch python', 'scratch for python', 'scratch', 'scratch cloud', 'scratch cloud variables', 'scratch bot'], - url='https://github.com/TimMcCool/scratchattach', + url='https://scratchattach.tim1de.net', classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", diff --git a/website/source/index.html b/website/source/index.html index 651c1868..e32c7147 100644 --- a/website/source/index.html +++ b/website/source/index.html @@ -206,10 +206,9 @@

Getting started

Hosting

To run your Python code 24/7, you will need to host it.

-

We recommend ScratchHost for this purpose.
ScratchHost is a community-created hosting platform (not affiliated with scratchattach or the Scratch team).

+

In the wiki, you can find a list of recommended hosting services that can be used for this purpose.

- - +