From 3960e9cd73e1a1afba0fb974fc75b61ebb33263f Mon Sep 17 00:00:00 2001 From: Tim <53166177+TimMcCool@users.noreply.github.com> Date: Sat, 9 Nov 2024 14:12:51 +0100 Subject: [PATCH 001/101] fixed image in pypi info page --- README.md | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/setup.py b/setup.py index ed290bcc..3e3df4d7 100644 --- a/setup.py +++ b/setup.py @@ -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", From 645606af5c543e848f8fc095ef4da8f9c95ef564 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sat, 9 Nov 2024 20:02:02 +0000 Subject: [PATCH 002/101] Added create_studio and did a little with exceptions. - ConnectionError is a builtin so i renamed it, also i fixed a typo in cloud_requests.py --- scratchattach/cloud/_base.py | 4 +- scratchattach/eventhandlers/cloud_requests.py | 4 +- scratchattach/site/session.py | 45 ++++++++++++++++--- scratchattach/utils/exceptions.py | 23 +++++++--- scratchattach/utils/requests.py | 4 +- 5 files changed, 63 insertions(+), 17 deletions(-) diff --git a/scratchattach/cloud/_base.py b/scratchattach/cloud/_base.py index f3076ed7..fbbe323b 100644 --- a/scratchattach/cloud/_base.py +++ b/scratchattach/cloud/_base.py @@ -100,7 +100,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 +126,7 @@ 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} diff --git a/scratchattach/eventhandlers/cloud_requests.py b/scratchattach/eventhandlers/cloud_requests.py index d3fcfa9b..10121cd8 100644 --- a/scratchattach/eventhandlers/cloud_requests.py +++ b/scratchattach/eventhandlers/cloud_requests.py @@ -167,8 +167,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/site/session.py b/scratchattach/site/session.py index c13dc3a7..69a6d3d8 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -24,15 +24,16 @@ from ..eventhandlers import message_events, filterbot from . import activity from ._base import BaseSiteComponent -from ..utils.commons import headers, empty_project_json +from ..utils.commons import headers, empty_project_json, webscrape_count from bs4 import BeautifulSoup from ..other import project_json_capabilities from ..utils.requests import Requests as requests CREATE_PROJECT_USES = [] +CREATE_STUDIO_USES = [] -class Session(BaseSiteComponent): +class Session(BaseSiteComponent): ''' Represents a Scratch log in / session. Stores authentication data (session id and xtoken). @@ -164,7 +165,7 @@ def new_email_address(self) -> str | None: 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 @@ -402,10 +403,9 @@ def explore_studios(self, *, query="", mode="trending", language="en", limit=40, 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=None, project_json=empty_project_json, parent_id=None): # not working """ Creates a project on the Scratch website. @@ -436,6 +436,41 @@ def create_project(self, *, title=None, project_json=empty_project_json, parent_ 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=None, description: str = None): + """ + Create a project 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.\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 studios, it WILL get you banned.") + return + 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 + # --- My stuff page --- def mystuff_projects(self, filter_arg="all", *, page=1, sort_by="", descending=True): diff --git a/scratchattach/utils/exceptions.py b/scratchattach/utils/exceptions.py index 10b756e8..8167a2f2 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -18,7 +18,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 +28,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 +43,7 @@ class XTokenError(Exception): pass + # Not found errors: class UserNotFound(Exception): @@ -60,6 +61,7 @@ class ProjectNotFound(Exception): pass + class ClassroomNotFound(Exception): """ Raised when a non-existent Classroom is requested. @@ -75,15 +77,18 @@ class StudioNotFound(Exception): pass + class ForumContentNotFound(Exception): """ Raised when a non-existent forum topic / post is requested. """ pass + class CommentNotFound(Exception): pass + # API errors: class LoginFailure(Exception): @@ -95,6 +100,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 +108,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 +124,7 @@ class Response429(Exception): pass + class CommentPostFailure(Exception): """ Raised when a comment fails to post. This can have various reasons. @@ -124,12 +132,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 +147,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 +183,12 @@ 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 diff --git a/scratchattach/utils/requests.py b/scratchattach/utils/requests.py index 1c90a749..951bab42 100644 --- a/scratchattach/utils/requests.py +++ b/scratchattach/utils/requests.py @@ -9,9 +9,9 @@ class Requests: """ @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: From 07a69c2d33ec1dbd2e33691db81a5f9913077100 Mon Sep 17 00:00:00 2001 From: FA ReTek <107722825+FAReTek1@users.noreply.github.com> Date: Sat, 9 Nov 2024 21:28:37 +0000 Subject: [PATCH 003/101] typo Signed-off-by: FA ReTek <107722825+FAReTek1@users.noreply.github.com> --- scratchattach/site/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index 69a6d3d8..19220768 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -438,7 +438,7 @@ def create_project(self, *, title=None, project_json=empty_project_json, parent_ def create_studio(self, *, title=None, description: str = None): """ - Create a project on the scratch website + Create a studio on the scratch website Warning: Don't spam this method - it WILL get you banned from Scratch. From f9922af15a86f7d910ba45aa9fd0e5986235df76 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sat, 9 Nov 2024 21:53:50 +0000 Subject: [PATCH 004/101] A ton of UNTESTED docstrings --- scratchattach/cloud/_base.py | 86 ++-- scratchattach/cloud/cloud.py | 5 +- .../other/project_json_capabilities.py | 181 ++++---- scratchattach/site/session.py | 395 ++++++++++-------- scratchattach/utils/commons.py | 8 +- 5 files changed, 379 insertions(+), 296 deletions(-) diff --git a/scratchattach/cloud/_base.py b/scratchattach/cloud/_base.py index fbbe323b..debd9b67 100644 --- a/scratchattach/cloud/_base.py +++ b/scratchattach/cloud/_base.py @@ -1,60 +1,58 @@ -from abc import ABC, abstractmethod - -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): - - # Required internal attributes that every object representing a cloud needs to have (no matter what cloud is represented): + def __init__(self, **kwargs): + # Required internal attributes that every object representing a cloud needs to have + # (no matter what cloud is represented): self._session = None - self.active_connection = False #whether a connection to a cloud variable server is currently established + 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 +61,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" @@ -126,7 +125,8 @@ def _send_packet_list(self, packet_list): self.websocket.send(packet_string) except Exception: self.active_connection = False - raise exceptions.CloudConnectionError(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 +139,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 +166,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 +231,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 +256,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 +265,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 +273,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..a387fede 100644 --- a/scratchattach/cloud/cloud.py +++ b/scratchattach/cloud/cloud.py @@ -91,9 +91,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/other/project_json_capabilities.py b/scratchattach/other/project_json_capabilities.py index 06d65c29..fe5670e2 100644 --- a/scratchattach/other/project_json_capabilities.py +++ b/scratchattach/other/project_json_capabilities.py @@ -1,34 +1,38 @@ """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 + +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 +from ..utils.requests import Requests as requests -def load_components(json_data:list, ComponentClass, target_list): + +# 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 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 @@ -44,25 +48,26 @@ def _generate_new_id(self): """ 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): @@ -81,7 +86,7 @@ def previous_chain(self): 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): @@ -94,7 +99,7 @@ def attached_chain(self): 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 +107,7 @@ 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,8 +117,8 @@ 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 @@ -126,7 +131,7 @@ def _reattach(self, new_parent_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) @@ -159,7 +164,7 @@ 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) @@ -171,36 +176,36 @@ def inputs_as_blocks(self): 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: @@ -236,61 +241,71 @@ def to_json(self): 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 @@ -304,7 +319,7 @@ 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 @@ -317,15 +332,15 @@ def add_block_chain(self, block_chain, *, parent_id=None): 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 @@ -335,7 +350,7 @@ def to_json(self): return [self.name, self.saved_value, True] else: return [self.name, self.saved_value] - + def make_cloud_variable(self): self.is_cloud = True @@ -346,16 +361,16 @@ def __init__(self, **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): @@ -375,12 +390,12 @@ def target(self): 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") @@ -390,7 +405,7 @@ def to_json(self): 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) @@ -406,7 +421,7 @@ def download(self, *, filename=None, dir=""): ) ) - 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 @@ -414,7 +429,7 @@ def __init__(self, *, sprites=[], monitors=[], extensions=[], meta=[{"agent":Non 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,8 +438,8 @@ 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: @@ -449,16 +464,17 @@ def to_json(self): 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) @@ -470,16 +486,16 @@ def list_by_id(self, list_id): 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. @@ -496,16 +512,19 @@ def save(self, *, filename=None, dir=""): 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: @@ -520,19 +539,21 @@ def _load_sb3_file(path_to_file): 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 +564,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/session.py b/scratchattach/site/session.py index 69a6d3d8..bfb55697 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -1,32 +1,33 @@ """Session class and login function""" +import base64 +import hashlib import json -import re -import warnings import pathlib -import hashlib -import time import random -import base64 -import secrets +import re +import time +import warnings +# import secrets +# import zipfile from typing import Type -import zipfile -from . import forum - -from ..utils import commons +from bs4 import BeautifulSoup -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 . import classroom +from . import forum +from . import studio +from . import user, project, backpack_asset from ._base import BaseSiteComponent -from ..utils.commons import headers, empty_project_json, webscrape_count -from bs4 import BeautifulSoup +# noinspection PyProtectedMember +# Pycharm doesn't like that you are importing a protected member '_base' +from ..cloud import cloud, _base +from ..eventhandlers import message_events, filterbot from ..other import project_json_capabilities +from ..utils import commons +from ..utils import exceptions +from ..utils.commons import headers, empty_project_json, webscrape_count from ..utils.requests import Requests as requests CREATE_PROJECT_USES = [] @@ -34,31 +35,23 @@ 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: 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 - ''' + 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 + """ def __str__(self): return "Login for account: {self.username}" 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" @@ -69,23 +62,26 @@ def __init__(self, **entries): self.xtoken = None self.new_scratcher = None + # Set attributes that Session object may get + self._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 @@ -105,30 +101,40 @@ 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. + """ + 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. - ''' + scratchattach.user.User: Object representing the user associated with the session. + """ if not hasattr(self, "_user"): 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) @@ -144,10 +150,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 @@ -161,6 +169,7 @@ 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 ") @@ -168,13 +177,13 @@ def new_email_address(self) -> str | None: 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) -> 'activity.Activity': + """ Returns the messages. Keyword arguments: @@ -183,98 +192,100 @@ 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 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", @@ -282,14 +293,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", @@ -299,32 +311,38 @@ def in_followed_studios(self, *, limit=40, offset=0): # -- Project JSON editing capabilities --- + @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 + @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( @@ -345,62 +363,71 @@ 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): 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): 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 --- @@ -420,8 +447,8 @@ 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.\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.") CREATE_PROJECT_USES.insert(0, time.time()) if title is None: @@ -433,7 +460,8 @@ 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=None, description: str = None): @@ -451,8 +479,8 @@ def create_studio(self, *, title=None, description: str = None): if CREATE_STUDIO_USES[-1] < time.time() - 300: CREATE_STUDIO_USES.pop() else: - raise exceptions.BadRequest("Rate limit for creating Scratch studios 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 studios, it WILL get you banned.") - return + raise exceptions.BadRequest( + "Rate limit for creating Scratch studios 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 studios, it WILL get you banned.") CREATE_STUDIO_USES.insert(0, time.time()) if self.new_scratcher: @@ -497,19 +525,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"], @@ -519,7 +547,7 @@ 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): if descending: @@ -531,32 +559,31 @@ def mystuff_studios(self, filter_arg="all", *, page=1, sort_by="", descending=Tr 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, + 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) + raise (exceptions.FetchError) - - def backpack(self,limit=20, offset=0): + def backpack(self, limit=20, offset=0): ''' Lists the assets that are in the backpack of the user associated with the session. @@ -565,7 +592,7 @@ def backpack(self,limit=20, offset=0): ''' 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) @@ -578,24 +605,26 @@ def delete_from_backpack(self, backpack_asset_id): ''' return backpack_asset.BackpackAsset(id=backpack_asset_id, _session=self).delete() - def become_scratcher_invite(self): """ 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. + 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. @@ -609,28 +638,34 @@ def connect_scratch_cloud(self, project_id) -> 'cloud.ScratchCloud': """ 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): """ 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 @@ -642,7 +677,7 @@ 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): + def find_username_from_id(self, user_id: int): """ Warning: Every time this functions is run, a comment on your profile is posted and deleted. Therefore you shouldn't run this too often. @@ -654,7 +689,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: @@ -667,8 +703,7 @@ 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': + 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 @@ -686,7 +721,8 @@ 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': """ @@ -734,7 +770,8 @@ 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': """ @@ -749,7 +786,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): """ @@ -767,7 +803,8 @@ 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)) @@ -795,7 +832,10 @@ 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)) @@ -804,28 +844,36 @@ def connect_topic_list(self, category_id, *, page=1): 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': 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) @@ -833,21 +881,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. @@ -864,7 +920,7 @@ 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: @@ -873,7 +929,7 @@ def login(username, password, *, timeout=10) -> Session: _headers["Cookie"] = "scratchcsrftoken=a;scratchlanguage=en;" request = requests.post( "https://scratch.mit.edu/login/", data=data, headers=_headers, - timeout = timeout, + timeout=timeout, ) try: session_id = str(re.search('"(.*)"', request.headers["Set-Cookie"]).group()) @@ -886,11 +942,12 @@ def login(username, password, *, timeout=10) -> Session: def login_by_session_string(session_string) -> Session: - session_string = base64.b64decode(session_string).decode() # unobfuscate + 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/utils/commons.py b/scratchattach/utils/commons.py index a5ee3467..ceea75e5 100644 --- a/scratchattach/utils/commons.py +++ b/scratchattach/utils/commons.py @@ -81,6 +81,7 @@ def api_iterative_data(fetch_func, limit, offset, max_req_limit=40, unpack=True) api_data = api_data[:limit] return api_data + def api_iterative( url, *, limit, offset, max_req_limit=40, add_params="", headers=headers, cookies={} ): @@ -92,12 +93,12 @@ def api_iterative( if limit < 0: raise exceptions.BadRequest("limit parameter must be >= 0") - def fetch(o, l): + def fetch(off, lim): """ - 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 @@ -110,6 +111,7 @@ 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. # Builds an object of class that is inheriting from BaseSiteComponent From 92344edb9c2cf286db3f863772ec931b42623a48 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sat, 9 Nov 2024 22:20:09 +0000 Subject: [PATCH 005/101] More UNTESTED type hints --- scratchattach/site/session.py | 81 ++++++++++++++++++++-------------- scratchattach/site/user.py | 6 +-- scratchattach/utils/commons.py | 41 ++++++++++------- 3 files changed, 76 insertions(+), 52 deletions(-) diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index ef4e217d..b5c4685e 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -201,7 +201,7 @@ def messages(self, *, limit: int = 40, offset: int = 0, date_limit=None, filter_ 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) @@ -211,7 +211,7 @@ def admin_messages(self, *, limit=40, offset=0) -> list[dict]: """ 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 clear_messages(self): @@ -253,7 +253,7 @@ def feed(self, *, limit=20, offset=0, date_limit=None) -> list['activity.Activit 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) @@ -271,7 +271,7 @@ def loved_by_followed_users(self, *, limit=40, offset=0) -> list['project.Projec """ 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) @@ -414,7 +414,8 @@ def explore_projects(self, *, query: str = "*", mode: str = "trending", language 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( @@ -422,7 +423,8 @@ def search_studios(self, *, query="", mode="trending", language="en", limit=40, 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( @@ -432,7 +434,8 @@ def explore_studios(self, *, query="", mode="trending", language="en", limit=40, # --- 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. @@ -448,7 +451,10 @@ def create_project(self, *, title=None, project_json=empty_project_json, parent_ 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.") + "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: @@ -480,7 +486,10 @@ def create_studio(self, *, title=None, description: str = None): CREATE_STUDIO_USES.pop() else: raise exceptions.BadRequest( - "Rate limit for creating Scratch studios 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 studios, it WILL get you banned.") + "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: @@ -501,8 +510,9 @@ def create_studio(self, *, title=None, description: str = None): # --- 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: @@ -515,7 +525,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 @@ -547,9 +557,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 @@ -558,7 +569,8 @@ 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}", + f"https://scratch.mit.edu/site-api/galleries/{filter_arg}/" + f"?page={page}&ascsort={ascsort}&descsort={descsort}", headers=headers, cookies=self._cookies, timeout=10, @@ -581,33 +593,34 @@ def mystuff_studios(self, filter_arg="all", *, page=1, sort_by="", descending=Tr )) return studios except Exception: - raise (exceptions.FetchError) + raise exceptions.FetchError() - def backpack(self, limit=20, offset=0): - ''' + def backpack(self, limit: int = 20, offset: int = 0) -> list[dict]: + """ 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 - ''' + """ 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() @@ -615,19 +628,19 @@ def become_scratcher_invite(self): # --- Connect classes inheriting from BaseCloud --- # noinspection PyPep8Naming - def connect_cloud(self, project_id, *, CloudClass: Type[_base.BaseCloud] = cloud.ScratchCloud) -> Type[ - _base.BaseCloud]: + 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. 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) @@ -652,7 +665,7 @@ def connect_tw_cloud(self, project_id, *, purpose="", contact="", # noinspection PyPep8Naming # Class is camelcase here def _make_linked_object(self, identificator_name, identificator, Class: BaseSiteComponent, - NotFoundException: Exception): + NotFoundException: Exception) -> BaseSiteComponent: """ The Session class doesn't save the login in a ._session attribute, but IS the login ITSELF. @@ -811,7 +824,7 @@ def connect_topic_list(self, category_id, *, page=1): try: category_name = soup.find('h4').find("span").get_text() - except Exception as e: + except Exception: raise exceptions.BadRequest("Invalid category id") try: @@ -941,7 +954,7 @@ 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: +def login_by_session_string(session_string: str) -> Session: session_string = base64.b64decode(session_string).decode() # unobfuscate session_data = json.loads(session_string) try: diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index 15b4849d..b8adc2b2 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -269,7 +269,7 @@ def projects(self, *, limit=40, offset=0): 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,7 +420,7 @@ 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_bio(self, text): diff --git a/scratchattach/utils/commons.py b/scratchattach/utils/commons.py index ceea75e5..b56b99b2 100644 --- a/scratchattach/utils/commons.py +++ b/scratchattach/utils/commons.py @@ -1,15 +1,15 @@ """v2 ready: Common functions used by various internal modules""" 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", + "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 +} # headers recommended for accessing API endpoints that don't require verification empty_project_json = { 'targets': [ @@ -52,7 +52,8 @@ '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', }, } @@ -83,22 +84,27 @@ def api_iterative_data(fetch_func, limit, offset, max_req_limit=40, unpack=True) def api_iterative( - url, *, limit, offset, max_req_limit=40, add_params="", headers=headers, cookies={} + url, *, limit, offset, max_req_limit=40, add_params="", _headers=None, cookies=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 + 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(off, lim): """ Performs a single API request """ resp = requests.get( - f"{url}?limit={lim}&offset={off}{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 @@ -117,34 +123,39 @@ def _get_object(identificator_name, identificator, Class, NotFoundException, ses # 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("Key error at key " + str(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 parse_object_list(raw, Class, session=None, primary_key="id"): 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 From 4e23734651b3cc97b4e4511794b9ea5d151b8cb6 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sat, 9 Nov 2024 22:35:40 +0000 Subject: [PATCH 006/101] More UNTESTED type hints --- scratchattach/utils/commons.py | 47 +++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/scratchattach/utils/commons.py b/scratchattach/utils/commons.py index b56b99b2..4aeb74f2 100644 --- a/scratchattach/utils/commons.py +++ b/scratchattach/utils/commons.py @@ -1,9 +1,11 @@ """v2 ready: Common functions used by various internal modules""" +from types import FunctionType +from typing import Final, Any from . import exceptions from .requests import Requests as requests -headers = { +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", @@ -11,7 +13,7 @@ "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, @@ -58,39 +60,42 @@ } -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=None, cookies=None -): +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 + _headers = headers.copy() if cookies is None: cookies = {} @@ -99,7 +104,7 @@ def api_iterative( if limit < 0: raise exceptions.BadRequest("limit parameter must be >= 0") - def fetch(off, lim): + def fetch(off: int, lim: int): """ Performs a single API request """ @@ -109,7 +114,7 @@ def fetch(off, lim): 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( @@ -119,7 +124,7 @@ def fetch(off, lim): def _get_object(identificator_name, identificator, Class, NotFoundException, session=None): - # Interal function: Generalization of the process ran by get_user, get_studio etc. + # 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: @@ -140,16 +145,16 @@ def _get_object(identificator_name, identificator, Class, NotFoundException, ses 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 -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: results = [] for raw_dict in raw: try: From 8a53174a2d14cc9329dd184176a6ac0a29ddd4ea Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sat, 9 Nov 2024 22:54:01 +0000 Subject: [PATCH 007/101] More UNTESTED type hints --- scratchattach/site/_base.py | 18 +++++++++++++++--- scratchattach/site/forum.py | 2 +- scratchattach/utils/commons.py | 10 +++++++--- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/scratchattach/site/_base.py b/scratchattach/site/_base.py index 29514653..010265ea 100644 --- a/scratchattach/site/_base.py +++ b/scratchattach/site/_base.py @@ -1,9 +1,17 @@ from abc import ABC, abstractmethod + import requests -from threading import Thread +# from threading import Thread from ..utils import exceptions, commons + class BaseSiteComponent(ABC): + @abstractmethod + def __init__(self): + self._session = None + self._cookies = None + self._headers = None + self.update_API = None def update(self): """ @@ -11,8 +19,8 @@ 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: if "429" in str(response): @@ -44,3 +52,7 @@ def _make_linked_object(self, identificator_id, identificator, Class, NotFoundEx """ return commons._get_object(identificator_id, identificator, Class, NotFoundException, self._session) + update_function = requests.get + """ + Internal function run on update. Function is a method of the 'requests' module/class + """ diff --git a/scratchattach/site/forum.py b/scratchattach/site/forum.py index 510b10a8..2f7cde56 100644 --- a/scratchattach/site/forum.py +++ b/scratchattach/site/forum.py @@ -32,8 +32,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/utils/commons.py b/scratchattach/utils/commons.py index 4aeb74f2..f1cb2f49 100644 --- a/scratchattach/utils/commons.py +++ b/scratchattach/utils/commons.py @@ -1,10 +1,14 @@ """v2 ready: Common functions used by various internal modules""" from types import FunctionType -from typing import Final, Any +from typing import Final, Any, TYPE_CHECKING from . import exceptions from .requests import Requests as requests +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", @@ -60,7 +64,7 @@ } -def api_iterative_data(fetch_func: 'FunctionType', limit: int, offset: int, max_req_limit: int = 40, +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. @@ -123,7 +127,7 @@ def fetch(off: int, lim: int): return api_data -def _get_object(identificator_name, identificator, Class, NotFoundException, session=None): +def _get_object(identificator_name, identificator, Class, 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 From 49c25bb8ae2734c3692a7d072ce4836b6800db69 Mon Sep 17 00:00:00 2001 From: Jonathan <115192551+The-Arx@users.noreply.github.com> Date: Tue, 12 Nov 2024 12:00:54 -0800 Subject: [PATCH 008/101] Fix typo in cloud_server.py Signed-off-by: Jonathan <115192551+The-Arx@users.noreply.github.com> --- scratchattach/eventhandlers/cloud_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 60e66ed3..1b545c40 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -223,7 +223,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 +241,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) From 6cb631f17eb1e7568f851b8862a039643bee3c09 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Tue, 12 Nov 2024 22:27:17 +0000 Subject: [PATCH 009/101] Added (smart) translation api --- scratchattach/other/other_apis.py | 54 +++++++++++++++++++++++++-- scratchattach/utils/supportedlangs.py | 29 ++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 scratchattach/utils/supportedlangs.py diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index df8d4235..973feaa5 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -1,38 +1,52 @@ """Other Scratch API-related functions""" +import json +import warnings + from ..utils import commons +from ..utils.exceptions import BadRequest from ..utils.requests import Requests as requests -import json +from ..utils.supportedlangs import SUPPORTED_CODES, SUPPORTED_NAMES + # --- 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 +54,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 +72,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 +102,41 @@ 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() + # --- Misc --- # I'm not sure what to label this as def scratch_team_members() -> dict: @@ -115,3 +146,20 @@ def scratch_team_members() -> dict: text = text.split("\"}]')")[0] + "\"}]" return json.loads(text) + + +def translate(language: str, text: str = "hello"): + if language not in SUPPORTED_CODES: + if language.lower() in SUPPORTED_CODES: + language = language.lower() + elif language.title() in SUPPORTED_NAMES: + language = SUPPORTED_CODES[SUPPORTED_NAMES.index(language.title())] + else: + warnings.warn(f"'{language}' is probably not a supported language") + response_json = requests.get( + f"https://translate-service.scratch.mit.edu/translate?language={language}&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}") diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py new file mode 100644 index 00000000..0f3e7f38 --- /dev/null +++ b/scratchattach/utils/supportedlangs.py @@ -0,0 +1,29 @@ +""" +List of supported languages of scratch's translate extension. +Adapted from https://translate-service.scratch.mit.edu/supported?language=en +""" + +SUPPORTED_LANGS = {'sq': 'Albanian', 'am': 'Amharic', 'ar': 'Arabic', 'hy': 'Armenian', 'az': 'Azerbaijani', + 'eu': 'Basque', 'be': 'Belarusian', 'bg': 'Bulgarian', 'ca': 'Catalan', + 'zh-tw': 'Chinese (Traditional)', 'hr': 'Croatian', 'cs': 'Czech', 'da': 'Danish', 'nl': 'Dutch', + 'en': 'English', 'eo': 'Esperanto', 'et': 'Estonian', 'fi': 'Finnish', 'fr': 'French', + 'gl': 'Galician', 'de': 'German', 'el': 'Greek', 'ht': 'Haitian Creole', 'hi': 'Hindi', + 'hu': 'Hungarian', 'is': 'Icelandic', 'id': 'Indonesian', 'ga': 'Irish', 'it': 'Italian', + 'ja': 'Japanese', 'kn': 'Kannada', 'ko': 'Korean', 'ku': 'Kurdish (Kurmanji)', 'la': 'Latin', + 'lv': 'Latvian', 'lt': 'Lithuanian', 'mk': 'Macedonian', 'ms': 'Malay', 'ml': 'Malayalam', + 'mt': 'Maltese', 'mi': 'Maori', 'mr': 'Marathi', 'mn': 'Mongolian', 'my': 'Myanmar (Burmese)', + 'fa': 'Persian', 'pl': 'Polish', 'pt': 'Portuguese', 'ro': 'Romanian', 'ru': 'Russian', + 'gd': 'Scots Gaelic', 'sr': 'Serbian', 'sk': 'Slovak', 'sl': 'Slovenian', 'es': 'Spanish', + 'sv': 'Swedish', 'te': 'Telugu', 'th': 'Thai', 'tr': 'Turkish', 'uk': 'Ukrainian', 'uz': 'Uzbek', + 'vi': 'Vietnamese', 'cy': 'Welsh', 'zu': 'Zulu', 'he': 'Hebrew', 'zh-cn': 'Chinese (Simplified)'} +SUPPORTED_CODES = tuple(SUPPORTED_LANGS.keys()) +SUPPORTED_NAMES = tuple(SUPPORTED_LANGS.values()) + +# Code for generating the dict again: +# import requests +# +# SUPPORTED_LANGS = {} +# raw = requests.get("https://translate-service.scratch.mit.edu/supported").json() +# for lang in raw: +# SUPPORTED_LANGS[lang["code"]] = lang["name"] +# print(SUPPORTED_LANGS) From 9ca8008f71785c9f4c7a26fdf51eb3f8662f3099 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Tue, 12 Nov 2024 22:27:17 +0000 Subject: [PATCH 010/101] Added (smart) translation api (cherry picked from commit 6cb631f17eb1e7568f851b8862a039643bee3c09) --- scratchattach/other/other_apis.py | 54 +++++++++++++++++++++++++-- scratchattach/utils/supportedlangs.py | 29 ++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 scratchattach/utils/supportedlangs.py diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index df8d4235..973feaa5 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -1,38 +1,52 @@ """Other Scratch API-related functions""" +import json +import warnings + from ..utils import commons +from ..utils.exceptions import BadRequest from ..utils.requests import Requests as requests -import json +from ..utils.supportedlangs import SUPPORTED_CODES, SUPPORTED_NAMES + # --- 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 +54,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 +72,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 +102,41 @@ 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() + # --- Misc --- # I'm not sure what to label this as def scratch_team_members() -> dict: @@ -115,3 +146,20 @@ def scratch_team_members() -> dict: text = text.split("\"}]')")[0] + "\"}]" return json.loads(text) + + +def translate(language: str, text: str = "hello"): + if language not in SUPPORTED_CODES: + if language.lower() in SUPPORTED_CODES: + language = language.lower() + elif language.title() in SUPPORTED_NAMES: + language = SUPPORTED_CODES[SUPPORTED_NAMES.index(language.title())] + else: + warnings.warn(f"'{language}' is probably not a supported language") + response_json = requests.get( + f"https://translate-service.scratch.mit.edu/translate?language={language}&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}") diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py new file mode 100644 index 00000000..0f3e7f38 --- /dev/null +++ b/scratchattach/utils/supportedlangs.py @@ -0,0 +1,29 @@ +""" +List of supported languages of scratch's translate extension. +Adapted from https://translate-service.scratch.mit.edu/supported?language=en +""" + +SUPPORTED_LANGS = {'sq': 'Albanian', 'am': 'Amharic', 'ar': 'Arabic', 'hy': 'Armenian', 'az': 'Azerbaijani', + 'eu': 'Basque', 'be': 'Belarusian', 'bg': 'Bulgarian', 'ca': 'Catalan', + 'zh-tw': 'Chinese (Traditional)', 'hr': 'Croatian', 'cs': 'Czech', 'da': 'Danish', 'nl': 'Dutch', + 'en': 'English', 'eo': 'Esperanto', 'et': 'Estonian', 'fi': 'Finnish', 'fr': 'French', + 'gl': 'Galician', 'de': 'German', 'el': 'Greek', 'ht': 'Haitian Creole', 'hi': 'Hindi', + 'hu': 'Hungarian', 'is': 'Icelandic', 'id': 'Indonesian', 'ga': 'Irish', 'it': 'Italian', + 'ja': 'Japanese', 'kn': 'Kannada', 'ko': 'Korean', 'ku': 'Kurdish (Kurmanji)', 'la': 'Latin', + 'lv': 'Latvian', 'lt': 'Lithuanian', 'mk': 'Macedonian', 'ms': 'Malay', 'ml': 'Malayalam', + 'mt': 'Maltese', 'mi': 'Maori', 'mr': 'Marathi', 'mn': 'Mongolian', 'my': 'Myanmar (Burmese)', + 'fa': 'Persian', 'pl': 'Polish', 'pt': 'Portuguese', 'ro': 'Romanian', 'ru': 'Russian', + 'gd': 'Scots Gaelic', 'sr': 'Serbian', 'sk': 'Slovak', 'sl': 'Slovenian', 'es': 'Spanish', + 'sv': 'Swedish', 'te': 'Telugu', 'th': 'Thai', 'tr': 'Turkish', 'uk': 'Ukrainian', 'uz': 'Uzbek', + 'vi': 'Vietnamese', 'cy': 'Welsh', 'zu': 'Zulu', 'he': 'Hebrew', 'zh-cn': 'Chinese (Simplified)'} +SUPPORTED_CODES = tuple(SUPPORTED_LANGS.keys()) +SUPPORTED_NAMES = tuple(SUPPORTED_LANGS.values()) + +# Code for generating the dict again: +# import requests +# +# SUPPORTED_LANGS = {} +# raw = requests.get("https://translate-service.scratch.mit.edu/supported").json() +# for lang in raw: +# SUPPORTED_LANGS[lang["code"]] = lang["name"] +# print(SUPPORTED_LANGS) From f3ce6a27098fc4c4acaf4db79e8d0273e75c90f9 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 13 Nov 2024 17:53:04 +0000 Subject: [PATCH 011/101] Added tts api --- scratchattach/other/other_apis.py | 40 +++++++- scratchattach/utils/supportedlangs.py | 141 +++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 2 deletions(-) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 973feaa5..25e0c174 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -6,7 +6,7 @@ from ..utils import commons from ..utils.exceptions import BadRequest from ..utils.requests import Requests as requests -from ..utils.supportedlangs import SUPPORTED_CODES, SUPPORTED_NAMES +from ..utils.supportedlangs import SUPPORTED_CODES, SUPPORTED_NAMES, tts_lang # --- Front page --- @@ -163,3 +163,41 @@ def translate(language: str, text: str = "hello"): return response_json["result"] else: raise BadRequest(f"Language '{language}' does not seem to be valid.\nResponse: {response_json}") + + +def text2speech(text: str = "hello", gender: 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 gender == "female" or gender == "alto": + gender = ("female", 1) + elif gender == "male" or gender == "tenor": + gender = ("male", 1) + elif gender == "squeak": + gender = ("female", 1.19) + elif gender == "giant": + gender = ("male", .84) + elif gender == "kitten": + gender = ("female", 1.41) + else: + gender = ("female", 1) + + if language not in SUPPORTED_NAMES: + if language.lower() in SUPPORTED_NAMES: + language = language.lower() + + elif language.title() in SUPPORTED_CODES: + language = SUPPORTED_NAMES[SUPPORTED_CODES.index(language.title())] + + lang = tts_lang(language.title()) + if lang is None: + warnings.warn(f"Language '{language}' is probably not a supported language") + else: + language = lang["speechSynthLocale"] + + response = requests.get(f"https://synthesis-service.scratch.mit.edu/synth" + f"?locale={language}&gender={gender[0]}&text={text}") + return response.content, gender[1] diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index 0f3e7f38..cdb2ed42 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -1,8 +1,9 @@ """ -List of supported languages of scratch's translate extension. +List of supported languages of scratch's translate and text2speech extensions. Adapted from https://translate-service.scratch.mit.edu/supported?language=en """ +# Supported langs for translate SUPPORTED_LANGS = {'sq': 'Albanian', 'am': 'Amharic', 'ar': 'Arabic', 'hy': 'Armenian', 'az': 'Azerbaijani', 'eu': 'Basque', 'be': 'Belarusian', 'bg': 'Bulgarian', 'ca': 'Catalan', 'zh-tw': 'Chinese (Traditional)', 'hr': 'Croatian', 'cs': 'Czech', 'da': 'Danish', 'nl': 'Dutch', @@ -27,3 +28,141 @@ # for lang in raw: # SUPPORTED_LANGS[lang["code"]] = lang["name"] # print(SUPPORTED_LANGS) + +# Language info for tts +TTS_LANGUAGE_INFO = [ + { + "name": 'Arabic', + "locales": ['ar'], + "speechSynthLocale": 'arb', + "singleGender": True + }, + { + "name": 'Chinese (Mandarin)', + "locales": ['zh-cn', 'zh-tw'], + "speechSynthLocale": 'cmn-CN', + "singleGender": True + }, + { + "name": 'Danish', + "locales": ['da'], + "speechSynthLocale": 'da-DK' + }, + { + "name": 'Dutch', + "locales": ['nl'], + "speechSynthLocale": 'nl-NL' + }, + { + "name": 'English', + "locales": ['en'], + "speechSynthLocale": 'en-US' + }, + { + "name": 'French', + "locales": ['fr'], + "speechSynthLocale": 'fr-FR' + }, + { + "name": 'German', + "locales": ['de'], + "speechSynthLocale": 'de-DE' + }, + { + "name": 'Hindi', + "locales": ['hi'], + "speechSynthLocale": 'hi-IN', + "singleGender": True + }, + { + "name": 'Icelandic', + "locales": ['is'], + "speechSynthLocale": 'is-IS' + }, + { + "name": 'Italian', + "locales": ['it'], + "speechSynthLocale": 'it-IT' + }, + { + "name": 'Japanese', + "locales": ['ja', 'ja-hira'], + "speechSynthLocale": 'ja-JP' + }, + { + "name": 'Korean', + "locales": ['ko'], + "speechSynthLocale": 'ko-KR', + "singleGender": True + }, + { + "name": 'Norwegian', + "locales": ['nb', 'nn'], + "speechSynthLocale": 'nb-NO', + "singleGender": True + }, + { + "name": 'Polish', + "locales": ['pl'], + "speechSynthLocale": 'pl-PL' + }, + { + "name": 'Portuguese (Brazilian)', + "locales": ['pt-br'], + "speechSynthLocale": 'pt-BR' + }, + { + "name": 'Portuguese (European)', + "locales": ['pt'], + "speechSynthLocale": 'pt-PT' + }, + { + "name": 'Romanian', + "locales": ['ro'], + "speechSynthLocale": 'ro-RO', + "singleGender": True + }, + { + "name": 'Russian', + "locales": ['ru'], + "speechSynthLocale": 'ru-RU' + }, + { + "name": 'Spanish (European)', + "locales": ['es'], + "speechSynthLocale": 'es-ES' + }, + { + "name": 'Spanish (Latin American)', + "locales": ['es-419'], + "speechSynthLocale": 'es-US' + }, + { + "name": 'Swedish', + "locales": ['sv'], + "speechSynthLocale": 'sv-SE', + "singleGender": True + }, + { + "name": 'Turkish', + "locales": ['tr'], + "speechSynthLocale": 'tr-TR', + "singleGender": True + }, + { + "name": 'Welsh', + "locales": ['cy'], + "speechSynthLocale": 'cy-GB', + "singleGender": True + }] + + +def tts_lang(attribute: str, by: str = None): + for lang in TTS_LANGUAGE_INFO: + if by is None: + if attribute in lang.values(): + return lang + continue + + if lang.get(by) == attribute: + return lang From b7a3249f15ead77a7e5c1351eb6e2ddf4367fcae Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 13 Nov 2024 17:53:04 +0000 Subject: [PATCH 012/101] Added tts api (cherry picked from commit f3ce6a27098fc4c4acaf4db79e8d0273e75c90f9) --- scratchattach/other/other_apis.py | 40 +++++++- scratchattach/utils/supportedlangs.py | 141 +++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 2 deletions(-) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 973feaa5..25e0c174 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -6,7 +6,7 @@ from ..utils import commons from ..utils.exceptions import BadRequest from ..utils.requests import Requests as requests -from ..utils.supportedlangs import SUPPORTED_CODES, SUPPORTED_NAMES +from ..utils.supportedlangs import SUPPORTED_CODES, SUPPORTED_NAMES, tts_lang # --- Front page --- @@ -163,3 +163,41 @@ def translate(language: str, text: str = "hello"): return response_json["result"] else: raise BadRequest(f"Language '{language}' does not seem to be valid.\nResponse: {response_json}") + + +def text2speech(text: str = "hello", gender: 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 gender == "female" or gender == "alto": + gender = ("female", 1) + elif gender == "male" or gender == "tenor": + gender = ("male", 1) + elif gender == "squeak": + gender = ("female", 1.19) + elif gender == "giant": + gender = ("male", .84) + elif gender == "kitten": + gender = ("female", 1.41) + else: + gender = ("female", 1) + + if language not in SUPPORTED_NAMES: + if language.lower() in SUPPORTED_NAMES: + language = language.lower() + + elif language.title() in SUPPORTED_CODES: + language = SUPPORTED_NAMES[SUPPORTED_CODES.index(language.title())] + + lang = tts_lang(language.title()) + if lang is None: + warnings.warn(f"Language '{language}' is probably not a supported language") + else: + language = lang["speechSynthLocale"] + + response = requests.get(f"https://synthesis-service.scratch.mit.edu/synth" + f"?locale={language}&gender={gender[0]}&text={text}") + return response.content, gender[1] diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index 0f3e7f38..cdb2ed42 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -1,8 +1,9 @@ """ -List of supported languages of scratch's translate extension. +List of supported languages of scratch's translate and text2speech extensions. Adapted from https://translate-service.scratch.mit.edu/supported?language=en """ +# Supported langs for translate SUPPORTED_LANGS = {'sq': 'Albanian', 'am': 'Amharic', 'ar': 'Arabic', 'hy': 'Armenian', 'az': 'Azerbaijani', 'eu': 'Basque', 'be': 'Belarusian', 'bg': 'Bulgarian', 'ca': 'Catalan', 'zh-tw': 'Chinese (Traditional)', 'hr': 'Croatian', 'cs': 'Czech', 'da': 'Danish', 'nl': 'Dutch', @@ -27,3 +28,141 @@ # for lang in raw: # SUPPORTED_LANGS[lang["code"]] = lang["name"] # print(SUPPORTED_LANGS) + +# Language info for tts +TTS_LANGUAGE_INFO = [ + { + "name": 'Arabic', + "locales": ['ar'], + "speechSynthLocale": 'arb', + "singleGender": True + }, + { + "name": 'Chinese (Mandarin)', + "locales": ['zh-cn', 'zh-tw'], + "speechSynthLocale": 'cmn-CN', + "singleGender": True + }, + { + "name": 'Danish', + "locales": ['da'], + "speechSynthLocale": 'da-DK' + }, + { + "name": 'Dutch', + "locales": ['nl'], + "speechSynthLocale": 'nl-NL' + }, + { + "name": 'English', + "locales": ['en'], + "speechSynthLocale": 'en-US' + }, + { + "name": 'French', + "locales": ['fr'], + "speechSynthLocale": 'fr-FR' + }, + { + "name": 'German', + "locales": ['de'], + "speechSynthLocale": 'de-DE' + }, + { + "name": 'Hindi', + "locales": ['hi'], + "speechSynthLocale": 'hi-IN', + "singleGender": True + }, + { + "name": 'Icelandic', + "locales": ['is'], + "speechSynthLocale": 'is-IS' + }, + { + "name": 'Italian', + "locales": ['it'], + "speechSynthLocale": 'it-IT' + }, + { + "name": 'Japanese', + "locales": ['ja', 'ja-hira'], + "speechSynthLocale": 'ja-JP' + }, + { + "name": 'Korean', + "locales": ['ko'], + "speechSynthLocale": 'ko-KR', + "singleGender": True + }, + { + "name": 'Norwegian', + "locales": ['nb', 'nn'], + "speechSynthLocale": 'nb-NO', + "singleGender": True + }, + { + "name": 'Polish', + "locales": ['pl'], + "speechSynthLocale": 'pl-PL' + }, + { + "name": 'Portuguese (Brazilian)', + "locales": ['pt-br'], + "speechSynthLocale": 'pt-BR' + }, + { + "name": 'Portuguese (European)', + "locales": ['pt'], + "speechSynthLocale": 'pt-PT' + }, + { + "name": 'Romanian', + "locales": ['ro'], + "speechSynthLocale": 'ro-RO', + "singleGender": True + }, + { + "name": 'Russian', + "locales": ['ru'], + "speechSynthLocale": 'ru-RU' + }, + { + "name": 'Spanish (European)', + "locales": ['es'], + "speechSynthLocale": 'es-ES' + }, + { + "name": 'Spanish (Latin American)', + "locales": ['es-419'], + "speechSynthLocale": 'es-US' + }, + { + "name": 'Swedish', + "locales": ['sv'], + "speechSynthLocale": 'sv-SE', + "singleGender": True + }, + { + "name": 'Turkish', + "locales": ['tr'], + "speechSynthLocale": 'tr-TR', + "singleGender": True + }, + { + "name": 'Welsh', + "locales": ['cy'], + "speechSynthLocale": 'cy-GB', + "singleGender": True + }] + + +def tts_lang(attribute: str, by: str = None): + for lang in TTS_LANGUAGE_INFO: + if by is None: + if attribute in lang.values(): + return lang + continue + + if lang.get(by) == attribute: + return lang From 2b93c185c3293be700b4e3311b9f13f407134157 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 13 Nov 2024 18:09:08 +0000 Subject: [PATCH 013/101] added an enum i guess? --- scratchattach/__init__.py | 1 + scratchattach/other/other_apis.py | 18 ++--- scratchattach/utils/supportedlangs.py | 108 ++++++++++++++++++++++---- 3 files changed, 103 insertions(+), 24 deletions(-) diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index 3e96d7b4..8c3bd392 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.supportedlangs import TranslateSupportedLangs as TSLangs from .site.activity import Activity from .site.backpack_asset import BackpackAsset diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 25e0c174..89180696 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -6,7 +6,7 @@ from ..utils import commons from ..utils.exceptions import BadRequest from ..utils.requests import Requests as requests -from ..utils.supportedlangs import SUPPORTED_CODES, SUPPORTED_NAMES, tts_lang +from ..utils.supportedlangs import tts_lang, TSL_CODES, TSL_NAMES # --- Front page --- @@ -149,11 +149,11 @@ def scratch_team_members() -> dict: def translate(language: str, text: str = "hello"): - if language not in SUPPORTED_CODES: - if language.lower() in SUPPORTED_CODES: + if language not in TSL_CODES: + if language.lower() in TSL_CODES: language = language.lower() - elif language.title() in SUPPORTED_NAMES: - language = SUPPORTED_CODES[SUPPORTED_NAMES.index(language.title())] + elif language.title() in TSL_NAMES: + language = TSL_CODES[TSL_NAMES.index(language.title())] else: warnings.warn(f"'{language}' is probably not a supported language") response_json = requests.get( @@ -185,12 +185,12 @@ def text2speech(text: str = "hello", gender: str = "female", language: str = "en else: gender = ("female", 1) - if language not in SUPPORTED_NAMES: - if language.lower() in SUPPORTED_NAMES: + if language not in TSL_NAMES: + if language.lower() in TSL_NAMES: language = language.lower() - elif language.title() in SUPPORTED_CODES: - language = SUPPORTED_NAMES[SUPPORTED_CODES.index(language.title())] + elif language.title() in TSL_CODES: + language = TSL_NAMES[TSL_CODES.index(language.title())] lang = tts_lang(language.title()) if lang is None: diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index cdb2ed42..b4a7fe26 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -2,23 +2,101 @@ List of supported languages of scratch's translate and text2speech extensions. Adapted from https://translate-service.scratch.mit.edu/supported?language=en """ +from enum import Enum # Supported langs for translate -SUPPORTED_LANGS = {'sq': 'Albanian', 'am': 'Amharic', 'ar': 'Arabic', 'hy': 'Armenian', 'az': 'Azerbaijani', - 'eu': 'Basque', 'be': 'Belarusian', 'bg': 'Bulgarian', 'ca': 'Catalan', - 'zh-tw': 'Chinese (Traditional)', 'hr': 'Croatian', 'cs': 'Czech', 'da': 'Danish', 'nl': 'Dutch', - 'en': 'English', 'eo': 'Esperanto', 'et': 'Estonian', 'fi': 'Finnish', 'fr': 'French', - 'gl': 'Galician', 'de': 'German', 'el': 'Greek', 'ht': 'Haitian Creole', 'hi': 'Hindi', - 'hu': 'Hungarian', 'is': 'Icelandic', 'id': 'Indonesian', 'ga': 'Irish', 'it': 'Italian', - 'ja': 'Japanese', 'kn': 'Kannada', 'ko': 'Korean', 'ku': 'Kurdish (Kurmanji)', 'la': 'Latin', - 'lv': 'Latvian', 'lt': 'Lithuanian', 'mk': 'Macedonian', 'ms': 'Malay', 'ml': 'Malayalam', - 'mt': 'Maltese', 'mi': 'Maori', 'mr': 'Marathi', 'mn': 'Mongolian', 'my': 'Myanmar (Burmese)', - 'fa': 'Persian', 'pl': 'Polish', 'pt': 'Portuguese', 'ro': 'Romanian', 'ru': 'Russian', - 'gd': 'Scots Gaelic', 'sr': 'Serbian', 'sk': 'Slovak', 'sl': 'Slovenian', 'es': 'Spanish', - 'sv': 'Swedish', 'te': 'Telugu', 'th': 'Thai', 'tr': 'Turkish', 'uk': 'Ukrainian', 'uz': 'Uzbek', - 'vi': 'Vietnamese', 'cy': 'Welsh', 'zu': 'Zulu', 'he': 'Hebrew', 'zh-cn': 'Chinese (Simplified)'} -SUPPORTED_CODES = tuple(SUPPORTED_LANGS.keys()) -SUPPORTED_NAMES = tuple(SUPPORTED_LANGS.values()) +_TRANSLATE_SUPPORTED_LANGS = {'sq': 'Albanian', 'am': 'Amharic', 'ar': 'Arabic', 'hy': 'Armenian', 'az': 'Azerbaijani', + 'eu': 'Basque', 'be': 'Belarusian', 'bg': 'Bulgarian', 'ca': 'Catalan', + 'zh-tw': 'Chinese (Traditional)', 'hr': 'Croatian', 'cs': 'Czech', 'da': 'Danish', + 'nl': 'Dutch', + 'en': 'English', 'eo': 'Esperanto', 'et': 'Estonian', 'fi': 'Finnish', 'fr': 'French', + 'gl': 'Galician', 'de': 'German', 'el': 'Greek', 'ht': 'Haitian Creole', 'hi': 'Hindi', + 'hu': 'Hungarian', 'is': 'Icelandic', 'id': 'Indonesian', 'ga': 'Irish', 'it': 'Italian', + 'ja': 'Japanese', 'kn': 'Kannada', 'ko': 'Korean', 'ku': 'Kurdish (Kurmanji)', + 'la': 'Latin', + 'lv': 'Latvian', 'lt': 'Lithuanian', 'mk': 'Macedonian', 'ms': 'Malay', 'ml': 'Malayalam', + 'mt': 'Maltese', 'mi': 'Maori', 'mr': 'Marathi', 'mn': 'Mongolian', + 'my': 'Myanmar (Burmese)', + 'fa': 'Persian', 'pl': 'Polish', 'pt': 'Portuguese', 'ro': 'Romanian', 'ru': 'Russian', + 'gd': 'Scots Gaelic', 'sr': 'Serbian', 'sk': 'Slovak', 'sl': 'Slovenian', 'es': 'Spanish', + 'sv': 'Swedish', 'te': 'Telugu', 'th': 'Thai', 'tr': 'Turkish', 'uk': 'Ukrainian', + 'uz': 'Uzbek', + 'vi': 'Vietnamese', 'cy': 'Welsh', 'zu': 'Zulu', 'he': 'Hebrew', + 'zh-cn': 'Chinese (Simplified)'} + +TSL_CODES = tuple(_TRANSLATE_SUPPORTED_LANGS.keys()) +TSL_NAMES = tuple(_TRANSLATE_SUPPORTED_LANGS.values()) + + +class TranslateSupportedLangs(Enum): + Albanian = "sq" + Amharic = "am" + Arabic = "ar" + Armenian = "hy" + Azerbaijani = "az" + Basque = "eu" + Belarusian = "be" + Bulgarian = "bg" + Catalan = "ca" + Traditional_Chinese = "zh-tw" + Croatian = "hr" + Czech = "cs" + Danish = "da" + Dutch = "nl" + English = "en" + Esperanto = "eo" + Estonian = "et" + Finnish = "fi" + French = "fr" + Galician = "gl" + German = "de" + Greek = "el" + Haitian_Creole = "ht" + Hindi = "hi" + Hungarian = "hu" + Icelandic = "is" + Indonesian = "id" + Irish = "ga" + Italian = "it" + Japanese = "ja" + Kannada = "kn" + Korean = "ko" + Kurdish = "ku" + Kurmanji = "ku" + Latin = "la" + Latvian = "lv" + Lithuanian = "lt" + Macedonian = "mk" + Malay = "ms" + Malayalam = "ml" + Maltese = "mt" + Maori = "mi" + Marathi = "mr" + Mongolian = "mn" + Myanmar = "my" + Burmese = "my" + Persian = "fa" + Polish = "pl" + Portuguese = "pt" + Romanian = "ro" + Russian = "ru" + Scots_Gaelic = "gd" + Serbian = "sr" + Slovak = "sk" + Slovenian = "sl" + Spanish = "es" + Swedish = "sv" + Telugu = "te" + Thai = "th" + Turkish = "tr" + Ukrainian = "uk" + Uzbek = "uz" + Vietnamese = "vi" + Welsh = "cy" + Zulu = "zu" + Hebrew = "he" + Simplified_Chinese = "zh-cn" + # Code for generating the dict again: # import requests From 1a9e4d8b5e261f0b1678d573402ecca2a8d185be Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 13 Nov 2024 18:09:08 +0000 Subject: [PATCH 014/101] added an enum i guess? --- scratchattach/__init__.py | 1 + scratchattach/other/other_apis.py | 18 ++--- scratchattach/utils/supportedlangs.py | 108 ++++++++++++++++++++++---- 3 files changed, 103 insertions(+), 24 deletions(-) diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index 3e96d7b4..8c3bd392 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.supportedlangs import TranslateSupportedLangs as TSLangs from .site.activity import Activity from .site.backpack_asset import BackpackAsset diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 25e0c174..89180696 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -6,7 +6,7 @@ from ..utils import commons from ..utils.exceptions import BadRequest from ..utils.requests import Requests as requests -from ..utils.supportedlangs import SUPPORTED_CODES, SUPPORTED_NAMES, tts_lang +from ..utils.supportedlangs import tts_lang, TSL_CODES, TSL_NAMES # --- Front page --- @@ -149,11 +149,11 @@ def scratch_team_members() -> dict: def translate(language: str, text: str = "hello"): - if language not in SUPPORTED_CODES: - if language.lower() in SUPPORTED_CODES: + if language not in TSL_CODES: + if language.lower() in TSL_CODES: language = language.lower() - elif language.title() in SUPPORTED_NAMES: - language = SUPPORTED_CODES[SUPPORTED_NAMES.index(language.title())] + elif language.title() in TSL_NAMES: + language = TSL_CODES[TSL_NAMES.index(language.title())] else: warnings.warn(f"'{language}' is probably not a supported language") response_json = requests.get( @@ -185,12 +185,12 @@ def text2speech(text: str = "hello", gender: str = "female", language: str = "en else: gender = ("female", 1) - if language not in SUPPORTED_NAMES: - if language.lower() in SUPPORTED_NAMES: + if language not in TSL_NAMES: + if language.lower() in TSL_NAMES: language = language.lower() - elif language.title() in SUPPORTED_CODES: - language = SUPPORTED_NAMES[SUPPORTED_CODES.index(language.title())] + elif language.title() in TSL_CODES: + language = TSL_NAMES[TSL_CODES.index(language.title())] lang = tts_lang(language.title()) if lang is None: diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index cdb2ed42..b4a7fe26 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -2,23 +2,101 @@ List of supported languages of scratch's translate and text2speech extensions. Adapted from https://translate-service.scratch.mit.edu/supported?language=en """ +from enum import Enum # Supported langs for translate -SUPPORTED_LANGS = {'sq': 'Albanian', 'am': 'Amharic', 'ar': 'Arabic', 'hy': 'Armenian', 'az': 'Azerbaijani', - 'eu': 'Basque', 'be': 'Belarusian', 'bg': 'Bulgarian', 'ca': 'Catalan', - 'zh-tw': 'Chinese (Traditional)', 'hr': 'Croatian', 'cs': 'Czech', 'da': 'Danish', 'nl': 'Dutch', - 'en': 'English', 'eo': 'Esperanto', 'et': 'Estonian', 'fi': 'Finnish', 'fr': 'French', - 'gl': 'Galician', 'de': 'German', 'el': 'Greek', 'ht': 'Haitian Creole', 'hi': 'Hindi', - 'hu': 'Hungarian', 'is': 'Icelandic', 'id': 'Indonesian', 'ga': 'Irish', 'it': 'Italian', - 'ja': 'Japanese', 'kn': 'Kannada', 'ko': 'Korean', 'ku': 'Kurdish (Kurmanji)', 'la': 'Latin', - 'lv': 'Latvian', 'lt': 'Lithuanian', 'mk': 'Macedonian', 'ms': 'Malay', 'ml': 'Malayalam', - 'mt': 'Maltese', 'mi': 'Maori', 'mr': 'Marathi', 'mn': 'Mongolian', 'my': 'Myanmar (Burmese)', - 'fa': 'Persian', 'pl': 'Polish', 'pt': 'Portuguese', 'ro': 'Romanian', 'ru': 'Russian', - 'gd': 'Scots Gaelic', 'sr': 'Serbian', 'sk': 'Slovak', 'sl': 'Slovenian', 'es': 'Spanish', - 'sv': 'Swedish', 'te': 'Telugu', 'th': 'Thai', 'tr': 'Turkish', 'uk': 'Ukrainian', 'uz': 'Uzbek', - 'vi': 'Vietnamese', 'cy': 'Welsh', 'zu': 'Zulu', 'he': 'Hebrew', 'zh-cn': 'Chinese (Simplified)'} -SUPPORTED_CODES = tuple(SUPPORTED_LANGS.keys()) -SUPPORTED_NAMES = tuple(SUPPORTED_LANGS.values()) +_TRANSLATE_SUPPORTED_LANGS = {'sq': 'Albanian', 'am': 'Amharic', 'ar': 'Arabic', 'hy': 'Armenian', 'az': 'Azerbaijani', + 'eu': 'Basque', 'be': 'Belarusian', 'bg': 'Bulgarian', 'ca': 'Catalan', + 'zh-tw': 'Chinese (Traditional)', 'hr': 'Croatian', 'cs': 'Czech', 'da': 'Danish', + 'nl': 'Dutch', + 'en': 'English', 'eo': 'Esperanto', 'et': 'Estonian', 'fi': 'Finnish', 'fr': 'French', + 'gl': 'Galician', 'de': 'German', 'el': 'Greek', 'ht': 'Haitian Creole', 'hi': 'Hindi', + 'hu': 'Hungarian', 'is': 'Icelandic', 'id': 'Indonesian', 'ga': 'Irish', 'it': 'Italian', + 'ja': 'Japanese', 'kn': 'Kannada', 'ko': 'Korean', 'ku': 'Kurdish (Kurmanji)', + 'la': 'Latin', + 'lv': 'Latvian', 'lt': 'Lithuanian', 'mk': 'Macedonian', 'ms': 'Malay', 'ml': 'Malayalam', + 'mt': 'Maltese', 'mi': 'Maori', 'mr': 'Marathi', 'mn': 'Mongolian', + 'my': 'Myanmar (Burmese)', + 'fa': 'Persian', 'pl': 'Polish', 'pt': 'Portuguese', 'ro': 'Romanian', 'ru': 'Russian', + 'gd': 'Scots Gaelic', 'sr': 'Serbian', 'sk': 'Slovak', 'sl': 'Slovenian', 'es': 'Spanish', + 'sv': 'Swedish', 'te': 'Telugu', 'th': 'Thai', 'tr': 'Turkish', 'uk': 'Ukrainian', + 'uz': 'Uzbek', + 'vi': 'Vietnamese', 'cy': 'Welsh', 'zu': 'Zulu', 'he': 'Hebrew', + 'zh-cn': 'Chinese (Simplified)'} + +TSL_CODES = tuple(_TRANSLATE_SUPPORTED_LANGS.keys()) +TSL_NAMES = tuple(_TRANSLATE_SUPPORTED_LANGS.values()) + + +class TranslateSupportedLangs(Enum): + Albanian = "sq" + Amharic = "am" + Arabic = "ar" + Armenian = "hy" + Azerbaijani = "az" + Basque = "eu" + Belarusian = "be" + Bulgarian = "bg" + Catalan = "ca" + Traditional_Chinese = "zh-tw" + Croatian = "hr" + Czech = "cs" + Danish = "da" + Dutch = "nl" + English = "en" + Esperanto = "eo" + Estonian = "et" + Finnish = "fi" + French = "fr" + Galician = "gl" + German = "de" + Greek = "el" + Haitian_Creole = "ht" + Hindi = "hi" + Hungarian = "hu" + Icelandic = "is" + Indonesian = "id" + Irish = "ga" + Italian = "it" + Japanese = "ja" + Kannada = "kn" + Korean = "ko" + Kurdish = "ku" + Kurmanji = "ku" + Latin = "la" + Latvian = "lv" + Lithuanian = "lt" + Macedonian = "mk" + Malay = "ms" + Malayalam = "ml" + Maltese = "mt" + Maori = "mi" + Marathi = "mr" + Mongolian = "mn" + Myanmar = "my" + Burmese = "my" + Persian = "fa" + Polish = "pl" + Portuguese = "pt" + Romanian = "ro" + Russian = "ru" + Scots_Gaelic = "gd" + Serbian = "sr" + Slovak = "sk" + Slovenian = "sl" + Spanish = "es" + Swedish = "sv" + Telugu = "te" + Thai = "th" + Turkish = "tr" + Ukrainian = "uk" + Uzbek = "uz" + Vietnamese = "vi" + Welsh = "cy" + Zulu = "zu" + Hebrew = "he" + Simplified_Chinese = "zh-cn" + # Code for generating the dict again: # import requests From 765b0682800326f29d8a18a3a7a19d3bae51ba94 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sat, 16 Nov 2024 20:18:55 +0000 Subject: [PATCH 015/101] using languages enum now + kitten improvement --- scratchattach/__init__.py | 2 +- scratchattach/other/other_apis.py | 50 ++-- scratchattach/utils/supportedlangs.py | 354 +++++++++----------------- 3 files changed, 153 insertions(+), 253 deletions(-) diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index 8c3bd392..8628bddb 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -10,7 +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.supportedlangs import TranslateSupportedLangs as TSLangs +from .utils.supportedlangs import Languages from .site.activity import Activity from .site.backpack_asset import BackpackAsset diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 89180696..e4401c0b 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -6,7 +6,7 @@ from ..utils import commons from ..utils.exceptions import BadRequest from ..utils.requests import Requests as requests -from ..utils.supportedlangs import tts_lang, TSL_CODES, TSL_NAMES +from ..utils.supportedlangs import Languages # --- Front page --- @@ -148,16 +148,17 @@ def scratch_team_members() -> dict: return json.loads(text) -def translate(language: str, text: str = "hello"): - if language not in TSL_CODES: - if language.lower() in TSL_CODES: - language = language.lower() - elif language.title() in TSL_NAMES: - language = TSL_CODES[TSL_NAMES.index(language.title())] - else: - warnings.warn(f"'{language}' is probably not a supported language") +def translate(language: str | Languages, text: str = "hello"): + if language.lower() not in Languages.all_of("code", str.lower): + if language.lower() in Languages.all_of("name", str.lower): + language = Languages.find(language.lower(), apply_func=str.lower).code + + lang = Languages.find(language, "code", str.lower) + if lang is None: + raise ValueError(f"{language} is not a supported translate language") + response_json = requests.get( - f"https://translate-service.scratch.mit.edu/translate?language={language}&text={text}").json() + f"https://translate-service.scratch.mit.edu/translate?language={lang.code}&text={text}").json() if "result" in response_json: return response_json["result"] @@ -182,22 +183,29 @@ def text2speech(text: str = "hello", gender: str = "female", language: str = "en gender = ("male", .84) elif gender == "kitten": gender = ("female", 1.41) + split = text.split(' ') + text = '' + for token in split: + if token.strip() != '': + text += "meow " else: gender = ("female", 1) - if language not in TSL_NAMES: - if language.lower() in TSL_NAMES: - language = language.lower() + og_lang = language + if isinstance(language, Languages): + language = language.value.tts_locale - elif language.title() in TSL_CODES: - language = TSL_NAMES[TSL_CODES.index(language.title())] + if language is None: + raise ValueError(f"Language '{og_lang}' is not a supported tts language") - lang = tts_lang(language.title()) - if lang is None: - warnings.warn(f"Language '{language}' is probably not a supported language") - else: - language = lang["speechSynthLocale"] + if language.lower() not in Languages.all_of("tts_locale", str.lower): + if language.lower() in Languages.all_of("name", str.lower): + language = Languages.find(language.lower(), apply_func=str.lower).tts_locale + + lang = Languages.find(language, "tts_locale") + if lang is None or language is None: + raise ValueError(f"Language '{og_lang}' is not a supported tts language") response = requests.get(f"https://synthesis-service.scratch.mit.edu/synth" - f"?locale={language}&gender={gender[0]}&text={text}") + f"?locale={lang.tts_locale}&gender={gender[0]}&text={text}") return response.content, gender[1] diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index b4a7fe26..d6ac0f46 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -2,245 +2,137 @@ List of supported languages of scratch's translate and text2speech extensions. Adapted from https://translate-service.scratch.mit.edu/supported?language=en """ + from enum import Enum +from typing import Callable + + +class _Language: + def __init__(self, name: str = None, code: str = None, locales: list[str] = None, tts_locale: str = None, + single_gender: bool = None): + self.name = name + self.code = code + self.locales = locales + self.tts_locale = tts_locale + self.single_gender = single_gender + + def __repr__(self): + ret = "Language(" + for attr in self.__dict__.keys(): + if not attr.startswith("_"): + val = getattr(self, attr) + ret += f"{repr(val)}, " + if ret.endswith(", "): + ret = ret[:-2] -# Supported langs for translate -_TRANSLATE_SUPPORTED_LANGS = {'sq': 'Albanian', 'am': 'Amharic', 'ar': 'Arabic', 'hy': 'Armenian', 'az': 'Azerbaijani', - 'eu': 'Basque', 'be': 'Belarusian', 'bg': 'Bulgarian', 'ca': 'Catalan', - 'zh-tw': 'Chinese (Traditional)', 'hr': 'Croatian', 'cs': 'Czech', 'da': 'Danish', - 'nl': 'Dutch', - 'en': 'English', 'eo': 'Esperanto', 'et': 'Estonian', 'fi': 'Finnish', 'fr': 'French', - 'gl': 'Galician', 'de': 'German', 'el': 'Greek', 'ht': 'Haitian Creole', 'hi': 'Hindi', - 'hu': 'Hungarian', 'is': 'Icelandic', 'id': 'Indonesian', 'ga': 'Irish', 'it': 'Italian', - 'ja': 'Japanese', 'kn': 'Kannada', 'ko': 'Korean', 'ku': 'Kurdish (Kurmanji)', - 'la': 'Latin', - 'lv': 'Latvian', 'lt': 'Lithuanian', 'mk': 'Macedonian', 'ms': 'Malay', 'ml': 'Malayalam', - 'mt': 'Maltese', 'mi': 'Maori', 'mr': 'Marathi', 'mn': 'Mongolian', - 'my': 'Myanmar (Burmese)', - 'fa': 'Persian', 'pl': 'Polish', 'pt': 'Portuguese', 'ro': 'Romanian', 'ru': 'Russian', - 'gd': 'Scots Gaelic', 'sr': 'Serbian', 'sk': 'Slovak', 'sl': 'Slovenian', 'es': 'Spanish', - 'sv': 'Swedish', 'te': 'Telugu', 'th': 'Thai', 'tr': 'Turkish', 'uk': 'Ukrainian', - 'uz': 'Uzbek', - 'vi': 'Vietnamese', 'cy': 'Welsh', 'zu': 'Zulu', 'he': 'Hebrew', - 'zh-cn': 'Chinese (Simplified)'} + ret += ')' + return ret -TSL_CODES = tuple(_TRANSLATE_SUPPORTED_LANGS.keys()) -TSL_NAMES = tuple(_TRANSLATE_SUPPORTED_LANGS.values()) + def __str__(self): + return f"Language<{self.name} - {self.code}>" -class TranslateSupportedLangs(Enum): - Albanian = "sq" - Amharic = "am" - Arabic = "ar" - Armenian = "hy" - Azerbaijani = "az" - Basque = "eu" - Belarusian = "be" - Bulgarian = "bg" - Catalan = "ca" - Traditional_Chinese = "zh-tw" - Croatian = "hr" - Czech = "cs" - Danish = "da" - Dutch = "nl" - English = "en" - Esperanto = "eo" - Estonian = "et" - Finnish = "fi" - French = "fr" - Galician = "gl" - German = "de" - Greek = "el" - Haitian_Creole = "ht" - Hindi = "hi" - Hungarian = "hu" - Icelandic = "is" - Indonesian = "id" - Irish = "ga" - Italian = "it" - Japanese = "ja" - Kannada = "kn" - Korean = "ko" - Kurdish = "ku" - Kurmanji = "ku" - Latin = "la" - Latvian = "lv" - Lithuanian = "lt" - Macedonian = "mk" - Malay = "ms" - Malayalam = "ml" - Maltese = "mt" - Maori = "mi" - Marathi = "mr" - Mongolian = "mn" - Myanmar = "my" - Burmese = "my" - Persian = "fa" - Polish = "pl" - Portuguese = "pt" - Romanian = "ro" - Russian = "ru" - Scots_Gaelic = "gd" - Serbian = "sr" - Slovak = "sk" - Slovenian = "sl" - Spanish = "es" - Swedish = "sv" - Telugu = "te" - Thai = "th" - Turkish = "tr" - Ukrainian = "uk" - Uzbek = "uz" - Vietnamese = "vi" - Welsh = "cy" - Zulu = "zu" - Hebrew = "he" - Simplified_Chinese = "zh-cn" +class Languages(Enum): + 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', None, None, None) + 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', None, None, None) + 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', None, None, None) + Mandarin = Chinese_Simplified + cmn_CN = _Language(None, None, ['zh-cn', 'zh-tw'], 'cmn-CN', True) + nb_NO = _Language(None, None, ['nb', 'nn'], 'nb-NO', True) + pt_BR = _Language(None, None, ['pt-br'], 'pt-BR', False) + Brazilian = pt_BR + pt_PT = _Language(None, None, ['pt'], 'pt-PT', False) + es_ES = _Language(None, None, ['es'], 'es-ES', False) + es_US = _Language(None, None, ['es-419'], 'es-US', False) -# Code for generating the dict again: -# import requests -# -# SUPPORTED_LANGS = {} -# raw = requests.get("https://translate-service.scratch.mit.edu/supported").json() -# for lang in raw: -# SUPPORTED_LANGS[lang["code"]] = lang["name"] -# print(SUPPORTED_LANGS) + @staticmethod + def find(value, by: str = "name", apply_func: Callable = None) -> _Language: + if apply_func is None: + def apply_func(x): + return x -# Language info for tts -TTS_LANGUAGE_INFO = [ - { - "name": 'Arabic', - "locales": ['ar'], - "speechSynthLocale": 'arb', - "singleGender": True - }, - { - "name": 'Chinese (Mandarin)', - "locales": ['zh-cn', 'zh-tw'], - "speechSynthLocale": 'cmn-CN', - "singleGender": True - }, - { - "name": 'Danish', - "locales": ['da'], - "speechSynthLocale": 'da-DK' - }, - { - "name": 'Dutch', - "locales": ['nl'], - "speechSynthLocale": 'nl-NL' - }, - { - "name": 'English', - "locales": ['en'], - "speechSynthLocale": 'en-US' - }, - { - "name": 'French', - "locales": ['fr'], - "speechSynthLocale": 'fr-FR' - }, - { - "name": 'German', - "locales": ['de'], - "speechSynthLocale": 'de-DE' - }, - { - "name": 'Hindi', - "locales": ['hi'], - "speechSynthLocale": 'hi-IN', - "singleGender": True - }, - { - "name": 'Icelandic', - "locales": ['is'], - "speechSynthLocale": 'is-IS' - }, - { - "name": 'Italian', - "locales": ['it'], - "speechSynthLocale": 'it-IT' - }, - { - "name": 'Japanese', - "locales": ['ja', 'ja-hira'], - "speechSynthLocale": 'ja-JP' - }, - { - "name": 'Korean', - "locales": ['ko'], - "speechSynthLocale": 'ko-KR', - "singleGender": True - }, - { - "name": 'Norwegian', - "locales": ['nb', 'nn'], - "speechSynthLocale": 'nb-NO', - "singleGender": True - }, - { - "name": 'Polish', - "locales": ['pl'], - "speechSynthLocale": 'pl-PL' - }, - { - "name": 'Portuguese (Brazilian)', - "locales": ['pt-br'], - "speechSynthLocale": 'pt-BR' - }, - { - "name": 'Portuguese (European)', - "locales": ['pt'], - "speechSynthLocale": 'pt-PT' - }, - { - "name": 'Romanian', - "locales": ['ro'], - "speechSynthLocale": 'ro-RO', - "singleGender": True - }, - { - "name": 'Russian', - "locales": ['ru'], - "speechSynthLocale": 'ru-RU' - }, - { - "name": 'Spanish (European)', - "locales": ['es'], - "speechSynthLocale": 'es-ES' - }, - { - "name": 'Spanish (Latin American)', - "locales": ['es-419'], - "speechSynthLocale": 'es-US' - }, - { - "name": 'Swedish', - "locales": ['sv'], - "speechSynthLocale": 'sv-SE', - "singleGender": True - }, - { - "name": 'Turkish', - "locales": ['tr'], - "speechSynthLocale": 'tr-TR', - "singleGender": True - }, - { - "name": 'Welsh', - "locales": ['cy'], - "speechSynthLocale": 'cy-GB', - "singleGender": True - }] + for lang_enum in Languages: + lang = lang_enum.value + try: + if apply_func(getattr(lang, by)) == value: + return lang + except TypeError: + pass + @staticmethod + def all_of(attr_name: str = "name", apply_func: Callable = None): + if apply_func is None: + def apply_func(x): + return x -def tts_lang(attribute: str, by: str = None): - for lang in TTS_LANGUAGE_INFO: - if by is None: - if attribute in lang.values(): - return lang - continue + for lang_enum in Languages: + lang = lang_enum.value + attr = getattr(lang, attr_name) + try: + yield apply_func(attr) - if lang.get(by) == attribute: - return lang + except TypeError: + yield attr From f91178d1ecdce6bb412bc2fe667a34c93f15ccee Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sat, 16 Nov 2024 20:18:55 +0000 Subject: [PATCH 016/101] using languages enum now + kitten improvement (cherry picked from commit 765b0682800326f29d8a18a3a7a19d3bae51ba94) --- scratchattach/__init__.py | 2 +- scratchattach/other/other_apis.py | 50 ++-- scratchattach/utils/supportedlangs.py | 354 +++++++++----------------- 3 files changed, 153 insertions(+), 253 deletions(-) diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index 8c3bd392..8628bddb 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -10,7 +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.supportedlangs import TranslateSupportedLangs as TSLangs +from .utils.supportedlangs import Languages from .site.activity import Activity from .site.backpack_asset import BackpackAsset diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 89180696..e4401c0b 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -6,7 +6,7 @@ from ..utils import commons from ..utils.exceptions import BadRequest from ..utils.requests import Requests as requests -from ..utils.supportedlangs import tts_lang, TSL_CODES, TSL_NAMES +from ..utils.supportedlangs import Languages # --- Front page --- @@ -148,16 +148,17 @@ def scratch_team_members() -> dict: return json.loads(text) -def translate(language: str, text: str = "hello"): - if language not in TSL_CODES: - if language.lower() in TSL_CODES: - language = language.lower() - elif language.title() in TSL_NAMES: - language = TSL_CODES[TSL_NAMES.index(language.title())] - else: - warnings.warn(f"'{language}' is probably not a supported language") +def translate(language: str | Languages, text: str = "hello"): + if language.lower() not in Languages.all_of("code", str.lower): + if language.lower() in Languages.all_of("name", str.lower): + language = Languages.find(language.lower(), apply_func=str.lower).code + + lang = Languages.find(language, "code", str.lower) + if lang is None: + raise ValueError(f"{language} is not a supported translate language") + response_json = requests.get( - f"https://translate-service.scratch.mit.edu/translate?language={language}&text={text}").json() + f"https://translate-service.scratch.mit.edu/translate?language={lang.code}&text={text}").json() if "result" in response_json: return response_json["result"] @@ -182,22 +183,29 @@ def text2speech(text: str = "hello", gender: str = "female", language: str = "en gender = ("male", .84) elif gender == "kitten": gender = ("female", 1.41) + split = text.split(' ') + text = '' + for token in split: + if token.strip() != '': + text += "meow " else: gender = ("female", 1) - if language not in TSL_NAMES: - if language.lower() in TSL_NAMES: - language = language.lower() + og_lang = language + if isinstance(language, Languages): + language = language.value.tts_locale - elif language.title() in TSL_CODES: - language = TSL_NAMES[TSL_CODES.index(language.title())] + if language is None: + raise ValueError(f"Language '{og_lang}' is not a supported tts language") - lang = tts_lang(language.title()) - if lang is None: - warnings.warn(f"Language '{language}' is probably not a supported language") - else: - language = lang["speechSynthLocale"] + if language.lower() not in Languages.all_of("tts_locale", str.lower): + if language.lower() in Languages.all_of("name", str.lower): + language = Languages.find(language.lower(), apply_func=str.lower).tts_locale + + lang = Languages.find(language, "tts_locale") + if lang is None or language is None: + raise ValueError(f"Language '{og_lang}' is not a supported tts language") response = requests.get(f"https://synthesis-service.scratch.mit.edu/synth" - f"?locale={language}&gender={gender[0]}&text={text}") + f"?locale={lang.tts_locale}&gender={gender[0]}&text={text}") return response.content, gender[1] diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index b4a7fe26..d6ac0f46 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -2,245 +2,137 @@ List of supported languages of scratch's translate and text2speech extensions. Adapted from https://translate-service.scratch.mit.edu/supported?language=en """ + from enum import Enum +from typing import Callable + + +class _Language: + def __init__(self, name: str = None, code: str = None, locales: list[str] = None, tts_locale: str = None, + single_gender: bool = None): + self.name = name + self.code = code + self.locales = locales + self.tts_locale = tts_locale + self.single_gender = single_gender + + def __repr__(self): + ret = "Language(" + for attr in self.__dict__.keys(): + if not attr.startswith("_"): + val = getattr(self, attr) + ret += f"{repr(val)}, " + if ret.endswith(", "): + ret = ret[:-2] -# Supported langs for translate -_TRANSLATE_SUPPORTED_LANGS = {'sq': 'Albanian', 'am': 'Amharic', 'ar': 'Arabic', 'hy': 'Armenian', 'az': 'Azerbaijani', - 'eu': 'Basque', 'be': 'Belarusian', 'bg': 'Bulgarian', 'ca': 'Catalan', - 'zh-tw': 'Chinese (Traditional)', 'hr': 'Croatian', 'cs': 'Czech', 'da': 'Danish', - 'nl': 'Dutch', - 'en': 'English', 'eo': 'Esperanto', 'et': 'Estonian', 'fi': 'Finnish', 'fr': 'French', - 'gl': 'Galician', 'de': 'German', 'el': 'Greek', 'ht': 'Haitian Creole', 'hi': 'Hindi', - 'hu': 'Hungarian', 'is': 'Icelandic', 'id': 'Indonesian', 'ga': 'Irish', 'it': 'Italian', - 'ja': 'Japanese', 'kn': 'Kannada', 'ko': 'Korean', 'ku': 'Kurdish (Kurmanji)', - 'la': 'Latin', - 'lv': 'Latvian', 'lt': 'Lithuanian', 'mk': 'Macedonian', 'ms': 'Malay', 'ml': 'Malayalam', - 'mt': 'Maltese', 'mi': 'Maori', 'mr': 'Marathi', 'mn': 'Mongolian', - 'my': 'Myanmar (Burmese)', - 'fa': 'Persian', 'pl': 'Polish', 'pt': 'Portuguese', 'ro': 'Romanian', 'ru': 'Russian', - 'gd': 'Scots Gaelic', 'sr': 'Serbian', 'sk': 'Slovak', 'sl': 'Slovenian', 'es': 'Spanish', - 'sv': 'Swedish', 'te': 'Telugu', 'th': 'Thai', 'tr': 'Turkish', 'uk': 'Ukrainian', - 'uz': 'Uzbek', - 'vi': 'Vietnamese', 'cy': 'Welsh', 'zu': 'Zulu', 'he': 'Hebrew', - 'zh-cn': 'Chinese (Simplified)'} + ret += ')' + return ret -TSL_CODES = tuple(_TRANSLATE_SUPPORTED_LANGS.keys()) -TSL_NAMES = tuple(_TRANSLATE_SUPPORTED_LANGS.values()) + def __str__(self): + return f"Language<{self.name} - {self.code}>" -class TranslateSupportedLangs(Enum): - Albanian = "sq" - Amharic = "am" - Arabic = "ar" - Armenian = "hy" - Azerbaijani = "az" - Basque = "eu" - Belarusian = "be" - Bulgarian = "bg" - Catalan = "ca" - Traditional_Chinese = "zh-tw" - Croatian = "hr" - Czech = "cs" - Danish = "da" - Dutch = "nl" - English = "en" - Esperanto = "eo" - Estonian = "et" - Finnish = "fi" - French = "fr" - Galician = "gl" - German = "de" - Greek = "el" - Haitian_Creole = "ht" - Hindi = "hi" - Hungarian = "hu" - Icelandic = "is" - Indonesian = "id" - Irish = "ga" - Italian = "it" - Japanese = "ja" - Kannada = "kn" - Korean = "ko" - Kurdish = "ku" - Kurmanji = "ku" - Latin = "la" - Latvian = "lv" - Lithuanian = "lt" - Macedonian = "mk" - Malay = "ms" - Malayalam = "ml" - Maltese = "mt" - Maori = "mi" - Marathi = "mr" - Mongolian = "mn" - Myanmar = "my" - Burmese = "my" - Persian = "fa" - Polish = "pl" - Portuguese = "pt" - Romanian = "ro" - Russian = "ru" - Scots_Gaelic = "gd" - Serbian = "sr" - Slovak = "sk" - Slovenian = "sl" - Spanish = "es" - Swedish = "sv" - Telugu = "te" - Thai = "th" - Turkish = "tr" - Ukrainian = "uk" - Uzbek = "uz" - Vietnamese = "vi" - Welsh = "cy" - Zulu = "zu" - Hebrew = "he" - Simplified_Chinese = "zh-cn" +class Languages(Enum): + 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', None, None, None) + 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', None, None, None) + 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', None, None, None) + Mandarin = Chinese_Simplified + cmn_CN = _Language(None, None, ['zh-cn', 'zh-tw'], 'cmn-CN', True) + nb_NO = _Language(None, None, ['nb', 'nn'], 'nb-NO', True) + pt_BR = _Language(None, None, ['pt-br'], 'pt-BR', False) + Brazilian = pt_BR + pt_PT = _Language(None, None, ['pt'], 'pt-PT', False) + es_ES = _Language(None, None, ['es'], 'es-ES', False) + es_US = _Language(None, None, ['es-419'], 'es-US', False) -# Code for generating the dict again: -# import requests -# -# SUPPORTED_LANGS = {} -# raw = requests.get("https://translate-service.scratch.mit.edu/supported").json() -# for lang in raw: -# SUPPORTED_LANGS[lang["code"]] = lang["name"] -# print(SUPPORTED_LANGS) + @staticmethod + def find(value, by: str = "name", apply_func: Callable = None) -> _Language: + if apply_func is None: + def apply_func(x): + return x -# Language info for tts -TTS_LANGUAGE_INFO = [ - { - "name": 'Arabic', - "locales": ['ar'], - "speechSynthLocale": 'arb', - "singleGender": True - }, - { - "name": 'Chinese (Mandarin)', - "locales": ['zh-cn', 'zh-tw'], - "speechSynthLocale": 'cmn-CN', - "singleGender": True - }, - { - "name": 'Danish', - "locales": ['da'], - "speechSynthLocale": 'da-DK' - }, - { - "name": 'Dutch', - "locales": ['nl'], - "speechSynthLocale": 'nl-NL' - }, - { - "name": 'English', - "locales": ['en'], - "speechSynthLocale": 'en-US' - }, - { - "name": 'French', - "locales": ['fr'], - "speechSynthLocale": 'fr-FR' - }, - { - "name": 'German', - "locales": ['de'], - "speechSynthLocale": 'de-DE' - }, - { - "name": 'Hindi', - "locales": ['hi'], - "speechSynthLocale": 'hi-IN', - "singleGender": True - }, - { - "name": 'Icelandic', - "locales": ['is'], - "speechSynthLocale": 'is-IS' - }, - { - "name": 'Italian', - "locales": ['it'], - "speechSynthLocale": 'it-IT' - }, - { - "name": 'Japanese', - "locales": ['ja', 'ja-hira'], - "speechSynthLocale": 'ja-JP' - }, - { - "name": 'Korean', - "locales": ['ko'], - "speechSynthLocale": 'ko-KR', - "singleGender": True - }, - { - "name": 'Norwegian', - "locales": ['nb', 'nn'], - "speechSynthLocale": 'nb-NO', - "singleGender": True - }, - { - "name": 'Polish', - "locales": ['pl'], - "speechSynthLocale": 'pl-PL' - }, - { - "name": 'Portuguese (Brazilian)', - "locales": ['pt-br'], - "speechSynthLocale": 'pt-BR' - }, - { - "name": 'Portuguese (European)', - "locales": ['pt'], - "speechSynthLocale": 'pt-PT' - }, - { - "name": 'Romanian', - "locales": ['ro'], - "speechSynthLocale": 'ro-RO', - "singleGender": True - }, - { - "name": 'Russian', - "locales": ['ru'], - "speechSynthLocale": 'ru-RU' - }, - { - "name": 'Spanish (European)', - "locales": ['es'], - "speechSynthLocale": 'es-ES' - }, - { - "name": 'Spanish (Latin American)', - "locales": ['es-419'], - "speechSynthLocale": 'es-US' - }, - { - "name": 'Swedish', - "locales": ['sv'], - "speechSynthLocale": 'sv-SE', - "singleGender": True - }, - { - "name": 'Turkish', - "locales": ['tr'], - "speechSynthLocale": 'tr-TR', - "singleGender": True - }, - { - "name": 'Welsh', - "locales": ['cy'], - "speechSynthLocale": 'cy-GB', - "singleGender": True - }] + for lang_enum in Languages: + lang = lang_enum.value + try: + if apply_func(getattr(lang, by)) == value: + return lang + except TypeError: + pass + @staticmethod + def all_of(attr_name: str = "name", apply_func: Callable = None): + if apply_func is None: + def apply_func(x): + return x -def tts_lang(attribute: str, by: str = None): - for lang in TTS_LANGUAGE_INFO: - if by is None: - if attribute in lang.values(): - return lang - continue + for lang_enum in Languages: + lang = lang_enum.value + attr = getattr(lang, attr_name) + try: + yield apply_func(attr) - if lang.get(by) == attribute: - return lang + except TypeError: + yield attr From dbe7c321b93ab7ec043cb4d6d1cc7d2dee0e300d Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Mon, 18 Nov 2024 18:33:14 +0000 Subject: [PATCH 017/101] more classroom stuff. lets you actually edit title, description and status (wiwo for classes) --- scratchattach/site/activity.py | 4 +- scratchattach/site/classroom.py | 145 +++++++++++++++++++++++++------- scratchattach/site/project.py | 3 +- scratchattach/site/session.py | 57 ++++++++++++- scratchattach/utils/requests.py | 18 ++-- 5 files changed, 182 insertions(+), 45 deletions(-) diff --git a/scratchattach/site/activity.py b/scratchattach/site/activity.py index 6fcc26d4..c1cf4c1b 100644 --- a/scratchattach/site/activity.py +++ b/scratchattach/site/activity.py @@ -34,9 +34,9 @@ def __init__(self, **entries): # 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 diff --git a/scratchattach/site/classroom.py b/scratchattach/site/classroom.py index 0744d30a..c81c4dbb 100644 --- a/scratchattach/site/classroom.py +++ b/scratchattach/site/classroom.py @@ -1,9 +1,17 @@ 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 + +if TYPE_CHECKING: + from ..site.session import Session + +from ..utils.commons import requests +from . import user from ._base import BaseSiteComponent +from ..utils import exceptions, commons +from ..utils.commons import headers + class Classroom(BaseSiteComponent): def __init__(self, **entries): @@ -14,10 +22,10 @@ def __init__(self, **entries): 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 @@ -37,30 +45,44 @@ def __init__(self, **entries): self._json_headers["Content-Type"] = "application/json" 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 return True - + def student_count(self): # 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): """ Returns the student on the class. @@ -73,19 +95,19 @@ def student_names(self, *, page=1): """ 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(' ')[0]) for i in text.split('\n Classroom: @@ -120,9 +200,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 +219,5 @@ 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) diff --git a/scratchattach/site/project.py b/scratchattach/site/project.py index 47e8b465..065fff73 100644 --- a/scratchattach/site/project.py +++ b/scratchattach/site/project.py @@ -324,7 +324,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 +343,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 b5c4685e..025ed26a 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -1,6 +1,7 @@ """Session class and login function""" import base64 +import datetime import hashlib import json import pathlib @@ -49,7 +50,7 @@ class Session(BaseSiteComponent): """ def __str__(self): - return "Login for account: {self.username}" + return f"Login for account: {self.username}" def __init__(self, **entries): # Info on how the .update method has to fetch the data: @@ -63,7 +64,7 @@ def __init__(self, **entries): self.new_scratcher = None # Set attributes that Session object may get - self._user = None + self._user: user.User = None # Update attributes from entries dict: self.__dict__.update(entries) @@ -94,6 +95,8 @@ def _update_from_dict(self, data: dict): 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"] @@ -118,7 +121,11 @@ def connect_linked_user(self) -> 'user.User': Returns: scratchattach.user.User: Object representing the user associated with the session. """ - if not hasattr(self, "_user"): + 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 @@ -508,6 +515,16 @@ def create_studio(self, *, title=None, description: str = None): return new_studio + def create_class(self, title: str, desc: str = ''): + 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: str = "all", *, page: int = 1, sort_by: str = '', descending: bool = True) \ @@ -595,6 +612,40 @@ def mystuff_studios(self, filter_arg: str = "all", *, page: int = 1, sort_by: st except Exception: 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 = '' + + 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" + + 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 backpack(self, limit: int = 20, offset: int = 0) -> list[dict]: """ Lists the assets that are in the backpack of the user associated with the session. diff --git a/scratchattach/utils/requests.py b/scratchattach/utils/requests.py index 951bab42..6d38ce71 100644 --- a/scratchattach/utils/requests.py +++ b/scratchattach/utils/requests.py @@ -3,6 +3,7 @@ proxies = None + class Requests: """ Centralized HTTP request handler (for better error handling and proxies) @@ -24,16 +25,18 @@ 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): 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) except Exception as e: raise exceptions.FetchError(e) Requests.check_response(r) @@ -42,7 +45,8 @@ def post(url, *, data=None, json=None, headers=None, cookies=None, timeout=None, @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 +55,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 + From 74342559f58826d83bbe86859e7db0e8c3784e3f Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Mon, 18 Nov 2024 18:51:58 +0000 Subject: [PATCH 018/101] classroom alerts, simplified code for wiwo/bio setting --- scratchattach/site/session.py | 44 ++++++++++++++++++---------- scratchattach/site/user.py | 53 ++++++++++++---------------------- scratchattach/utils/commons.py | 1 + 3 files changed, 49 insertions(+), 49 deletions(-) diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index 025ed26a..c9e3f904 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -221,6 +221,31 @@ def admin_messages(self, *, limit=40, offset=0) -> list[dict]: limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies ) + @staticmethod + def _get_class_sort_mode(mode: str): + 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 + + def classroom_alerts(self, mode: str = "Last created", page: int = None): + ascsort, descsort = self._get_class_sort_mode(mode) + + data = requests.get("https://scratch.mit.edu/site-api/classrooms/alerts/", + params={"page": page, "ascsort": ascsort, "descsort": descsort}, + headers=self._headers, cookies=self._cookies).json() + return data + def clear_messages(self): """ Clears all messages. @@ -519,8 +544,9 @@ def create_class(self, title: str, desc: str = ''): 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() + 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) @@ -615,19 +641,7 @@ def mystuff_studios(self, filter_arg: str = "all", *, page: int = 1, sort_by: st 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 = '' - - 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" + ascsort, descsort = self._get_class_sort_mode(mode) classes_data = requests.get("https://scratch.mit.edu/site-api/classrooms/all/", params={"page": page, "ascsort": ascsort, "descsort": descsort}, diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index b8adc2b2..c281c1c7 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -107,7 +107,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: @@ -427,39 +426,25 @@ 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 +459,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 +499,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 +519,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': [] diff --git a/scratchattach/utils/commons.py b/scratchattach/utils/commons.py index f1cb2f49..6b34b167 100644 --- a/scratchattach/utils/commons.py +++ b/scratchattach/utils/commons.py @@ -115,6 +115,7 @@ def fetch(off: int, lim: int): resp = requests.get( 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": ""}: From 1904ee1420cbc2bae5edb3d12597dd0374c854a5 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Mon, 18 Nov 2024 19:16:39 +0000 Subject: [PATCH 019/101] added setting pfp! wow that was confusing! bruh --- scratchattach/site/user.py | 16 ++++++++++++++-- scratchattach/utils/requests.py | 4 ++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index c281c1c7..e2134bec 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -71,10 +71,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: @@ -422,6 +422,18 @@ def viewed_projects(self, limit=24, offset=0): 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` diff --git a/scratchattach/utils/requests.py b/scratchattach/utils/requests.py index 6d38ce71..35bb1be7 100644 --- a/scratchattach/utils/requests.py +++ b/scratchattach/utils/requests.py @@ -33,10 +33,10 @@ def get(url, *, data=None, json=None, headers=None, cookies=None, timeout=None, 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): try: r = requests.post(url, data=data, json=json, headers=headers, cookies=cookies, params=params, - timeout=timeout, proxies=proxies) + timeout=timeout, proxies=proxies, files=files) except Exception as e: raise exceptions.FetchError(e) Requests.check_response(r) From 680640ed8874ff23d6ca0a3a3fd1e12137fbd051 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Mon, 18 Nov 2024 20:03:04 +0000 Subject: [PATCH 020/101] find ended classes, reopen/close classes & set thumbnail --- scratchattach/site/classroom.py | 29 +++++++++++++++++++++++++++++ scratchattach/site/session.py | 22 ++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/scratchattach/site/classroom.py b/scratchattach/site/classroom.py index c81c4dbb..cb534192 100644 --- a/scratchattach/site/classroom.py +++ b/scratchattach/site/classroom.py @@ -130,6 +130,12 @@ def _check_session(self): raise exceptions.Unauthenticated( f"Classroom {self} has no associated session. Use session.connect_classroom() instead of sa.get_classroom()") + def set_thumbnail(self, thumbnail: bytes): + 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): self._check_session() response = requests.put(f"https://scratch.mit.edu/site-api/classrooms/all/{self.id}/", @@ -184,6 +190,29 @@ def set_title(self, title: str): warnings.warn(f"{self._session} may not be authenticated to edit {self}") raise e + def reopen(self): + 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): + 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 get_classroom(class_id) -> Classroom: """ diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index c9e3f904..3e82f788 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -660,6 +660,28 @@ def mystuff_classes(self, mode: str = "Last created", page: int = None) -> list[ _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 = self._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[dict]: """ Lists the assets that are in the backpack of the user associated with the session. From 0ee943a1ec93789fdfbd805f947b4c0f80302bd3 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Mon, 18 Nov 2024 20:44:15 +0000 Subject: [PATCH 021/101] activity for classes. private activity is in a weird non-normalised format though --- scratchattach/site/activity.py | 37 +++++++++++++++++++-------------- scratchattach/site/classroom.py | 33 ++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/scratchattach/site/activity.py b/scratchattach/site/activity.py index c1cf4c1b..30d1dabe 100644 --- a/scratchattach/site/activity.py +++ b/scratchattach/site/activity.py @@ -16,11 +16,11 @@ from ..utils.requests import Requests as requests -class Activity(BaseSiteComponent): - ''' +class Activity(BaseSiteComponent): + """ Represents a Scratch activity (message or other user page activity) - ''' + """ def str(self): return str(self.raw) @@ -47,28 +47,33 @@ def _update_from_html(self, data): 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.time = _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: diff --git a/scratchattach/site/classroom.py b/scratchattach/site/classroom.py index cb534192..8989ab54 100644 --- a/scratchattach/site/classroom.py +++ b/scratchattach/site/classroom.py @@ -7,11 +7,12 @@ from ..site.session import Session from ..utils.commons import requests -from . import user +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): @@ -213,6 +214,36 @@ def close(self): warnings.warn(f"{self._session} may not be authenticated to edit {self}") raise e + 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): + """ + Get a list of actvity raw dictionaries. However, they are in a very annoying format. This method should be updated + """ + + self._check_session() + + data = requests.get(f"https://scratch.mit.edu/site-api/classrooms/activity/{self.id}/all/", headers=self._headers, cookies=self._cookies).json() + + return data + def get_classroom(class_id) -> Classroom: """ From c56923592b211a201e7250373bf9bcad65edba45 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Mon, 18 Nov 2024 20:57:51 +0000 Subject: [PATCH 022/101] added more parameters for the functions --- scratchattach/site/classroom.py | 17 +++++++++----- scratchattach/site/session.py | 39 +++++++++++++-------------------- scratchattach/utils/commons.py | 20 +++++++++++++++++ 3 files changed, 47 insertions(+), 29 deletions(-) diff --git a/scratchattach/site/classroom.py b/scratchattach/site/classroom.py index 8989ab54..c528a6ee 100644 --- a/scratchattach/site/classroom.py +++ b/scratchattach/site/classroom.py @@ -14,6 +14,7 @@ from bs4 import BeautifulSoup + class Classroom(BaseSiteComponent): def __init__(self, **entries): # Info on how the .update method has to fetch the data: @@ -134,8 +135,8 @@ def _check_session(self): def set_thumbnail(self, thumbnail: bytes): 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}) + headers=self._headers, cookies=self._cookies, + files={"file": thumbnail}) def set_description(self, desc: str): self._check_session() @@ -221,7 +222,9 @@ def public_activity(self, *, limit=20): """ 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') + 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") @@ -233,14 +236,18 @@ def public_activity(self, *, limit=20): return activities - def activity(self): + def activity(self, student: str="all", mode: str = "Last created", page: int = None): """ Get a list of actvity raw dictionaries. However, they are in a very annoying format. This method should be updated """ self._check_session() - data = requests.get(f"https://scratch.mit.edu/site-api/classrooms/activity/{self.id}/all/", headers=self._headers, cookies=self._cookies).json() + 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() return data diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index 3e82f788..57755f5e 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -28,7 +28,7 @@ from ..other import project_json_capabilities from ..utils import commons from ..utils import exceptions -from ..utils.commons import headers, empty_project_json, webscrape_count +from ..utils.commons import headers, empty_project_json, webscrape_count, get_class_sort_mode from ..utils.requests import Requests as requests CREATE_PROJECT_USES = [] @@ -221,27 +221,18 @@ def admin_messages(self, *, limit=40, offset=0) -> list[dict]: limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies ) - @staticmethod - def _get_class_sort_mode(mode: str): - 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 - - def classroom_alerts(self, mode: str = "Last created", page: int = None): - ascsort, descsort = self._get_class_sort_mode(mode) - - data = requests.get("https://scratch.mit.edu/site-api/classrooms/alerts/", + 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 @@ -641,7 +632,7 @@ def mystuff_studios(self, filter_arg: str = "all", *, page: int = 1, sort_by: st 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 = self._get_class_sort_mode(mode) + 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}, @@ -663,7 +654,7 @@ def mystuff_classes(self, mode: str = "Last created", page: int = None) -> list[ 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 = self._get_class_sort_mode(mode) + 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}, diff --git a/scratchattach/utils/commons.py b/scratchattach/utils/commons.py index 6b34b167..f79f8188 100644 --- a/scratchattach/utils/commons.py +++ b/scratchattach/utils/commons.py @@ -169,3 +169,23 @@ def parse_object_list(raw, Class, session=None, primary_key="id") -> list: except Exception as e: print("Warning raised by scratchattach: failed to parse ", raw_dict, "error", e) return results + + +def get_class_sort_mode(mode: 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 From a2b87ef4ba40070b1b7759789b07e8768f15022b Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Mon, 18 Nov 2024 21:42:03 +0000 Subject: [PATCH 023/101] register by class token --- scratchattach/site/classroom.py | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/scratchattach/site/classroom.py b/scratchattach/site/classroom.py index c528a6ee..c7eeac86 100644 --- a/scratchattach/site/classroom.py +++ b/scratchattach/site/classroom.py @@ -18,6 +18,7 @@ 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']}" @@ -46,6 +47,9 @@ def __init__(self, **entries): self._json_headers["accept"] = "application/json" self._json_headers["Content-Type"] = "application/json" + def __repr__(self): + return f"classroom called '{self.title}'" + def _update_from_dict(self, classrooms): try: self.id = int(classrooms["id"]) @@ -215,6 +219,25 @@ def close(self): warnings.warn(f"{self._session} may not be authenticated to edit {self}") raise e + def register_user(self, username: str, password: str, birth_month: int, birth_year: int, + gender: str, country: str, is_robot: bool = False): + return register_user(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: @@ -288,3 +311,25 @@ def get_classroom_from_token(class_token) -> Classroom: """ 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_user(class_id: int, class_token: str, username: str, password: str, birth_month: int, birth_year: int, gender: str, country: str, is_robot: bool = False): + 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}") \ No newline at end of file From 51d342279a28a21c2e1371d11ef8d55470379c03 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 17:35:37 +0000 Subject: [PATCH 024/101] smarter language evaluation in translate function. some credit to thecommcraft --- scratchattach/other/other_apis.py | 18 ++++++++++++++---- scratchattach/utils/supportedlangs.py | 1 + 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index e4401c0b..7360c0ae 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -149,14 +149,24 @@ def scratch_team_members() -> dict: def translate(language: str | Languages, text: str = "hello"): - if language.lower() not in Languages.all_of("code", str.lower): - if language.lower() in Languages.all_of("name", str.lower): - language = Languages.find(language.lower(), apply_func=str.lower).code + lang = language + if isinstance(language, str): + if language.lower() in Languages.all_of("code", str.lower): + lang = Languages.find(language.lower(), "code", apply_func=str.lower) + + elif language.lower() in Languages.all_of("name", str.lower): + lang = Languages.find(language.lower(), apply_func=str.lower) + + elif isinstance(language, Languages): + lang = language.value + else: + # The code will work so long as the language has a 'code' attribute, however, this is bad practice. + warnings.warn(f"{language} is not {str} or {Languages}, but {type(language)}.") - lang = Languages.find(language, "code", str.lower) if lang is None: raise ValueError(f"{language} is not a supported translate language") + print(lang.__dict__) response_json = requests.get( f"https://translate-service.scratch.mit.edu/translate?language={lang.code}&text={text}").json() diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index d6ac0f46..bdfdba55 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -116,6 +116,7 @@ def apply_func(x): for lang_enum in Languages: lang = lang_enum.value + try: if apply_func(getattr(lang, by)) == value: return lang From cec1a1234fe8c27df914aa47fcba3657ea7b4729 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 17:41:48 +0000 Subject: [PATCH 025/101] dataclasses = conciseness & clean --- scratchattach/other/other_apis.py | 1 - scratchattach/utils/supportedlangs.py | 30 +++++++-------------------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 7360c0ae..c5fd5491 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -166,7 +166,6 @@ def translate(language: str | Languages, text: str = "hello"): if lang is None: raise ValueError(f"{language} is not a supported translate language") - print(lang.__dict__) response_json = requests.get( f"https://translate-service.scratch.mit.edu/translate?language={lang.code}&text={text}").json() diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index bdfdba55..f1102209 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -4,32 +4,18 @@ """ from enum import Enum +from dataclasses import dataclass + from typing import Callable +@dataclass(init=True, repr=True) class _Language: - def __init__(self, name: str = None, code: str = None, locales: list[str] = None, tts_locale: str = None, - single_gender: bool = None): - self.name = name - self.code = code - self.locales = locales - self.tts_locale = tts_locale - self.single_gender = single_gender - - def __repr__(self): - ret = "Language(" - for attr in self.__dict__.keys(): - if not attr.startswith("_"): - val = getattr(self, attr) - ret += f"{repr(val)}, " - if ret.endswith(", "): - ret = ret[:-2] - - ret += ')' - return ret - - def __str__(self): - return f"Language<{self.name} - {self.code}>" + name: str = None + code: str = None + locales: list[str] = None + tts_locale: str = None + single_gender: bool = None class Languages(Enum): From a5c48a00b844a55c1211cd01e5d5b441284519ec Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 17:48:34 +0000 Subject: [PATCH 026/101] docstrings and removing use of kwarg for all_of --- scratchattach/other/other_apis.py | 4 ++-- scratchattach/utils/supportedlangs.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index c5fd5491..ee4598a0 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -152,10 +152,10 @@ def translate(language: str | Languages, text: str = "hello"): lang = language if isinstance(language, str): if language.lower() in Languages.all_of("code", str.lower): - lang = Languages.find(language.lower(), "code", apply_func=str.lower) + lang = Languages.find(language.lower(), "code", str.lower) elif language.lower() in Languages.all_of("name", str.lower): - lang = Languages.find(language.lower(), apply_func=str.lower) + lang = Languages.find(language.lower(), "name", str.lower) elif isinstance(language, Languages): lang = language.value diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index f1102209..549043c6 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -96,6 +96,13 @@ class Languages(Enum): @staticmethod def find(value, by: str = "name", apply_func: Callable = None) -> _Language: + """ + Finds the language 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 @@ -111,6 +118,16 @@ def apply_func(x): @staticmethod def all_of(attr_name: str = "name", apply_func: Callable = None): + """ + Returns the list of each listed language'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 From b6c565eb60180e2e9af0657b4beaad69096e4989 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 17:35:37 +0000 Subject: [PATCH 027/101] smarter language evaluation in translate function. some credit to thecommcraft (cherry picked from commit 51d342279a28a21c2e1371d11ef8d55470379c03) --- scratchattach/other/other_apis.py | 18 ++++++++++++++---- scratchattach/utils/supportedlangs.py | 1 + 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index e4401c0b..7360c0ae 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -149,14 +149,24 @@ def scratch_team_members() -> dict: def translate(language: str | Languages, text: str = "hello"): - if language.lower() not in Languages.all_of("code", str.lower): - if language.lower() in Languages.all_of("name", str.lower): - language = Languages.find(language.lower(), apply_func=str.lower).code + lang = language + if isinstance(language, str): + if language.lower() in Languages.all_of("code", str.lower): + lang = Languages.find(language.lower(), "code", apply_func=str.lower) + + elif language.lower() in Languages.all_of("name", str.lower): + lang = Languages.find(language.lower(), apply_func=str.lower) + + elif isinstance(language, Languages): + lang = language.value + else: + # The code will work so long as the language has a 'code' attribute, however, this is bad practice. + warnings.warn(f"{language} is not {str} or {Languages}, but {type(language)}.") - lang = Languages.find(language, "code", str.lower) if lang is None: raise ValueError(f"{language} is not a supported translate language") + print(lang.__dict__) response_json = requests.get( f"https://translate-service.scratch.mit.edu/translate?language={lang.code}&text={text}").json() diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index d6ac0f46..bdfdba55 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -116,6 +116,7 @@ def apply_func(x): for lang_enum in Languages: lang = lang_enum.value + try: if apply_func(getattr(lang, by)) == value: return lang From e3c476936f68f31dcd26364a3228271a4bfa1a5e Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 17:41:48 +0000 Subject: [PATCH 028/101] dataclasses = conciseness & clean (cherry picked from commit cec1a1234fe8c27df914aa47fcba3657ea7b4729) --- scratchattach/other/other_apis.py | 1 - scratchattach/utils/supportedlangs.py | 30 +++++++-------------------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 7360c0ae..c5fd5491 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -166,7 +166,6 @@ def translate(language: str | Languages, text: str = "hello"): if lang is None: raise ValueError(f"{language} is not a supported translate language") - print(lang.__dict__) response_json = requests.get( f"https://translate-service.scratch.mit.edu/translate?language={lang.code}&text={text}").json() diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index bdfdba55..f1102209 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -4,32 +4,18 @@ """ from enum import Enum +from dataclasses import dataclass + from typing import Callable +@dataclass(init=True, repr=True) class _Language: - def __init__(self, name: str = None, code: str = None, locales: list[str] = None, tts_locale: str = None, - single_gender: bool = None): - self.name = name - self.code = code - self.locales = locales - self.tts_locale = tts_locale - self.single_gender = single_gender - - def __repr__(self): - ret = "Language(" - for attr in self.__dict__.keys(): - if not attr.startswith("_"): - val = getattr(self, attr) - ret += f"{repr(val)}, " - if ret.endswith(", "): - ret = ret[:-2] - - ret += ')' - return ret - - def __str__(self): - return f"Language<{self.name} - {self.code}>" + name: str = None + code: str = None + locales: list[str] = None + tts_locale: str = None + single_gender: bool = None class Languages(Enum): From 8690df15c80ef8390a2a3f1734c342ea56109a3b Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 17:48:34 +0000 Subject: [PATCH 029/101] docstrings and removing use of kwarg for all_of (cherry picked from commit a5c48a00b844a55c1211cd01e5d5b441284519ec) --- scratchattach/other/other_apis.py | 4 ++-- scratchattach/utils/supportedlangs.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index c5fd5491..ee4598a0 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -152,10 +152,10 @@ def translate(language: str | Languages, text: str = "hello"): lang = language if isinstance(language, str): if language.lower() in Languages.all_of("code", str.lower): - lang = Languages.find(language.lower(), "code", apply_func=str.lower) + lang = Languages.find(language.lower(), "code", str.lower) elif language.lower() in Languages.all_of("name", str.lower): - lang = Languages.find(language.lower(), apply_func=str.lower) + lang = Languages.find(language.lower(), "name", str.lower) elif isinstance(language, Languages): lang = language.value diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index f1102209..549043c6 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -96,6 +96,13 @@ class Languages(Enum): @staticmethod def find(value, by: str = "name", apply_func: Callable = None) -> _Language: + """ + Finds the language 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 @@ -111,6 +118,16 @@ def apply_func(x): @staticmethod def all_of(attr_name: str = "name", apply_func: Callable = None): + """ + Returns the list of each listed language'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 From 6ce4ff7c4781b0fd493b864f0aace3e0232fd815 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 18:43:00 +0000 Subject: [PATCH 030/101] merge tts chinese with translate versions, same with portuguese (not brazilian) --- scratchattach/utils/supportedlangs.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index 549043c6..2cee9061 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -28,7 +28,7 @@ class Languages(Enum): 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', 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) @@ -65,7 +65,7 @@ class Languages(Enum): 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', None, None, None) + 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) @@ -83,14 +83,12 @@ class Languages(Enum): 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', None, None, None) + Chinese_Simplified = _Language('Chinese (Simplified)', 'zh-cn', ['zh-cn', 'zh-tw'], 'cmn-CN', True) Mandarin = Chinese_Simplified - cmn_CN = _Language(None, None, ['zh-cn', 'zh-tw'], 'cmn-CN', True) nb_NO = _Language(None, None, ['nb', 'nn'], 'nb-NO', True) pt_BR = _Language(None, None, ['pt-br'], 'pt-BR', False) Brazilian = pt_BR - pt_PT = _Language(None, None, ['pt'], 'pt-PT', False) es_ES = _Language(None, None, ['es'], 'es-ES', False) es_US = _Language(None, None, ['es-419'], 'es-US', False) From 3e9f72e994f7ee1ea1ddc536d5d3958d823e97ac Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 18:43:00 +0000 Subject: [PATCH 031/101] merge tts chinese with translate versions, same with portuguese (not brazilian) (cherry picked from commit 6ce4ff7c4781b0fd493b864f0aace3e0232fd815) --- scratchattach/utils/supportedlangs.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py index 549043c6..2cee9061 100644 --- a/scratchattach/utils/supportedlangs.py +++ b/scratchattach/utils/supportedlangs.py @@ -28,7 +28,7 @@ class Languages(Enum): 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', 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) @@ -65,7 +65,7 @@ class Languages(Enum): 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', None, None, None) + 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) @@ -83,14 +83,12 @@ class Languages(Enum): 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', None, None, None) + Chinese_Simplified = _Language('Chinese (Simplified)', 'zh-cn', ['zh-cn', 'zh-tw'], 'cmn-CN', True) Mandarin = Chinese_Simplified - cmn_CN = _Language(None, None, ['zh-cn', 'zh-tw'], 'cmn-CN', True) nb_NO = _Language(None, None, ['nb', 'nn'], 'nb-NO', True) pt_BR = _Language(None, None, ['pt-br'], 'pt-BR', False) Brazilian = pt_BR - pt_PT = _Language(None, None, ['pt'], 'pt-PT', False) es_ES = _Language(None, None, ['es'], 'es-ES', False) es_US = _Language(None, None, ['es-419'], 'es-US', False) From a6c6168caed23cf862ac6ec4e4c1e78c718e48e8 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 21:51:11 +0000 Subject: [PATCH 032/101] using enums for tts genders asw + enum wrapper with finding by multiple attrs --- scratchattach/__init__.py | 2 +- scratchattach/other/other_apis.py | 76 +++++------ scratchattach/utils/enums.py | 187 ++++++++++++++++++++++++++ scratchattach/utils/exceptions.py | 14 ++ scratchattach/utils/supportedlangs.py | 140 ------------------- 5 files changed, 235 insertions(+), 184 deletions(-) create mode 100644 scratchattach/utils/enums.py delete mode 100644 scratchattach/utils/supportedlangs.py diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index 8628bddb..0ec563e1 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -10,7 +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.supportedlangs import Languages +from .utils.enums import Languages, TTSVoices from .site.activity import Activity from .site.backpack_asset import BackpackAsset diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index ee4598a0..976910fc 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -4,9 +4,9 @@ import warnings from ..utils import commons -from ..utils.exceptions import BadRequest +from ..utils.exceptions import BadRequest, InvalidLanguage, InvalidTTSGender from ..utils.requests import Requests as requests -from ..utils.supportedlangs import Languages +from ..utils.enums import Languages, Language, TTSVoices, TTSVoice # --- Front page --- @@ -149,22 +149,15 @@ def scratch_team_members() -> dict: def translate(language: str | Languages, text: str = "hello"): - lang = language if isinstance(language, str): - if language.lower() in Languages.all_of("code", str.lower): - lang = Languages.find(language.lower(), "code", str.lower) - - elif language.lower() in Languages.all_of("name", str.lower): - lang = Languages.find(language.lower(), "name", str.lower) - + lang = Languages.find_by_attrs(language.lower(), ["code", "tts_locale", "name"], str.lower) elif isinstance(language, Languages): lang = language.value else: - # The code will work so long as the language has a 'code' attribute, however, this is bad practice. - warnings.warn(f"{language} is not {str} or {Languages}, but {type(language)}.") + lang = language - if lang is None: - raise ValueError(f"{language} is not a supported translate language") + if not isinstance(lang, Language): + raise InvalidLanguage(f"{language} is not a supported translate language") response_json = requests.get( f"https://translate-service.scratch.mit.edu/translate?language={lang.code}&text={text}").json() @@ -175,46 +168,43 @@ def translate(language: str | Languages, text: str = "hello"): raise BadRequest(f"Language '{language}' does not seem to be valid.\nResponse: {response_json}") -def text2speech(text: str = "hello", gender: str = "female", language: str = "en-US"): +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 gender == "female" or gender == "alto": - gender = ("female", 1) - elif gender == "male" or gender == "tenor": - gender = ("male", 1) - elif gender == "squeak": - gender = ("female", 1.19) - elif gender == "giant": - gender = ("male", .84) - elif gender == "kitten": - gender = ("female", 1.41) - split = text.split(' ') - text = '' - for token in split: - if token.strip() != '': - text += "meow " + 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: - gender = ("female", 1) + voice = voice_name - og_lang = language - if isinstance(language, Languages): - language = language.value.tts_locale + if not isinstance(voice, TTSVoice): + raise InvalidTTSGender(f"TTS Gender {voice_name} is not supported.") - if language is None: - raise ValueError(f"Language '{og_lang}' is not a supported tts language") + # 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 language.lower() not in Languages.all_of("tts_locale", str.lower): - if language.lower() in Languages.all_of("name", str.lower): - language = Languages.find(language.lower(), apply_func=str.lower).tts_locale + if not isinstance(lang, Language): + raise InvalidLanguage(f"Language '{language}' is not a language") - lang = Languages.find(language, "tts_locale") - if lang is None or language is None: - raise ValueError(f"Language '{og_lang}' is not a supported tts 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={gender[0]}&text={text}") - return response.content, gender[1] + f"?locale={lang.tts_locale}&gender={voice.gender}&text={text}") + return response.content, voice.playback_rate diff --git a/scratchattach/utils/enums.py b/scratchattach/utils/enums.py new file mode 100644 index 00000000..9a0c36eb --- /dev/null +++ b/scratchattach/utils/enums.py @@ -0,0 +1,187 @@ +""" +List of supported languages of scratch's translate and text2speech extensions. +Adapted from https://translate-service.scratch.mit.edu/supported?language=en +""" + +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 apply_func(getattr(item_obj, by)) == 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: + 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 8167a2f2..17172255 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -89,6 +89,20 @@ 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): diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py deleted file mode 100644 index 2cee9061..00000000 --- a/scratchattach/utils/supportedlangs.py +++ /dev/null @@ -1,140 +0,0 @@ -""" -List of supported languages of scratch's translate and text2speech extensions. -Adapted from https://translate-service.scratch.mit.edu/supported?language=en -""" - -from enum import Enum -from dataclasses import dataclass - -from typing import Callable - - -@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 Languages(Enum): - 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) - - @staticmethod - def find(value, by: str = "name", apply_func: Callable = None) -> _Language: - """ - Finds the language 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 lang_enum in Languages: - lang = lang_enum.value - - try: - if apply_func(getattr(lang, by)) == value: - return lang - except TypeError: - pass - - @staticmethod - def all_of(attr_name: str = "name", apply_func: Callable = None): - """ - Returns the list of each listed language'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 lang_enum in Languages: - lang = lang_enum.value - attr = getattr(lang, attr_name) - try: - yield apply_func(attr) - - except TypeError: - yield attr From 6e92961ea45743726f5aa03d348d8f18685edc8b Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 21:51:11 +0000 Subject: [PATCH 033/101] using enums for tts genders asw + enum wrapper with finding by multiple attrs (cherry picked from commit a6c6168caed23cf862ac6ec4e4c1e78c718e48e8) --- scratchattach/__init__.py | 2 +- scratchattach/other/other_apis.py | 76 +++++------ scratchattach/utils/enums.py | 187 ++++++++++++++++++++++++++ scratchattach/utils/exceptions.py | 33 ++++- scratchattach/utils/supportedlangs.py | 140 ------------------- 5 files changed, 249 insertions(+), 189 deletions(-) create mode 100644 scratchattach/utils/enums.py delete mode 100644 scratchattach/utils/supportedlangs.py diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index 8628bddb..0ec563e1 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -10,7 +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.supportedlangs import Languages +from .utils.enums import Languages, TTSVoices from .site.activity import Activity from .site.backpack_asset import BackpackAsset diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index ee4598a0..976910fc 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -4,9 +4,9 @@ import warnings from ..utils import commons -from ..utils.exceptions import BadRequest +from ..utils.exceptions import BadRequest, InvalidLanguage, InvalidTTSGender from ..utils.requests import Requests as requests -from ..utils.supportedlangs import Languages +from ..utils.enums import Languages, Language, TTSVoices, TTSVoice # --- Front page --- @@ -149,22 +149,15 @@ def scratch_team_members() -> dict: def translate(language: str | Languages, text: str = "hello"): - lang = language if isinstance(language, str): - if language.lower() in Languages.all_of("code", str.lower): - lang = Languages.find(language.lower(), "code", str.lower) - - elif language.lower() in Languages.all_of("name", str.lower): - lang = Languages.find(language.lower(), "name", str.lower) - + lang = Languages.find_by_attrs(language.lower(), ["code", "tts_locale", "name"], str.lower) elif isinstance(language, Languages): lang = language.value else: - # The code will work so long as the language has a 'code' attribute, however, this is bad practice. - warnings.warn(f"{language} is not {str} or {Languages}, but {type(language)}.") + lang = language - if lang is None: - raise ValueError(f"{language} is not a supported translate language") + if not isinstance(lang, Language): + raise InvalidLanguage(f"{language} is not a supported translate language") response_json = requests.get( f"https://translate-service.scratch.mit.edu/translate?language={lang.code}&text={text}").json() @@ -175,46 +168,43 @@ def translate(language: str | Languages, text: str = "hello"): raise BadRequest(f"Language '{language}' does not seem to be valid.\nResponse: {response_json}") -def text2speech(text: str = "hello", gender: str = "female", language: str = "en-US"): +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 gender == "female" or gender == "alto": - gender = ("female", 1) - elif gender == "male" or gender == "tenor": - gender = ("male", 1) - elif gender == "squeak": - gender = ("female", 1.19) - elif gender == "giant": - gender = ("male", .84) - elif gender == "kitten": - gender = ("female", 1.41) - split = text.split(' ') - text = '' - for token in split: - if token.strip() != '': - text += "meow " + 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: - gender = ("female", 1) + voice = voice_name - og_lang = language - if isinstance(language, Languages): - language = language.value.tts_locale + if not isinstance(voice, TTSVoice): + raise InvalidTTSGender(f"TTS Gender {voice_name} is not supported.") - if language is None: - raise ValueError(f"Language '{og_lang}' is not a supported tts language") + # 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 language.lower() not in Languages.all_of("tts_locale", str.lower): - if language.lower() in Languages.all_of("name", str.lower): - language = Languages.find(language.lower(), apply_func=str.lower).tts_locale + if not isinstance(lang, Language): + raise InvalidLanguage(f"Language '{language}' is not a language") - lang = Languages.find(language, "tts_locale") - if lang is None or language is None: - raise ValueError(f"Language '{og_lang}' is not a supported tts 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={gender[0]}&text={text}") - return response.content, gender[1] + f"?locale={lang.tts_locale}&gender={voice.gender}&text={text}") + return response.content, voice.playback_rate diff --git a/scratchattach/utils/enums.py b/scratchattach/utils/enums.py new file mode 100644 index 00000000..9a0c36eb --- /dev/null +++ b/scratchattach/utils/enums.py @@ -0,0 +1,187 @@ +""" +List of supported languages of scratch's translate and text2speech extensions. +Adapted from https://translate-service.scratch.mit.edu/supported?language=en +""" + +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 apply_func(getattr(item_obj, by)) == 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: + 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..82e1514a 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -18,7 +18,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): @@ -32,7 +31,6 @@ def __init__(self, message=""): self.message = "The user corresponding to the connected login / session is not allowed to perform this action." super().__init__(self.message) - pass class XTokenError(Exception): """ @@ -43,6 +41,7 @@ class XTokenError(Exception): pass + # Not found errors: class UserNotFound(Exception): @@ -60,6 +59,7 @@ class ProjectNotFound(Exception): pass + class ClassroomNotFound(Exception): """ Raised when a non-existent Classroom is requested. @@ -75,15 +75,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 +112,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 +120,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 +136,7 @@ class Response429(Exception): pass + class CommentPostFailure(Exception): """ Raised when a comment fails to post. This can have various reasons. @@ -124,12 +144,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 +159,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 +195,12 @@ 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 diff --git a/scratchattach/utils/supportedlangs.py b/scratchattach/utils/supportedlangs.py deleted file mode 100644 index 2cee9061..00000000 --- a/scratchattach/utils/supportedlangs.py +++ /dev/null @@ -1,140 +0,0 @@ -""" -List of supported languages of scratch's translate and text2speech extensions. -Adapted from https://translate-service.scratch.mit.edu/supported?language=en -""" - -from enum import Enum -from dataclasses import dataclass - -from typing import Callable - - -@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 Languages(Enum): - 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) - - @staticmethod - def find(value, by: str = "name", apply_func: Callable = None) -> _Language: - """ - Finds the language 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 lang_enum in Languages: - lang = lang_enum.value - - try: - if apply_func(getattr(lang, by)) == value: - return lang - except TypeError: - pass - - @staticmethod - def all_of(attr_name: str = "name", apply_func: Callable = None): - """ - Returns the list of each listed language'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 lang_enum in Languages: - lang = lang_enum.value - attr = getattr(lang, attr_name) - try: - yield apply_func(attr) - - except TypeError: - yield attr From 7d71d969e5cf1ccc3e1a11ca6cfc330085bd7e45 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 22:08:47 +0000 Subject: [PATCH 034/101] make sure language has code for translation --- scratchattach/other/other_apis.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 976910fc..c9519b87 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -1,7 +1,6 @@ """Other Scratch API-related functions""" import json -import warnings from ..utils import commons from ..utils.exceptions import BadRequest, InvalidLanguage, InvalidTTSGender @@ -157,7 +156,10 @@ def translate(language: str | Languages, text: str = "hello"): lang = language if not isinstance(lang, Language): - raise InvalidLanguage(f"{language} is not a supported translate 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() From 2104d002821d45b9e02d79efa47b228667e099a9 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 22:08:47 +0000 Subject: [PATCH 035/101] make sure language has code for translation (cherry picked from commit 7d71d969e5cf1ccc3e1a11ca6cfc330085bd7e45) --- scratchattach/other/other_apis.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 976910fc..c9519b87 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -1,7 +1,6 @@ """Other Scratch API-related functions""" import json -import warnings from ..utils import commons from ..utils.exceptions import BadRequest, InvalidLanguage, InvalidTTSGender @@ -157,7 +156,10 @@ def translate(language: str | Languages, text: str = "hello"): lang = language if not isinstance(lang, Language): - raise InvalidLanguage(f"{language} is not a supported translate 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() From 9ec03bc1af1ac653096f10992cadb8a0ffb41869 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Thu, 21 Nov 2024 17:06:56 +0000 Subject: [PATCH 036/101] 1 docstring --- scratchattach/utils/enums.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scratchattach/utils/enums.py b/scratchattach/utils/enums.py index 9a0c36eb..0fbc0ffc 100644 --- a/scratchattach/utils/enums.py +++ b/scratchattach/utils/enums.py @@ -68,6 +68,9 @@ def apply_func(x): @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: From 24c7f1fb754be616a74df8b7b3e65b7503e87e06 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Thu, 21 Nov 2024 17:06:56 +0000 Subject: [PATCH 037/101] 1 docstring --- scratchattach/utils/enums.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scratchattach/utils/enums.py b/scratchattach/utils/enums.py index 9a0c36eb..0fbc0ffc 100644 --- a/scratchattach/utils/enums.py +++ b/scratchattach/utils/enums.py @@ -68,6 +68,9 @@ def apply_func(x): @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: From d0f9356a1570c8c7fd238f4e83a97ffb23958b80 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Thu, 21 Nov 2024 18:49:34 +0000 Subject: [PATCH 038/101] removed json.dumps, using json in requests.post instead --- scratchattach/site/session.py | 5 ++--- scratchattach/utils/exceptions.py | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index 57755f5e..0c79f241 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -1015,11 +1015,10 @@ def login(username, password, *, timeout=10) -> 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, + "https://scratch.mit.edu/login/", json={"username": username, "password": password}, headers=_headers, timeout=timeout, ) try: diff --git a/scratchattach/utils/exceptions.py b/scratchattach/utils/exceptions.py index 17172255..4b24a4b7 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -90,6 +90,7 @@ class CommentNotFound(Exception): # Invalid inputs + class InvalidLanguage(Exception): """ Raised when an invalid language/language code/language object is provided, for TTS or Translate @@ -103,6 +104,7 @@ class InvalidTTSGender(Exception): """ pass + # API errors: class LoginFailure(Exception): From 7752050614b5c13db613ee080af4a1312c617842 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Thu, 21 Nov 2024 22:12:57 +0000 Subject: [PATCH 039/101] added support for closed classes using bs4 --- scratchattach/site/classroom.py | 104 ++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 18 deletions(-) diff --git a/scratchattach/site/classroom.py b/scratchattach/site/classroom.py index c7eeac86..bc7ed396 100644 --- a/scratchattach/site/classroom.py +++ b/scratchattach/site/classroom.py @@ -1,8 +1,9 @@ import datetime import warnings - from typing import TYPE_CHECKING +import bs4 + if TYPE_CHECKING: from ..site.session import Session @@ -50,6 +51,39 @@ def __init__(self, **entries): def __repr__(self): return f"classroom called '{self.title}'" + 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") + # id, title, description, status, date_start (iso str), 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"]) @@ -79,6 +113,7 @@ def _update_from_dict(self, classrooms): self.author._update_from_dict(classrooms["educator"]) except Exception: pass + self.is_closed = classrooms.get("is_closed", False) return True def student_count(self): @@ -99,6 +134,22 @@ 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"}): + if len(scrollable.contents) > 0: + 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 @@ -114,7 +165,7 @@ def class_studio_count(self): ).text return commons.webscrape_count(text, "Class Studios (", ")") - def class_studio_ids(self, *, page=1): + def class_studio_ids(self, *, page=1) -> list[int]: """ Returns the class studio on the class. @@ -124,6 +175,21 @@ def class_studio_ids(self, *, page=1): Returns: 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"}): + if len(scrollable.contents) > 0: + 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 @@ -219,9 +285,10 @@ def close(self): warnings.warn(f"{self._session} may not be authenticated to edit {self}") raise e - def register_user(self, username: str, password: str, birth_month: int, birth_year: int, - gender: str, country: str, is_robot: bool = False): - return register_user(self.id, self.classtoken, username, password, birth_month, birth_year, gender, country, is_robot) + 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): + 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: @@ -229,7 +296,8 @@ def generate_signup_link(self): 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) + 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: @@ -237,7 +305,6 @@ def generate_signup_link(self): else: raise exceptions.Unauthorized(f"{self._session} is not authorised to generate a signup link of {self}") - def public_activity(self, *, limit=20): """ Returns: @@ -259,7 +326,7 @@ def public_activity(self, *, limit=20): return activities - def activity(self, student: str="all", mode: str = "Last created", page: int = None): + def activity(self, student: str = "all", mode: str = "Last created", page: int = None): """ Get a list of actvity raw dictionaries. However, they are in a very annoying format. This method should be updated """ @@ -313,17 +380,18 @@ def get_classroom_from_token(class_token) -> Classroom: return commons._get_object("classtoken", class_token, Classroom, exceptions.ClassroomNotFound) -def register_user(class_id: int, class_token: str, username: str, password: str, birth_month: int, birth_year: int, gender: str, country: str, is_robot: bool = False): +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): data = {"classroom_id": class_id, - "classroom_token": class_token, + "classroom_token": class_token, - "username": username, - "password": password, - "birth_month": birth_month, - "birth_year": birth_year, - "gender": gender, - "country": country, - "is_robot": is_robot} + "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'}) @@ -332,4 +400,4 @@ def register_user(class_id: int, class_token: str, username: str, password: str, if "username" in ret: return else: - raise exceptions.Unauthorized(f"Can't create account: {response.text}") \ No newline at end of file + raise exceptions.Unauthorized(f"Can't create account: {response.text}") From 9f8f7278c2d9d1d306f4955674f82f388653f710 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Thu, 21 Nov 2024 22:29:56 +0000 Subject: [PATCH 040/101] studio adding and code simplification --- scratchattach/site/classroom.py | 37 ++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/scratchattach/site/classroom.py b/scratchattach/site/classroom.py index bc7ed396..6a96ad4f 100644 --- a/scratchattach/site/classroom.py +++ b/scratchattach/site/classroom.py @@ -47,6 +47,7 @@ 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): return f"classroom called '{self.title}'" @@ -140,13 +141,12 @@ def student_names(self, *, page=1): soup = BeautifulSoup(response.text, "html.parser") for scrollable in soup.find_all("ul", {"class": "scroll-content"}): - if len(scrollable.contents) > 0: - 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()) + 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 @@ -181,13 +181,12 @@ def class_studio_ids(self, *, page=1) -> list[int]: soup = BeautifulSoup(response.text, "html.parser") for scrollable in soup.find_all("ul", {"class": "scroll-content"}): - if len(scrollable.contents) > 0: - 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/", "/")) + 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( @@ -262,6 +261,16 @@ def set_title(self, title: str): warnings.warn(f"{self._session} may not be authenticated to edit {self}") raise e + def add_studio(self, name: str, description: str = ''): + 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): self._check_session() response = requests.put(f"https://scratch.mit.edu/site-api/classrooms/all/{self.id}/", From 7af67a94b9087b79b047f20f837e8f66cfa30fa3 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sat, 23 Nov 2024 15:01:53 +0000 Subject: [PATCH 041/101] project attrs (no sprites yet) --- scratchattach/__init__.py | 2 + scratchattach/editor/__init__.py | 1 + scratchattach/editor/base.py | 42 +++++++++ scratchattach/editor/commons.py | 131 +++++++++++++++++++++++++++ scratchattach/editor/extension.py | 41 +++++++++ scratchattach/editor/meta.py | 72 +++++++++++++++ scratchattach/editor/monitor.py | 141 ++++++++++++++++++++++++++++++ scratchattach/editor/project.py | 57 ++++++++++++ scratchattach/editor/sprite.py | 13 +++ scratchattach/utils/exceptions.py | 9 ++ 10 files changed, 509 insertions(+) create mode 100644 scratchattach/editor/__init__.py create mode 100644 scratchattach/editor/base.py create mode 100644 scratchattach/editor/commons.py create mode 100644 scratchattach/editor/extension.py create mode 100644 scratchattach/editor/meta.py create mode 100644 scratchattach/editor/monitor.py create mode 100644 scratchattach/editor/project.py create mode 100644 scratchattach/editor/sprite.py diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index 0ec563e1..eaa83072 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -23,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/editor/__init__.py b/scratchattach/editor/__init__.py new file mode 100644 index 00000000..5ce17c61 --- /dev/null +++ b/scratchattach/editor/__init__.py @@ -0,0 +1 @@ +from .project import Project diff --git a/scratchattach/editor/base.py b/scratchattach/editor/base.py new file mode 100644 index 00000000..76420fdd --- /dev/null +++ b/scratchattach/editor/base.py @@ -0,0 +1,42 @@ +""" +Editor base classes +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from . import project + from . import sprite + + +class ProjectPart(ABC): + @staticmethod + @abstractmethod + def from_json(data: dict | list | Any): + pass + + @abstractmethod + def to_json(self) -> dict | list | Any: + pass + + +class ProjectSubcomponent(ProjectPart, ABC): + def __init__(self, _project: project.Project): + self.project = _project + + +class SpriteSubComponent(ProjectPart, ABC): + def __init__(self, _sprite: sprite.Sprite): + self.sprite = _sprite + + @property + def project(self) -> project.Project: + return self.sprite.project + + +class IDComponent(SpriteSubComponent, ABC): + def __init__(self, _id: str, _sprite: sprite.Sprite): + self.id = _id + super().__init__(_sprite) diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py new file mode 100644 index 00000000..911b4a36 --- /dev/null +++ b/scratchattach/editor/commons.py @@ -0,0 +1,131 @@ +""" +Shared functions used by the editor module +""" + +import json +from typing import Final + +from ..utils import exceptions + +DIGITS: Final = tuple("0123456789") + + +def _read_json_number(string: str) -> float | int: + ret = '' + + minus = string[0] == '-' + if minus: + ret += '-' + string = string[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 string[0] == '0': + ret += '0' + string = string[1:] + + elif string[0] in DIGITS[1:9]: + while string[0] in DIGITS: + ret += string[0] + string = string[1:] + + frac, string = read_fraction(string) + ret += frac + + ret += read_exponent(string) + + return json.loads(ret) + + +def consume_json(string: str, i: int = 0) -> str | float | int | dict | list | bool | None: + """ + 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(string[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(string: str, i: int = 0) -> bool: + try: + consume_json(string, i) + return True + + except exceptions.UnclosedJSONError: + return False + + except ValueError: + return False + + +def is_valid_json(string: str) -> bool: + try: + json.loads(string) + return True + except ValueError: + return False diff --git a/scratchattach/editor/extension.py b/scratchattach/editor/extension.py new file mode 100644 index 00000000..83a34242 --- /dev/null +++ b/scratchattach/editor/extension.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass + +from . import base +from ..utils import enums + + +@dataclass(init=True, repr=True) +class Extension(base.ProjectPart): + 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/meta.py b/scratchattach/editor/meta.py new file mode 100644 index 00000000..e93a81a5 --- /dev/null +++ b/scratchattach/editor/meta.py @@ -0,0 +1,72 @@ +import re + +from . import base + +DEFAULT_VM = "0.1.0" +DEFAULT_AGENT = "scratchattach.editor by https://scratch.mit.edu/users/timmccool/" +DEFAULT_PLATFORM = { + "name": "scratchattach", + "url": "https://github.com/timMcCool/scratchattach/" +} + +EDIT_META = True +META_SET_PLATFORM = False + + +class Meta(base.ProjectPart): + def __init__(self, semver: str = "3.0.0", vm: str = DEFAULT_VM, agent: str = DEFAULT_AGENT, + platform: dict = 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.copy() + + + # Thanks to TurboWarp for this pattern ↓↓↓↓, I just copied it + if re.match("^([0-9]+\\.[0-9]+\\.[0-9]+)($|-)", vm) is None: + raise ValueError( + f"\"{vm}\" does not match pattern \"^([0-9]+\\.[0-9]+\\.[0-9]+)($|-)\" - maybe try \"0.0.0\"?") + + self.semver = semver + self.vm = vm + self.agent = agent + self.platform = platform + + def __repr__(self): + data = f"{self.semver} : {self.vm} : {self.agent}" + if self.platform: + data += f": {self.platform}" + + return f"Meta<{data}>" + + def to_json(self): + _json = { + "semver": self.semver, + "vm": self.vm, + "agent": self.agent + } + + if self.platform is not None: + _json["platform"] = self.platform + return _json + + @staticmethod + def from_json(data): + if data is None: + data = "" + + semver = data["semver"] + vm = data.get("vm") + agent = data.get("agent") + platform = data.get("platform") + + if EDIT_META or vm is None: + vm = DEFAULT_VM + if EDIT_META or agent is None: + agent = DEFAULT_AGENT + if META_SET_PLATFORM and (EDIT_META or platform is None): + platform = DEFAULT_PLATFORM.copy() + + return Meta(semver, vm, agent, platform) diff --git a/scratchattach/editor/monitor.py b/scratchattach/editor/monitor.py new file mode 100644 index 00000000..7f3f2e49 --- /dev/null +++ b/scratchattach/editor/monitor.py @@ -0,0 +1,141 @@ +from typing import Any + +from . import base + + +class Monitor(base.ProjectPart): + def __init__(self, reporter: base.SpriteSubComponent = 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): + """ + Represents a variable/list monitor + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Monitors + """ + assert isinstance(reporter, base.SpriteSubComponent) + + 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 + + def __repr__(self): + return f"Monitor<{self.opcode}>" + + @staticmethod + def from_json(data: dict | list | Any): + _id = data["id"] + mode = data["mode"] + + opcode = data["opcode"] + params = 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(_id, mode, opcode, params, sprite_name, value, width, height, x, y, visible, slider_min, + slider_max, is_discrete) + + def to_json(self): + _json = { + "id": f"PLEASE GET ID FROM VALUE {self.reporter}", + "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 + + # @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/project.py b/scratchattach/editor/project.py new file mode 100644 index 00000000..57b3cb48 --- /dev/null +++ b/scratchattach/editor/project.py @@ -0,0 +1,57 @@ +import json +from io import BytesIO, TextIOWrapper +from typing import Any, Iterable +from zipfile import ZipFile + +from . import base, meta, extension, monitor + + +class Project(base.ProjectPart): + def __init__(self, _meta: meta.Meta = None, _extensions: Iterable[extension.Extension] = (), + _monitors: Iterable[monitor.Monitor] = ()): + if _meta is None: + _meta = meta.Meta() + + self.meta = _meta + self.extensions = _extensions + self.monitors = _monitors + + def to_json(self) -> dict | list | Any: + pass + + @staticmethod + def from_json(data: dict | list | Any): + 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)) + + return Project(_meta, _extensions, _monitors) + + @staticmethod + def from_sb3(data: str | bytes | TextIOWrapper): + """ + Load a project from an sb3 file/bytes/file path + """ + if isinstance(data, bytes): + data = BytesIO(data) + + elif isinstance(data, str): + data = open(data, "rb") + with data: + try: + return Project.from_json(json.load(data)) + except ValueError: + with ZipFile(data) as archive: + data = json.loads(archive.read("project.json")) + return Project.from_json(data) diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py new file mode 100644 index 00000000..249c6006 --- /dev/null +++ b/scratchattach/editor/sprite.py @@ -0,0 +1,13 @@ +from . import base, project + + +class Sprite(base.ProjectSubcomponent): + def __init__(self, _project: project.Project=None): + super().__init__(_project) + + @staticmethod + def from_json(data: dict): + pass + + def to_json(self) -> dict: + pass \ No newline at end of file diff --git a/scratchattach/utils/exceptions.py b/scratchattach/utils/exceptions.py index 4b24a4b7..cea262af 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -208,3 +208,12 @@ class WebsocketServerError(Exception): """ pass + + +# Editor errors: + +class UnclosedJSONError(Exception): + """ + Raised when a JSON string is never closed. + """ + pass From 647cee2138cea8821671474e2512881a7c418ae8 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sat, 23 Nov 2024 20:20:56 +0000 Subject: [PATCH 042/101] sprites with everything but blocks (comments will need to be attached to their blocks) --- scratchattach/editor/__init__.py | 1 + scratchattach/editor/asset.py | 143 ++++++++++++++++++++++++++++++ scratchattach/editor/base.py | 29 +++++- scratchattach/editor/block.py | 15 ++++ scratchattach/editor/comment.py | 50 +++++++++++ scratchattach/editor/commons.py | 8 ++ scratchattach/editor/extension.py | 1 - scratchattach/editor/monitor.py | 6 +- scratchattach/editor/primitive.py | 63 +++++++++++++ scratchattach/editor/project.py | 47 ++++++++-- scratchattach/editor/sprite.py | 143 +++++++++++++++++++++++++++++- scratchattach/editor/vlb.py | 98 ++++++++++++++++++++ 12 files changed, 588 insertions(+), 16 deletions(-) create mode 100644 scratchattach/editor/asset.py create mode 100644 scratchattach/editor/block.py create mode 100644 scratchattach/editor/comment.py create mode 100644 scratchattach/editor/primitive.py create mode 100644 scratchattach/editor/vlb.py diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py index 5ce17c61..801e3b34 100644 --- a/scratchattach/editor/__init__.py +++ b/scratchattach/editor/__init__.py @@ -1 +1,2 @@ from .project import Project +from .extension import Extensions diff --git a/scratchattach/editor/asset.py b/scratchattach/editor/asset.py new file mode 100644 index 00000000..2689e7e6 --- /dev/null +++ b/scratchattach/editor/asset.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from . import base, project, commons, sprite + + +@dataclass(init=True) +class AssetFile: + filename: str + data: bytes + + def __repr__(self): + return f"AssetFile(filename={self.filename!r})" + + +class Asset(base.SpriteSubComponent): + def __init__(self, + name: str = "costume1", + file_name: str = "b7853f557e4426412e64bb3da6531a99.svg", + _sprite: sprite.Sprite = None): + """ + 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.file_name}>" + + @property + def file_name(self): + return f"{self.id}.{self.data_format}" + + @property + def md5ext(self): + return self.file_name + + @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, + } + + +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 = None): + """ + 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 = None): + """ + 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/base.py b/scratchattach/editor/base.py index 76420fdd..95934caa 100644 --- a/scratchattach/editor/base.py +++ b/scratchattach/editor/base.py @@ -1,8 +1,10 @@ """ Editor base classes """ + from __future__ import annotations +import copy from abc import ABC, abstractmethod from typing import Any, TYPE_CHECKING @@ -21,14 +23,20 @@ def from_json(data: dict | list | Any): def to_json(self) -> dict | list | Any: pass + def copy(self): + """ + :return: A **deep** copy of this ProjectPart. + """ + return copy.deepcopy(self) + class ProjectSubcomponent(ProjectPart, ABC): - def __init__(self, _project: project.Project): + def __init__(self, _project: project.Project = None): self.project = _project class SpriteSubComponent(ProjectPart, ABC): - def __init__(self, _sprite: sprite.Sprite): + def __init__(self, _sprite: sprite.Sprite | None): self.sprite = _sprite @property @@ -37,6 +45,21 @@ def project(self) -> project.Project: class IDComponent(SpriteSubComponent, ABC): - def __init__(self, _id: str, _sprite: sprite.Sprite): + def __init__(self, _id: str, _sprite: sprite.Sprite | None): self.id = _id super().__init__(_sprite) + + def __repr__(self): + return f"<{self.__class__.__name__}: {self.id}>" + + +class VLB(IDComponent, ABC): + """ + Base class for Variables, Lists and Broadcasts (Name + ID + sprite) + """ + def __init__(self, _id: str, name: str, _sprite: sprite.Sprite | None): + self.name = name + super().__init__(_id, _sprite) + + def __repr__(self): + return f"<{self.__class__.__name__} '{self.name}'>" \ No newline at end of file diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py new file mode 100644 index 00000000..8a70155f --- /dev/null +++ b/scratchattach/editor/block.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from . import base, sprite + + +class Block(base.IDComponent): + def __init__(self, _id: str = None, _sprite: sprite.Sprite = None): + super().__init__(_id, _sprite) + + @staticmethod + def from_json(data: dict): + pass + + def to_json(self) -> dict: + pass diff --git a/scratchattach/editor/comment.py b/scratchattach/editor/comment.py new file mode 100644 index 00000000..c41e9b32 --- /dev/null +++ b/scratchattach/editor/comment.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from . import base, block, sprite + + +class Comment(base.IDComponent): + def __init__(self, _id: str = None, _block: block.Block = None, x: int = 0, y: int = 0, width: int = 100, + height: int = 100, + text: str = '', *, _sprite: sprite.Sprite = None): + self.block = _block + + self.x = x + self.y = y + + self.width = width + self.height = height + + self.text = text + + super().__init__(_id, _sprite) + + def __repr__(self): + return f"Comment<{self.text[:10]!r}>" + + @staticmethod + def from_json(data: tuple[str, dict]): + assert len(data) == 2 + _id, data = data + + _block_id = data.get("blockId") + if _block_id is not None: + _block = block.Block(_block_id) + else: + _block = None + + x = data.get("x", 0) + y = data.get("y", 0) + + width = data.get("width", 100) + height = data.get("height", 100) + + text = data.get("text") + + ret = Comment(_id, _block, x, y, width, height, text) + if _block is not None: + _block.comment = ret + return ret + + def to_json(self) -> dict: + pass diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index 911b4a36..c04b4973 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -2,6 +2,7 @@ Shared functions used by the editor module """ +import copy import json from typing import Final @@ -129,3 +130,10 @@ def is_valid_json(string: str) -> bool: return True except ValueError: return False + + +def noneless_update(obj: dict, update: dict) -> None: + for key, value in update.items(): + if value is not None: + obj[key] = value + diff --git a/scratchattach/editor/extension.py b/scratchattach/editor/extension.py index 83a34242..410d0999 100644 --- a/scratchattach/editor/extension.py +++ b/scratchattach/editor/extension.py @@ -21,7 +21,6 @@ def from_json(data: str): return _extension - def to_json(self) -> str: return self.code diff --git a/scratchattach/editor/monitor.py b/scratchattach/editor/monitor.py index 7f3f2e49..de5ed5e5 100644 --- a/scratchattach/editor/monitor.py +++ b/scratchattach/editor/monitor.py @@ -22,7 +22,7 @@ def __init__(self, reporter: base.SpriteSubComponent = None, Represents a variable/list monitor https://en.scratch-wiki.info/wiki/Scratch_File_Format#Monitors """ - assert isinstance(reporter, base.SpriteSubComponent) + assert isinstance(reporter, base.SpriteSubComponent) or reporter is None self.reporter = reporter if params is None: @@ -51,6 +51,8 @@ def __repr__(self): @staticmethod def from_json(data: dict | list | Any): _id = data["id"] + # ^^ NEED TO FIND REPORTER OBJECT + mode = data["mode"] opcode = data["opcode"] @@ -71,7 +73,7 @@ def from_json(data: dict | list | Any): else: slider_min, slider_max, is_discrete = None, None, None - return Monitor(_id, mode, opcode, params, sprite_name, value, width, height, x, y, visible, slider_min, + return Monitor(None, mode, opcode, params, sprite_name, value, width, height, x, y, visible, slider_min, slider_max, is_discrete) def to_json(self): diff --git a/scratchattach/editor/primitive.py b/scratchattach/editor/primitive.py new file mode 100644 index 00000000..29345ceb --- /dev/null +++ b/scratchattach/editor/primitive.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from . import base, sprite +from ..utils import enums + + +@dataclass(init=True, repr=True) +class PrimType(base.ProjectPart): + code: int + name: str + + def __eq__(self, other): + if isinstance(other, str): + return self.name == other + return super().__eq__(other) + + + @staticmethod + def from_json(data: int): + pass + + def to_json(self) -> int: + pass + + +class PrimTypes(enums._EnumWrapper): + NULL = PrimType(1, "null") + BLOCK = PrimType(2, "block") + NUMBER = PrimType(4, "number") + POSITIVE_NUMBER = PrimType(5, "positive number") + POSITIVE_INTEGER = PrimType(6, "positive integer") + INTEGER = PrimType(7, "integer") + ANGLE = PrimType(8, "angle") + COLOR = PrimType(9, "color") + STRING = PrimType(10, "string") + BROADCAST = PrimType(11, "broadcast") + VARIABLE = PrimType(12, "variable") + VAR = PrimType(12, "var") + LIST = PrimType(13, "list") + + +class Primitive(base.IDComponent): + def __init__(self, _id: str = None, _sprite: sprite.Sprite = None): + """ + Class representing a Scratch string, number, angle etc. + Technically blocks but behave differently + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets:~:text=A%20few%20blocks,13 + """ + super().__init__(_id, _sprite) + + @staticmethod + def from_json(data: list): + assert isinstance(data, list) + + _type_idx = data[0] + _prim_type = PrimTypes.find(_type_idx, "code") + print(_prim_type == "variable") + + def to_json(self) -> list: + pass diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index 57b3cb48..f1c62872 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -1,20 +1,34 @@ +from __future__ import annotations + import json +import warnings from io import BytesIO, TextIOWrapper from typing import Any, Iterable from zipfile import ZipFile -from . import base, meta, extension, monitor +from . import base, meta, extension, monitor, sprite, asset class Project(base.ProjectPart): def __init__(self, _meta: meta.Meta = None, _extensions: Iterable[extension.Extension] = (), - _monitors: Iterable[monitor.Monitor] = ()): + _monitors: Iterable[monitor.Monitor] = (), _sprites: Iterable[sprite.Sprite] = (), *, + _asset_data: list[asset.AssetFile] = None): + # Defaulting for list parameters if _meta is None: _meta = meta.Meta() + if _asset_data is None: + _asset_data = [] self.meta = _meta self.extensions = _extensions self.monitors = _monitors + self.sprites = _sprites + + self.asset_data = _asset_data + + # Link sprites + for _sprite in self.sprites: + _sprite.project = self def to_json(self) -> dict | list | Any: pass @@ -36,12 +50,17 @@ def from_json(data: dict | list | Any): for _monitor_data in data.get("monitors", []): _monitors.append(monitor.Monitor.from_json(_monitor_data)) - return Project(_meta, _extensions, _monitors) + # Load sprites (targets) + _sprites = [] + for _sprite_data in data.get("targets", []): + _sprites.append(sprite.Sprite.from_json(_sprite_data)) + + return Project(_meta, _extensions, _monitors, _sprites) @staticmethod - def from_sb3(data: str | bytes | TextIOWrapper): + def from_sb3(data: str | bytes | TextIOWrapper, load_assets: bool = True): """ - Load a project from an sb3 file/bytes/file path + Load a project from an .sb3 file/bytes/file path """ if isinstance(data, bytes): data = BytesIO(data) @@ -49,9 +68,25 @@ def from_sb3(data: str | bytes | TextIOWrapper): elif isinstance(data, str): data = open(data, "rb") with data: + # For if the sb3 is just JSON (e.g. if it's exported from scratchattach) try: return Project.from_json(json.load(data)) except ValueError: with ZipFile(data) as archive: data = json.loads(archive.read("project.json")) - return Project.from_json(data) + + project = Project.from_json(data) + + # Also load assets + if load_assets: + asset_data = [] + for filename in archive.namelist(): + if filename != "project.json": + asset_data.append( + asset.AssetFile(filename, archive.read(filename)) + ) + project.asset_data = asset_data + 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 project diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index 249c6006..50632dc3 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -1,13 +1,148 @@ -from . import base, project +from __future__ import annotations + +from . import base, project, vlb, asset, comment, primitive class Sprite(base.ProjectSubcomponent): - def __init__(self, _project: project.Project=None): + 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, + # 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 = [] + + 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.costumes = _costumes + self.sounds = _sounds + + self.comments = _comments + + 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 + super().__init__(_project) + for sub_component in self.vlbs + self.comments + self.assets: + sub_component.sprite = self + + def __repr__(self): + return f"Sprite<{self.name}>" + + @property + def vlbs(self) -> list[base.VLB]: + return self.variables + self.lists + self.broadcasts + + @property + def assets(self) -> list[ asset.Costume | asset.Sound]: + return self.costumes + self.sounds + @staticmethod def from_json(data: dict): - pass + _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] = primitive.Primitive.from_json(_block_data) + else: + # Block + pass + + # 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, + + _tempo, _video_state, _video_transparency, _text_to_speech_language, + _visible, _x, _y, _size, _direction, _draggable, _rotation_style + ) def to_json(self) -> dict: - pass \ No newline at end of file + pass diff --git a/scratchattach/editor/vlb.py b/scratchattach/editor/vlb.py new file mode 100644 index 00000000..43b1d469 --- /dev/null +++ b/scratchattach/editor/vlb.py @@ -0,0 +1,98 @@ +""" +Variables, lists & broadcasts +""" + +from __future__ import annotations + +from . import base, sprite + + +class Broadcast(base.VLB): + def __init__(self, _id: str, _name: str, _sprite: sprite.Sprite = None): + """ + 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) -> tuple[str, str]: + return self.id, self.name + + +class Variable(base.VLB): + def __init__(self, _id: str, _name: str, _value: str | int | float, _is_cloud: bool = False, + _sprite: sprite.Sprite = None): + """ + Class representing a variable. + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets:~:text=variables,otherwise%20not%20present + """ + self.value = _value + self.is_cloud = _is_cloud + + 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) 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, tuple[str, str | int | float, bool] | tuple[str, str | int | float]]: + """ + Returns Variable data as the variable id, then a tuple representing it + """ + if self.is_cloud: + _ret = self.name, self.value, True + else: + _ret = self.name, self.value + + return self.id, _ret + + +class List(base.VLB): + def __init__(self, _id: str, _name: str, _value: str | int | float, + _sprite: sprite.Sprite = None): + """ + Class representing a list. + https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets:~:text=lists,as%20an%20array + """ + 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 the list id, then a tuple representing it + """ + return self.id, (self.name, self.value) From 860c111cf50403856da28a111d57a1cb0865b9d4 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sat, 23 Nov 2024 21:07:01 +0000 Subject: [PATCH 043/101] prims are better now --- scratchattach/editor/base.py | 3 +- scratchattach/editor/commons.py | 2 +- scratchattach/editor/prim.py | 129 ++++++++++++++++++++++++++++++ scratchattach/editor/primitive.py | 63 --------------- scratchattach/editor/sprite.py | 68 ++++++++++++++-- 5 files changed, 195 insertions(+), 70 deletions(-) create mode 100644 scratchattach/editor/prim.py delete mode 100644 scratchattach/editor/primitive.py diff --git a/scratchattach/editor/base.py b/scratchattach/editor/base.py index 95934caa..dcf76e88 100644 --- a/scratchattach/editor/base.py +++ b/scratchattach/editor/base.py @@ -57,9 +57,10 @@ class VLB(IDComponent, ABC): """ Base class for Variables, Lists and Broadcasts (Name + ID + sprite) """ + def __init__(self, _id: str, name: str, _sprite: sprite.Sprite | None): self.name = name super().__init__(_id, _sprite) def __repr__(self): - return f"<{self.__class__.__name__} '{self.name}'>" \ No newline at end of file + return f"<{self.__class__.__name__} '{self.name}'>" diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index c04b4973..9e0337f1 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -1,8 +1,8 @@ """ Shared functions used by the editor module """ +from __future__ import annotations -import copy import json from typing import Final diff --git a/scratchattach/editor/prim.py b/scratchattach/editor/prim.py new file mode 100644 index 00000000..8a5f7b9d --- /dev/null +++ b/scratchattach/editor/prim.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Final + +from . import base, sprite +from ..utils import enums + + +@dataclass(init=True, repr=True) +class PrimType(base.ProjectPart): + code: int + name: str + attrs: list = 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): + pass + + def to_json(self) -> int: + pass + + +BASIC_ATTRS: Final = ["value"] +VLB_ATTRS: Final = ["name", "id", "x", "y"] + + +class PrimTypes(enums._EnumWrapper): + NULL = PrimType(1, "null") + BLOCK = PrimType(2, "block") + NUMBER = PrimType(4, "number", BASIC_ATTRS) + POSITIVE_NUMBER = PrimType(5, "positive number", BASIC_ATTRS) + POSITIVE_INTEGER = PrimType(6, "positive integer", BASIC_ATTRS) + INTEGER = PrimType(7, "integer", BASIC_ATTRS) + ANGLE = PrimType(8, "angle", BASIC_ATTRS) + COLOR = PrimType(9, "color", BASIC_ATTRS) + STRING = PrimType(10, "string", BASIC_ATTRS) + BROADCAST = PrimType(11, "broadcast", VLB_ATTRS) + VARIABLE = PrimType(12, "variable", VLB_ATTRS) + LIST = PrimType(13, "list", VLB_ATTRS) + + @classmethod + def find(cls, value, by: str, apply_func: Callable = None) -> PrimType: + return super().find(value, by, apply_func=apply_func) + + +class Prim(base.SpriteSubComponent): + def __init__(self, _primtype: PrimType, _value: str = None, _name: str = None, _id: str = None, _x: int = None, + _y: int = None, _sprite: sprite.Sprite = None): + """ + 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 + """ + self.type = _primtype + + self.value = _value + + self.name = _name + """ + Once you get the object associated with this primitive (done upon sprite initialisation), + the name will be removed and the value will be changed from ``None`` + """ + self.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 (done upon sprite initialisation), + 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, _id, _x, _y = (None,) * 5 + if _prim_type == PrimTypes.NULL: + pass + elif _prim_type == PrimTypes.BLOCK: + pass + + elif _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, _id = data[1:3] + + if len(data) == 5: + _x, _y = data[3:5] + + return Prim(_prim_type, _value, _name, _id, _x, _y) + + def to_json(self) -> list: + pass diff --git a/scratchattach/editor/primitive.py b/scratchattach/editor/primitive.py deleted file mode 100644 index 29345ceb..00000000 --- a/scratchattach/editor/primitive.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -from . import base, sprite -from ..utils import enums - - -@dataclass(init=True, repr=True) -class PrimType(base.ProjectPart): - code: int - name: str - - def __eq__(self, other): - if isinstance(other, str): - return self.name == other - return super().__eq__(other) - - - @staticmethod - def from_json(data: int): - pass - - def to_json(self) -> int: - pass - - -class PrimTypes(enums._EnumWrapper): - NULL = PrimType(1, "null") - BLOCK = PrimType(2, "block") - NUMBER = PrimType(4, "number") - POSITIVE_NUMBER = PrimType(5, "positive number") - POSITIVE_INTEGER = PrimType(6, "positive integer") - INTEGER = PrimType(7, "integer") - ANGLE = PrimType(8, "angle") - COLOR = PrimType(9, "color") - STRING = PrimType(10, "string") - BROADCAST = PrimType(11, "broadcast") - VARIABLE = PrimType(12, "variable") - VAR = PrimType(12, "var") - LIST = PrimType(13, "list") - - -class Primitive(base.IDComponent): - def __init__(self, _id: str = None, _sprite: sprite.Sprite = None): - """ - Class representing a Scratch string, number, angle etc. - Technically blocks but behave differently - https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets:~:text=A%20few%20blocks,13 - """ - super().__init__(_id, _sprite) - - @staticmethod - def from_json(data: list): - assert isinstance(data, list) - - _type_idx = data[0] - _prim_type = PrimTypes.find(_type_idx, "code") - print(_prim_type == "variable") - - def to_json(self) -> list: - pass diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index 50632dc3..d57f91e6 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -1,6 +1,8 @@ from __future__ import annotations -from . import base, project, vlb, asset, comment, primitive +from typing import Generator + +from . import base, project, vlb, asset, comment, prim class Sprite(base.ProjectSubcomponent): @@ -9,7 +11,7 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int _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, + _comments: list[comment.Comment] = None, _prims: dict[str, prim.Prim] = 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, @@ -35,6 +37,8 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int _sounds = [] if _comments is None: _comments = [] + if _prims is None: + _prims = [] self.is_stage = is_stage self.name = name @@ -50,6 +54,7 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int self.sounds = _sounds self.comments = _comments + self.prims = _prims self.tempo = _tempo self.video_state = _video_state @@ -64,9 +69,16 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int super().__init__(_project) + # Assign sprite for sub_component in self.vlbs + self.comments + self.assets: sub_component.sprite = self + # Link prims to vars/lists/broadcasts + for _id, _prim in self.prims.items(): + _prim.sprite = self + if _prim.is_vlb: + pass + def __repr__(self): return f"Sprite<{self.name}>" @@ -75,7 +87,7 @@ def vlbs(self) -> list[base.VLB]: return self.variables + self.lists + self.broadcasts @property - def assets(self) -> list[ asset.Costume | asset.Sound]: + def assets(self) -> list[asset.Costume | asset.Sound]: return self.costumes + self.sounds @staticmethod @@ -114,7 +126,7 @@ def read_idcomponent(attr_name: str, cls: type[base.IDComponent]): for _block_id, _block_data in data.get("blocks", {}).items(): if isinstance(_block_data, list): # Prim - _prims[_block_id] = primitive.Primitive.from_json(_block_data) + _prims[_block_id] = prim.Prim.from_json(_block_data) else: # Block pass @@ -138,7 +150,7 @@ def read_idcomponent(attr_name: str, cls: type[base.IDComponent]): return Sprite(_is_stage, _name, _current_costume, _layer_order, _volume, _broadcasts, _variables, _lists, _costumes, - _sounds, _comments, + _sounds, _comments, _prims, _tempo, _video_state, _video_transparency, _text_to_speech_language, _visible, _x, _y, _size, _direction, _draggable, _rotation_style @@ -146,3 +158,49 @@ def read_idcomponent(attr_name: str, cls: type[base.IDComponent]): def to_json(self) -> dict: pass + + # Finding/getting from list/dict attributes + def find_var(self, value: str, by: str = "name", multiple: bool = False) -> vlb.Variable | Generator[ + vlb.Variable, None, None]: + by = by.lower() + for _variable in self.variables: + if by == "id": + compare = _variable.id + else: + # Defaulting + compare = _variable.name + if compare == value: + if multiple: + yield _variable + else: + return _variable + + def find_list(self, value: str, by: str = "name", multiple: bool = False) -> vlb.List | Generator[ + vlb.List, None, None]: + by = by.lower() + for _list in self.lists: + if by == "id": + compare = _list.id + else: + # Defaulting + compare = _list.name + if compare == value: + if multiple: + yield _list + else: + return _list + + def find_broadcast(self, value: str, by: str = "name", multiple: bool = False) -> vlb.Broadcast | Generator[ + vlb.Broadcast, None, None]: + by = by.lower() + for _broadcast in self.broadcasts: + if by == "id": + compare = _broadcast.id + else: + # Defaulting + compare = _broadcast.name + if compare == value: + if multiple: + yield _broadcast + else: + return _broadcast From f4881912a990eecbe4f3f5253f24c3fad4a1bea7 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sat, 23 Nov 2024 21:22:33 +0000 Subject: [PATCH 044/101] vlb prims have a vlb value, not a name/id --- scratchattach/editor/prim.py | 4 ++-- scratchattach/editor/sprite.py | 39 +++++++++++++++++++++---------- scratchattach/utils/exceptions.py | 7 ++++++ 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/scratchattach/editor/prim.py b/scratchattach/editor/prim.py index 8a5f7b9d..0cc94509 100644 --- a/scratchattach/editor/prim.py +++ b/scratchattach/editor/prim.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Callable, Final -from . import base, sprite +from . import base, sprite, vlb from ..utils import enums @@ -52,7 +52,7 @@ def find(cls, value, by: str, apply_func: Callable = None) -> PrimType: class Prim(base.SpriteSubComponent): - def __init__(self, _primtype: PrimType, _value: str = None, _name: str = None, _id: str = None, _x: int = None, + def __init__(self, _primtype: PrimType, _value: str | vlb.Variable | vlb.List | vlb.Broadcast = None, _name: str = None, _id: str = None, _x: int = None, _y: int = None, _sprite: sprite.Sprite = None): """ Class representing a Scratch string, number, angle, variable etc. diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index d57f91e6..3d3c05cd 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -1,8 +1,7 @@ from __future__ import annotations -from typing import Generator - from . import base, project, vlb, asset, comment, prim +from ..utils import exceptions class Sprite(base.ProjectSubcomponent): @@ -77,7 +76,17 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int for _id, _prim in self.prims.items(): _prim.sprite = self if _prim.is_vlb: - pass + if _prim.type.name == "variable": + _prim.value = self.find_variable(_prim.id, "id") + elif _prim.type.name == "list": + _prim.value = self.find_list(_prim.id, "id") + elif _prim.type.name == "broadcast": + _prim.value = self.find_broadcast(_prim.id, "id") + else: + # This should never happen + raise exceptions.BadVLBPrimitiveError(f"{_prim} claims to be VLB, but is {_prim.type.name}") + _prim.name = None + _prim.id = None def __repr__(self): return f"Sprite<{self.name}>" @@ -160,8 +169,8 @@ def to_json(self) -> dict: pass # Finding/getting from list/dict attributes - def find_var(self, value: str, by: str = "name", multiple: bool = False) -> vlb.Variable | Generator[ - vlb.Variable, None, None]: + 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: if by == "id": @@ -171,12 +180,14 @@ def find_var(self, value: str, by: str = "name", multiple: bool = False) -> vlb. compare = _variable.name if compare == value: if multiple: - yield _variable + _ret.append(_variable) else: return _variable + if multiple: + return _ret - def find_list(self, value: str, by: str = "name", multiple: bool = False) -> vlb.List | Generator[ - vlb.List, None, None]: + 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: if by == "id": @@ -186,12 +197,14 @@ def find_list(self, value: str, by: str = "name", multiple: bool = False) -> vlb compare = _list.name if compare == value: if multiple: - yield _list + _ret.append(_list) else: return _list + if multiple: + return _ret - def find_broadcast(self, value: str, by: str = "name", multiple: bool = False) -> vlb.Broadcast | Generator[ - vlb.Broadcast, None, None]: + 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: if by == "id": @@ -201,6 +214,8 @@ def find_broadcast(self, value: str, by: str = "name", multiple: bool = False) - compare = _broadcast.name if compare == value: if multiple: - yield _broadcast + _ret.append(_broadcast) else: return _broadcast + if multiple: + return _ret diff --git a/scratchattach/utils/exceptions.py b/scratchattach/utils/exceptions.py index cea262af..d151f108 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -217,3 +217,10 @@ 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 From 491297bab7723a8fac7c7ca4c766eb86699c3069 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sat, 23 Nov 2024 21:42:06 +0000 Subject: [PATCH 045/101] renamed some base classes --- scratchattach/editor/base.py | 8 +++--- scratchattach/editor/extension.py | 2 +- scratchattach/editor/meta.py | 2 +- scratchattach/editor/monitor.py | 2 +- scratchattach/editor/prim.py | 2 +- scratchattach/editor/project.py | 2 +- scratchattach/editor/sprite.py | 2 +- scratchattach/editor/vlb.py | 42 +++++++++++++++---------------- 8 files changed, 31 insertions(+), 31 deletions(-) diff --git a/scratchattach/editor/base.py b/scratchattach/editor/base.py index dcf76e88..b08d4f61 100644 --- a/scratchattach/editor/base.py +++ b/scratchattach/editor/base.py @@ -13,7 +13,7 @@ from . import sprite -class ProjectPart(ABC): +class JSONSerializable(ABC): @staticmethod @abstractmethod def from_json(data: dict | list | Any): @@ -30,12 +30,12 @@ def copy(self): return copy.deepcopy(self) -class ProjectSubcomponent(ProjectPart, ABC): +class ProjectSubcomponent(JSONSerializable, ABC): def __init__(self, _project: project.Project = None): self.project = _project -class SpriteSubComponent(ProjectPart, ABC): +class SpriteSubComponent(JSONSerializable, ABC): def __init__(self, _sprite: sprite.Sprite | None): self.sprite = _sprite @@ -53,7 +53,7 @@ def __repr__(self): return f"<{self.__class__.__name__}: {self.id}>" -class VLB(IDComponent, ABC): +class NamedIDComponent(IDComponent, ABC): """ Base class for Variables, Lists and Broadcasts (Name + ID + sprite) """ diff --git a/scratchattach/editor/extension.py b/scratchattach/editor/extension.py index 410d0999..634f10c1 100644 --- a/scratchattach/editor/extension.py +++ b/scratchattach/editor/extension.py @@ -5,7 +5,7 @@ @dataclass(init=True, repr=True) -class Extension(base.ProjectPart): +class Extension(base.JSONSerializable): code: str name: str = None diff --git a/scratchattach/editor/meta.py b/scratchattach/editor/meta.py index e93a81a5..8a322ddb 100644 --- a/scratchattach/editor/meta.py +++ b/scratchattach/editor/meta.py @@ -13,7 +13,7 @@ META_SET_PLATFORM = False -class Meta(base.ProjectPart): +class Meta(base.JSONSerializable): def __init__(self, semver: str = "3.0.0", vm: str = DEFAULT_VM, agent: str = DEFAULT_AGENT, platform: dict = None): """ diff --git a/scratchattach/editor/monitor.py b/scratchattach/editor/monitor.py index de5ed5e5..10f8eac7 100644 --- a/scratchattach/editor/monitor.py +++ b/scratchattach/editor/monitor.py @@ -3,7 +3,7 @@ from . import base -class Monitor(base.ProjectPart): +class Monitor(base.JSONSerializable): def __init__(self, reporter: base.SpriteSubComponent = None, mode: str = "default", opcode: str = "data_variable", diff --git a/scratchattach/editor/prim.py b/scratchattach/editor/prim.py index 0cc94509..ecd8dce1 100644 --- a/scratchattach/editor/prim.py +++ b/scratchattach/editor/prim.py @@ -8,7 +8,7 @@ @dataclass(init=True, repr=True) -class PrimType(base.ProjectPart): +class PrimType(base.JSONSerializable): code: int name: str attrs: list = None diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index f1c62872..563aab30 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -9,7 +9,7 @@ from . import base, meta, extension, monitor, sprite, asset -class Project(base.ProjectPart): +class Project(base.JSONSerializable): def __init__(self, _meta: meta.Meta = None, _extensions: Iterable[extension.Extension] = (), _monitors: Iterable[monitor.Monitor] = (), _sprites: Iterable[sprite.Sprite] = (), *, _asset_data: list[asset.AssetFile] = None): diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index 3d3c05cd..f920ffd0 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -92,7 +92,7 @@ def __repr__(self): return f"Sprite<{self.name}>" @property - def vlbs(self) -> list[base.VLB]: + def vlbs(self) -> list[base.NamedIDComponent]: return self.variables + self.lists + self.broadcasts @property diff --git a/scratchattach/editor/vlb.py b/scratchattach/editor/vlb.py index 43b1d469..84ef3335 100644 --- a/scratchattach/editor/vlb.py +++ b/scratchattach/editor/vlb.py @@ -7,26 +7,7 @@ from . import base, sprite -class Broadcast(base.VLB): - def __init__(self, _id: str, _name: str, _sprite: sprite.Sprite = None): - """ - 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) -> tuple[str, str]: - return self.id, self.name - - -class Variable(base.VLB): +class Variable(base.NamedIDComponent): def __init__(self, _id: str, _name: str, _value: str | int | float, _is_cloud: bool = False, _sprite: sprite.Sprite = None): """ @@ -68,7 +49,7 @@ def to_json(self) -> tuple[str, tuple[str, str | int | float, bool] | tuple[str, return self.id, _ret -class List(base.VLB): +class List(base.NamedIDComponent): def __init__(self, _id: str, _name: str, _value: str | int | float, _sprite: sprite.Sprite = None): """ @@ -96,3 +77,22 @@ def to_json(self) -> tuple[str, tuple[str, str | int | float, bool] | tuple[str, Returns List data as the list id, then a tuple representing it """ return self.id, (self.name, self.value) + + +class Broadcast(base.NamedIDComponent): + def __init__(self, _id: str, _name: str, _sprite: sprite.Sprite = None): + """ + 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) -> tuple[str, str]: + return self.id, self.name From 2d1e0f951d316f3c76a07cc0bea925012211cf75 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sun, 24 Nov 2024 10:41:41 +0000 Subject: [PATCH 046/101] platform dataclass + global var searching --- scratchattach/editor/base.py | 10 ++++++- scratchattach/editor/commons.py | 12 ++++++++ scratchattach/editor/meta.py | 48 +++++++++++++++++++++++-------- scratchattach/editor/prim.py | 4 +-- scratchattach/editor/project.py | 7 +++++ scratchattach/editor/sprite.py | 37 +++++++++++++++++++++++- scratchattach/utils/exceptions.py | 7 +++++ 7 files changed, 109 insertions(+), 16 deletions(-) diff --git a/scratchattach/editor/base.py b/scratchattach/editor/base.py index b08d4f61..903c79d6 100644 --- a/scratchattach/editor/base.py +++ b/scratchattach/editor/base.py @@ -13,7 +13,15 @@ from . import sprite -class JSONSerializable(ABC): +class Base(ABC): + def copy(self): + """ + :return: A **deep** copy of self + """ + return copy.deepcopy(self) + + +class JSONSerializable(Base, ABC): @staticmethod @abstractmethod def from_json(data: dict | list | Any): diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index 9e0337f1..57ab5053 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -137,3 +137,15 @@ def noneless_update(obj: dict, update: dict) -> None: 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] diff --git a/scratchattach/editor/meta.py b/scratchattach/editor/meta.py index 8a322ddb..60eba555 100644 --- a/scratchattach/editor/meta.py +++ b/scratchattach/editor/meta.py @@ -1,13 +1,35 @@ +from __future__ import annotations + import re +from dataclasses import dataclass + +from . import base, commons + + +@dataclass(init=True, repr=True) +class PlatformMeta(base.JSONSerializable): + name: str = None + url: str = 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")) -from . import base DEFAULT_VM = "0.1.0" DEFAULT_AGENT = "scratchattach.editor by https://scratch.mit.edu/users/timmccool/" -DEFAULT_PLATFORM = { - "name": "scratchattach", - "url": "https://github.com/timMcCool/scratchattach/" -} +DEFAULT_PLATFORM = PlatformMeta("scratchattach", "https://github.com/timMcCool/scratchattach/") EDIT_META = True META_SET_PLATFORM = False @@ -15,7 +37,7 @@ class Meta(base.JSONSerializable): def __init__(self, semver: str = "3.0.0", vm: str = DEFAULT_VM, agent: str = DEFAULT_AGENT, - platform: dict = None): + platform: PlatformMeta = None): """ Represents metadata of the project https://en.scratch-wiki.info/wiki/Scratch_File_Format#Metadata @@ -23,7 +45,6 @@ def __init__(self, semver: str = "3.0.0", vm: str = DEFAULT_VM, agent: str = DEF if platform is None and META_SET_PLATFORM: platform = DEFAULT_PLATFORM.copy() - # Thanks to TurboWarp for this pattern ↓↓↓↓, I just copied it if re.match("^([0-9]+\\.[0-9]+\\.[0-9]+)($|-)", vm) is None: raise ValueError( @@ -48,8 +69,8 @@ def to_json(self): "agent": self.agent } - if self.platform is not None: - _json["platform"] = self.platform + if self.platform: + _json["platform"] = self.platform.to_json() return _json @staticmethod @@ -60,13 +81,16 @@ def from_json(data): semver = data["semver"] vm = data.get("vm") agent = data.get("agent") - platform = data.get("platform") + 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 META_SET_PLATFORM and (EDIT_META or platform is None): - platform = DEFAULT_PLATFORM.copy() + + if EDIT_META: + if META_SET_PLATFORM and not platform: + platform = DEFAULT_PLATFORM.copy() return Meta(semver, vm, agent, platform) diff --git a/scratchattach/editor/prim.py b/scratchattach/editor/prim.py index ecd8dce1..84ebcc11 100644 --- a/scratchattach/editor/prim.py +++ b/scratchattach/editor/prim.py @@ -65,7 +65,7 @@ def __init__(self, _primtype: PrimType, _value: str | vlb.Variable | vlb.List | self.name = _name """ - Once you get the object associated with this primitive (done upon sprite initialisation), + 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.id = _id @@ -74,7 +74,7 @@ def __init__(self, _primtype: PrimType, _value: str | vlb.Variable | vlb.List | ---- - Once you get the object associated with it (done upon sprite initialisation), + Once you get the object associated with it (sprite.link_prims()), the id will be removed and the value will be changed from ``None`` """ diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index 563aab30..109290d1 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -29,6 +29,13 @@ def __init__(self, _meta: meta.Meta = None, _extensions: Iterable[extension.Exte # Link sprites for _sprite in self.sprites: _sprite.project = self + _sprite.link_prims() + + @property + def stage(self) -> sprite.Sprite: + for _sprite in self.sprites: + if _sprite.is_stage: + return _sprite def to_json(self) -> dict | list | Any: pass diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index f920ffd0..af2e1235 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -72,6 +72,12 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int for sub_component in self.vlbs + self.comments + self.assets: sub_component.sprite = self + def link_prims(self): + """ + Link primitives to corresponding VLB objects (requires project attribute) + """ + assert self.project is not None + # Link prims to vars/lists/broadcasts for _id, _prim in self.prims.items(): _prim.sprite = self @@ -85,6 +91,9 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int else: # This should never happen raise exceptions.BadVLBPrimitiveError(f"{_prim} claims to be VLB, but is {_prim.type.name}") + if _prim.value is None: + raise exceptions.UnlinkedVLB( + f"Prim has invalid {_prim.type.name} id") _prim.name = None _prim.id = None @@ -99,6 +108,10 @@ def vlbs(self) -> list[base.NamedIDComponent]: def assets(self) -> list[asset.Costume | asset.Sound]: return self.costumes + self.sounds + @property + def stage(self) -> Sprite: + return self.project.stage + @staticmethod def from_json(data: dict): _is_stage = data["isStage"] @@ -183,6 +196,13 @@ def find_variable(self, value: str, by: str = "name", multiple: bool = False) -> _ret.append(_variable) else: return _variable + # Search in stage for global variables + 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 @@ -200,10 +220,18 @@ def find_list(self, value: str, by: str = "name", multiple: bool = False) -> vlb _ret.append(_list) else: return _list + # Search in stage for global lists + 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]: + 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: @@ -217,5 +245,12 @@ def find_broadcast(self, value: str, by: str = "name", multiple: bool = False) - _ret.append(_broadcast) else: return _broadcast + # Search in stage for global broadcasts + 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 diff --git a/scratchattach/utils/exceptions.py b/scratchattach/utils/exceptions.py index d151f108..451fd242 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -224,3 +224,10 @@ 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 From 6a8f040aab87670d7dc6e869be8896f67b96b283 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sun, 24 Nov 2024 14:44:01 +0000 Subject: [PATCH 047/101] starting to add blocks --- scratchattach/editor/block.py | 85 +++++++++++++++++++++++++++++-- scratchattach/editor/project.py | 44 ++++++++++++++-- scratchattach/editor/sprite.py | 49 +++++++++++++++--- scratchattach/editor/vlb.py | 8 +++ scratchattach/utils/exceptions.py | 6 +++ 5 files changed, 177 insertions(+), 15 deletions(-) diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 8a70155f..b7689159 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -1,15 +1,92 @@ from __future__ import annotations +import warnings + from . import base, sprite -class Block(base.IDComponent): - def __init__(self, _id: str = None, _sprite: sprite.Sprite = None): - super().__init__(_id, _sprite) +class Block(base.SpriteSubComponent): + def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False, _next: Block = None, + _parent: Block = None, + *, _next_id: str = None, _parent_id: str = None, _sprite: sprite.Sprite = None): + + self.opcode = _opcode + self.is_shadow = _shadow + self.is_top_level = _top_level + + 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 + + super().__init__(_sprite) + + def __repr__(self): + return f"Block<{self.opcode!r}>" + + @property + def id(self) -> str | None: + warnings.warn(f"Using block IDs can cause consistency issues and is not recommended") + for _block_id, _block in self.sprite.blocks.items(): + if _block is self: + return _block_id + + @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 @staticmethod def from_json(data: dict): - pass + _opcode = data["opcode"] + + _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] = ... + + _fields = {} + for _field_code, _field_data in data.get("fields", {}).items(): + _fields[_field_code] = ... + + return Block(_opcode, _shadow, _top_level, _next_id=_next_id, _parent_id=_parent_id) def to_json(self) -> dict: pass + + def link_using_sprite(self): + 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 diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index 109290d1..dd3be2e3 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -7,10 +7,11 @@ from zipfile import ZipFile from . import base, meta, extension, monitor, sprite, asset +from ..utils import exceptions class Project(base.JSONSerializable): - def __init__(self, _meta: meta.Meta = None, _extensions: Iterable[extension.Extension] = (), + 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): # Defaulting for list parameters @@ -19,6 +20,8 @@ def __init__(self, _meta: meta.Meta = None, _extensions: Iterable[extension.Exte if _asset_data is None: _asset_data = [] + self.name = _name + self.meta = _meta self.extensions = _extensions self.monitors = _monitors @@ -27,9 +30,26 @@ def __init__(self, _meta: meta.Meta = None, _extensions: Iterable[extension.Exte self.asset_data = _asset_data # Link sprites + _stage_count = 0 + for _sprite in self.sprites: + if _sprite.is_stage: + _stage_count += 1 + _sprite.project = self _sprite.link_prims() + _sprite.link_blocks() + + 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: @@ -62,22 +82,34 @@ def from_json(data: dict | list | Any): for _sprite_data in data.get("targets", []): _sprites.append(sprite.Sprite.from_json(_sprite_data)) - return Project(_meta, _extensions, _monitors, _sprites) + return Project(None, _meta, _extensions, _monitors, _sprites) @staticmethod - def from_sb3(data: str | bytes | TextIOWrapper, load_assets: bool = True): + def from_sb3(data: str | bytes | TextIOWrapper, load_assets: bool = True, _name: str = None): """ Load a project from an .sb3 file/bytes/file path """ + + if _name is None: + if hasattr(data, "name"): + _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): + if _name is None: + _name = data.split('/')[-1] + _name = '.'.join(_name.split('.')[:-1]) + data = open(data, "rb") with data: # For if the sb3 is just JSON (e.g. if it's exported from scratchattach) try: - return Project.from_json(json.load(data)) + project = Project.from_json(json.load(data)) except ValueError: with ZipFile(data) as archive: data = json.loads(archive.read("project.json")) @@ -96,4 +128,6 @@ def from_sb3(data: str | bytes | TextIOWrapper, load_assets: bool = True): 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 project + + project.name = _name + return project diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index af2e1235..a7a79ec2 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -1,6 +1,6 @@ from __future__ import annotations -from . import base, project, vlb, asset, comment, prim +from . import base, project, vlb, asset, comment, prim, block from ..utils import exceptions @@ -11,6 +11,7 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int _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, @@ -38,6 +39,8 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int _comments = [] if _prims is None: _prims = [] + if _blocks is None: + _blocks = [] self.is_stage = is_stage self.name = name @@ -54,6 +57,7 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int self.comments = _comments self.prims = _prims + self.blocks = _blocks self.tempo = _tempo self.video_state = _video_state @@ -69,8 +73,9 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int super().__init__(_project) # Assign sprite - for sub_component in self.vlbs + self.comments + self.assets: - sub_component.sprite = self + 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 link_prims(self): """ @@ -80,7 +85,6 @@ def link_prims(self): # Link prims to vars/lists/broadcasts for _id, _prim in self.prims.items(): - _prim.sprite = self if _prim.is_vlb: if _prim.type.name == "variable": _prim.value = self.find_variable(_prim.id, "id") @@ -97,6 +101,14 @@ def link_prims(self): _prim.name = None _prim.id = None + def link_blocks(self): + """ + Link blocks to sprite/to other blocks + """ + for _block_id, _block in self.blocks.items(): + _block.link_using_sprite() + + def __repr__(self): return f"Sprite<{self.name}>" @@ -151,7 +163,7 @@ def read_idcomponent(attr_name: str, cls: type[base.IDComponent]): _prims[_block_id] = prim.Prim.from_json(_block_data) else: # Block - pass + _blocks[_block_id] = block.Block.from_json(_block_data) # Stage/sprite specific vars _tempo, _video_state, _video_transparency, _text_to_speech_language = (None,) * 4 @@ -172,7 +184,7 @@ def read_idcomponent(attr_name: str, cls: type[base.IDComponent]): return Sprite(_is_stage, _name, _current_costume, _layer_order, _volume, _broadcasts, _variables, _lists, _costumes, - _sounds, _comments, _prims, + _sounds, _comments, _prims, _blocks, _tempo, _video_state, _video_transparency, _text_to_speech_language, _visible, _x, _y, _size, _direction, _draggable, _rotation_style @@ -254,3 +266,28 @@ def find_broadcast(self, value: str, by: str = "name", multiple: bool = False) - if multiple: return _ret + + def find_block(self, value: str, by: str = "opcode", multiple: bool = False) -> vlb.Variable | list[vlb.Variable]: + _ret = [] + by = by.lower() + for _block_id, _block in self.blocks.items(): + if by == "id": + compare = _block_id + else: + # Defaulting + compare = _block.opcode + + if compare == value: + if multiple: + _ret.append(_block) + else: + return _block + # Search in stage for global variables + 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 diff --git a/scratchattach/editor/vlb.py b/scratchattach/editor/vlb.py index 84ef3335..cd9d1ed0 100644 --- a/scratchattach/editor/vlb.py +++ b/scratchattach/editor/vlb.py @@ -19,6 +19,14 @@ def __init__(self, _id: str, _name: str, _value: str | int | float, _is_cloud: b 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]]): """ diff --git a/scratchattach/utils/exceptions.py b/scratchattach/utils/exceptions.py index 451fd242..1bfd9016 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -231,3 +231,9 @@ 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 From 1ab0fed15d5603e64832e3fa0b9121c39e1f2b3d Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sun, 24 Nov 2024 16:28:41 +0000 Subject: [PATCH 048/101] added mutations --- scratchattach/editor/__init__.py | 4 +- scratchattach/editor/asset.py | 11 ++ scratchattach/editor/base.py | 26 ++-- scratchattach/editor/block.py | 20 ++- scratchattach/editor/mutation.py | 253 +++++++++++++++++++++++++++++++ scratchattach/editor/project.py | 18 ++- scratchattach/editor/sprite.py | 7 +- scratchattach/utils/enums.py | 1 + 8 files changed, 319 insertions(+), 21 deletions(-) create mode 100644 scratchattach/editor/mutation.py diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py index 801e3b34..8339e797 100644 --- a/scratchattach/editor/__init__.py +++ b/scratchattach/editor/__init__.py @@ -1,2 +1,4 @@ +from .asset import Asset, Costume, Sound from .project import Project -from .extension import Extensions +from .extension import Extensions, Extension +from .mutation import Mutation, Argument, parse_proc_code diff --git a/scratchattach/editor/asset.py b/scratchattach/editor/asset.py index 2689e7e6..6e05313d 100644 --- a/scratchattach/editor/asset.py +++ b/scratchattach/editor/asset.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from hashlib import md5 from . import base, project, commons, sprite @@ -67,6 +68,16 @@ def to_json(self) -> dict: "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, diff --git a/scratchattach/editor/base.py b/scratchattach/editor/base.py index 903c79d6..7132c038 100644 --- a/scratchattach/editor/base.py +++ b/scratchattach/editor/base.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from . import project from . import sprite + from . import block class Base(ABC): @@ -31,12 +32,6 @@ def from_json(data: dict | list | Any): def to_json(self) -> dict | list | Any: pass - def copy(self): - """ - :return: A **deep** copy of this ProjectPart. - """ - return copy.deepcopy(self) - class ProjectSubcomponent(JSONSerializable, ABC): def __init__(self, _project: project.Project = None): @@ -44,7 +39,7 @@ def __init__(self, _project: project.Project = None): class SpriteSubComponent(JSONSerializable, ABC): - def __init__(self, _sprite: sprite.Sprite | None): + def __init__(self, _sprite: sprite.Sprite = None): self.sprite = _sprite @property @@ -53,7 +48,7 @@ def project(self) -> project.Project: class IDComponent(SpriteSubComponent, ABC): - def __init__(self, _id: str, _sprite: sprite.Sprite | None): + def __init__(self, _id: str, _sprite: sprite.Sprite = None): self.id = _id super().__init__(_sprite) @@ -66,9 +61,22 @@ class NamedIDComponent(IDComponent, ABC): Base class for Variables, Lists and Broadcasts (Name + ID + sprite) """ - def __init__(self, _id: str, name: str, _sprite: sprite.Sprite | None): + def __init__(self, _id: str, name: str, _sprite: sprite.Sprite = None): 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 diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index b7689159..95821531 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -2,11 +2,11 @@ import warnings -from . import base, sprite +from . import base, sprite, mutation class Block(base.SpriteSubComponent): - def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False, _next: Block = None, + def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False, _mutation: mutation.Mutation=None, _next: Block = None, _parent: Block = None, *, _next_id: str = None, _parent_id: str = None, _sprite: sprite.Sprite = None): @@ -14,6 +14,8 @@ def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False self.is_shadow = _shadow self.is_top_level = _top_level + self.mutation = _mutation + self._next_id = _next_id """ Temporarily stores id of next block. Will be used later during project instantiation to find the next block object @@ -28,6 +30,10 @@ def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False super().__init__(_sprite) + # Link subcomponents + if self.mutation: + self.mutation.block = self + def __repr__(self): return f"Block<{self.opcode!r}>" @@ -72,12 +78,20 @@ def from_json(data: dict): for _field_code, _field_data in data.get("fields", {}).items(): _fields[_field_code] = ... - return Block(_opcode, _shadow, _top_level, _next_id=_next_id, _parent_id=_parent_id) + if "mutation" in data: + _mutation = mutation.Mutation.from_json(data["mutation"]) + else: + _mutation = None + + return Block(_opcode, _shadow, _top_level, _mutation, _next_id=_next_id, _parent_id=_parent_id) def to_json(self) -> dict: pass def link_using_sprite(self): + 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: diff --git a/scratchattach/editor/mutation.py b/scratchattach/editor/mutation.py new file mode 100644 index 00000000..f841c97b --- /dev/null +++ b/scratchattach/editor/mutation.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from . import base +from ..utils import enums + +if TYPE_CHECKING: + from . import block + + +@dataclass(init=True) +class ArgumentPlaceholder(base.Base): + type: str + + def __eq__(self, other): + if isinstance(other, enums._EnumWrapper): + other = other.value + + assert isinstance(other, ArgumentPlaceholder) + + return self.type == other.type + + def __repr__(self): + return f"" + + +class ArgumentPlaceholders(enums._EnumWrapper): + BOOLEAN = ArgumentPlaceholder("boolean") + NUMBER_OR_TEXT = ArgumentPlaceholder("number or text") + + +def parse_proc_code(_proc_code: str) -> list[str, ArgumentPlaceholder] | 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(ArgumentPlaceholder("boolean")) + elif token == "%s": + tokens.append(ArgumentPlaceholder("number or text")) + + token = '' + continue + + token += char + last_char = char + + if token != '': + tokens.append(token) + + return tokens + + +@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.Base): + name: str + default: str = '' + + _id: str = None + """ + Argument ID: Will be used to replace other parameters during block instantiation. + """ + + _block: block.Block = None + + +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(self.argument_ids), + bool(self.argument_names), + bool(self.argument_defaults)) + + @property + def parsed_proc_code(self) -> list[str, ArgumentPlaceholder] | 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") + + _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") + + 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: + pass + + 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 + break diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index dd3be2e3..5ad6d296 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -3,7 +3,7 @@ import json import warnings from io import BytesIO, TextIOWrapper -from typing import Any, Iterable +from typing import Any, Iterable, Union from zipfile import ZipFile from . import base, meta, extension, monitor, sprite, asset @@ -89,10 +89,11 @@ def from_sb3(data: str | bytes | TextIOWrapper, load_assets: bool = True, _name: """ Load a project from an .sb3 file/bytes/file path """ + _dir_for_name = None if _name is None: if hasattr(data, "name"): - _name = data.name + _dir_for_name = data.name if not isinstance(_name, str) and _name is not None: _name = str(_name) @@ -101,16 +102,19 @@ def from_sb3(data: str | bytes | TextIOWrapper, load_assets: bool = True, _name: data = BytesIO(data) elif isinstance(data, str): - if _name is None: - _name = data.split('/')[-1] - _name = '.'.join(_name.split('.')[:-1]) - + _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]) + with data: # For if the sb3 is just JSON (e.g. if it's exported from scratchattach) try: project = Project.from_json(json.load(data)) - except ValueError: + except ValueError or UnicodeDecodeError: with ZipFile(data) as archive: data = json.loads(archive.read("project.json")) diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index a7a79ec2..ef532699 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -2,6 +2,7 @@ from . import base, project, vlb, asset, comment, prim, block from ..utils import exceptions +from typing import Any class Sprite(base.ProjectSubcomponent): @@ -267,12 +268,16 @@ def find_broadcast(self, value: str, by: str = "name", multiple: bool = False) - if multiple: return _ret - def find_block(self, value: str, by: str = "opcode", multiple: bool = False) -> vlb.Variable | list[vlb.Variable]: + def find_block(self, value: str | Any, by: str = "opcode", multiple: bool = False) -> block.Block | list[block.Block]: _ret = [] by = by.lower() for _block_id, _block in self.blocks.items(): + compare = None if by == "id": compare = _block_id + elif by == "argument ids": + if _block.mutation is not None: + compare = _block.mutation.argument_ids else: # Defaulting compare = _block.opcode diff --git a/scratchattach/utils/enums.py b/scratchattach/utils/enums.py index 0fbc0ffc..67d4d914 100644 --- a/scratchattach/utils/enums.py +++ b/scratchattach/utils/enums.py @@ -2,6 +2,7 @@ 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 0aa48eceb14e96e7c051afa18d1d4e992650956a Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sun, 24 Nov 2024 17:52:32 +0000 Subject: [PATCH 049/101] fixed mutation arg defaulting and added local globals (for importing sprites without a project) --- scratchattach/editor/commons.py | 9 ++- scratchattach/editor/mutation.py | 38 +++++++---- scratchattach/editor/sprite.py | 101 ++++++++++++++++++++++-------- scratchattach/editor/vlb.py | 17 +++++ scratchattach/utils/exceptions.py | 8 +++ 5 files changed, 134 insertions(+), 39 deletions(-) diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index 57ab5053..ce88188b 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -4,7 +4,7 @@ from __future__ import annotations import json -from typing import Final +from typing import Final, Any from ..utils import exceptions @@ -149,3 +149,10 @@ def remove_nones(obj: dict) -> 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] diff --git a/scratchattach/editor/mutation.py b/scratchattach/editor/mutation.py index f841c97b..e7ca92c3 100644 --- a/scratchattach/editor/mutation.py +++ b/scratchattach/editor/mutation.py @@ -2,9 +2,9 @@ import json from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Iterable -from . import base +from . import base, commons from ..utils import enums if TYPE_CHECKING: @@ -14,6 +14,7 @@ @dataclass(init=True) class ArgumentPlaceholder(base.Base): type: str + proc_str: str def __eq__(self, other): if isinstance(other, enums._EnumWrapper): @@ -26,10 +27,19 @@ def __eq__(self, other): 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 + class ArgumentPlaceholders(enums._EnumWrapper): - BOOLEAN = ArgumentPlaceholder("boolean") - NUMBER_OR_TEXT = ArgumentPlaceholder("number or text") + BOOLEAN = ArgumentPlaceholder("boolean", "%b") + NUMBER_OR_TEXT = ArgumentPlaceholder("number or text", "%s") def parse_proc_code(_proc_code: str) -> list[str, ArgumentPlaceholder] | None: @@ -53,9 +63,9 @@ def parse_proc_code(_proc_code: str) -> list[str, ArgumentPlaceholder] | None: # Add the parameter token token = f"%{char}" if token == "%b": - tokens.append(ArgumentPlaceholder("boolean")) + tokens.append(ArgumentPlaceholders.BOOLEAN.value.copy()) elif token == "%s": - tokens.append(ArgumentPlaceholder("number or text")) + tokens.append(ArgumentPlaceholders.NUMBER_OR_TEXT.value.copy()) token = '' continue @@ -168,9 +178,9 @@ def argument_defaults(self): @property def argument_settings(self) -> ArgSettings: - return ArgSettings(bool(self.argument_ids), - bool(self.argument_names), - bool(self.argument_defaults)) + 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, ArgumentPlaceholder] | None: @@ -247,7 +257,13 @@ def link_arguments(self): 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 - break + 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[ArgumentPlaceholder] = filter(lambda tkn: isinstance(tkn, ArgumentPlaceholder), _parsed) + for i, _arg_ph in enumerate(_arg_phs): + self.arguments[i].default = _arg_ph.default diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index ef532699..a13b81cf 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -1,8 +1,10 @@ from __future__ import annotations +import warnings +from typing import Any + from . import base, project, vlb, asset, comment, prim, block from ..utils import exceptions -from typing import Any class Sprite(base.ProjectSubcomponent): @@ -52,6 +54,7 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int self.broadcasts = _broadcasts self.variables = _variables self.lists = _lists + self._local_globals = [] self.costumes = _costumes self.sounds = _sounds @@ -97,8 +100,15 @@ def link_prims(self): # This should never happen raise exceptions.BadVLBPrimitiveError(f"{_prim} claims to be VLB, but is {_prim.type.name}") if _prim.value is None: - raise exceptions.UnlinkedVLB( - f"Prim has invalid {_prim.type.name} id") + if not self.project: + new_vlb = vlb.construct(_prim.type.name.lower(), _prim.id, _prim.name) + self._add_local_global(new_vlb) + _prim.value = new_vlb + else: + new_vlb = vlb.construct(_prim.type.name.lower(), _prim.id, _prim.name) + self.stage.add_vlb(new_vlb) + + warnings.warn(f"Prim has unknown {_prim.type.name} id; adding as global variable") _prim.name = None _prim.id = None @@ -109,6 +119,31 @@ def link_blocks(self): for _block_id, _block in self.blocks.items(): _block.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 __repr__(self): return f"Sprite<{self.name}>" @@ -198,7 +233,10 @@ def to_json(self) -> dict: 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: + for _variable in self.variables + self._local_globals: + if not isinstance(_variable, vlb.Variable): + continue + if by == "id": compare = _variable.id else: @@ -210,11 +248,12 @@ def find_variable(self, value: str, by: str = "name", multiple: bool = False) -> else: return _variable # Search in stage for global variables - if not self.is_stage: - if multiple: - _ret += self.stage.find_variable(value, by, True) - else: - return self.stage.find_variable(value, by) + 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 @@ -222,7 +261,9 @@ def find_variable(self, value: str, by: str = "name", multiple: bool = False) -> 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: + for _list in self.lists + self._local_globals: + if not isinstance(_list, vlb.List): + continue if by == "id": compare = _list.id else: @@ -234,11 +275,12 @@ def find_list(self, value: str, by: str = "name", multiple: bool = False) -> vlb else: return _list # Search in stage for global lists - if not self.is_stage: - if multiple: - _ret += self.stage.find_list(value, by, True) - else: - return self.stage.find_list(value, by) + 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 @@ -247,7 +289,9 @@ def find_broadcast(self, value: str, by: str = "name", multiple: bool = False) - vlb.Broadcast]: _ret = [] by = by.lower() - for _broadcast in self.broadcasts: + for _broadcast in self.broadcasts + self._local_globals: + if not isinstance(_broadcast, vlb.Broadcast): + continue if by == "id": compare = _broadcast.id else: @@ -259,16 +303,18 @@ def find_broadcast(self, value: str, by: str = "name", multiple: bool = False) - else: return _broadcast # Search in stage for global broadcasts - if not self.is_stage: - if multiple: - _ret += self.stage.find_broadcast(value, by, True) - else: - return self.stage.find_broadcast(value, by) + 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_block(self, value: str | Any, by: str = "opcode", multiple: bool = False) -> block.Block | list[block.Block]: + def find_block(self, value: str | Any, by: str = "opcode", multiple: bool = False) -> block.Block | list[ + block.Block]: _ret = [] by = by.lower() for _block_id, _block in self.blocks.items(): @@ -288,11 +334,12 @@ def find_block(self, value: str | Any, by: str = "opcode", multiple: bool = Fals else: return _block # Search in stage for global variables - if not self.is_stage: - if multiple: - _ret += self.stage.find_block(value, by, True) - else: - return self.stage.find_block(value, by) + 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 diff --git a/scratchattach/editor/vlb.py b/scratchattach/editor/vlb.py index cd9d1ed0..5bbcead5 100644 --- a/scratchattach/editor/vlb.py +++ b/scratchattach/editor/vlb.py @@ -4,7 +4,10 @@ from __future__ import annotations +from typing import Literal + from . import base, sprite +from ..utils import exceptions class Variable(base.NamedIDComponent): @@ -104,3 +107,17 @@ def from_json(data: tuple[str, str]): def to_json(self) -> tuple[str, str]: return self.id, self.name + + +def construct(vlb_type: Literal["variable", "list", "broadcast"], _id: str = None, _name: str = None, + _sprite: sprite.Sprite = None) -> 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/utils/exceptions.py b/scratchattach/utils/exceptions.py index 1bfd9016..ec008e41 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -232,8 +232,16 @@ class UnlinkedVLB(Exception): """ 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 From cdd5d99233e705851e3f0ab4fa45cb9fa83887aa Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sun, 24 Nov 2024 20:12:07 +0000 Subject: [PATCH 050/101] fields --- scratchattach/editor/block.py | 25 ++++++++++++++++---- scratchattach/editor/field.py | 42 ++++++++++++++++++++++++++++++++++ scratchattach/editor/sprite.py | 15 ++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 scratchattach/editor/field.py diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 95821531..5850b6e7 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -2,19 +2,23 @@ import warnings -from . import base, sprite, mutation +from . import base, sprite, mutation, field class Block(base.SpriteSubComponent): - def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False, _mutation: mutation.Mutation=None, _next: Block = None, + def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False, _mutation: mutation.Mutation=None, _fields:dict[str, field.Field]=None, _next: Block = None, _parent: Block = None, *, _next_id: str = None, _parent_id: str = None, _sprite: sprite.Sprite = None): + # Defaulting for args + if _fields is None: + _fields = {} self.opcode = _opcode self.is_shadow = _shadow self.is_top_level = _top_level self.mutation = _mutation + self.fields = _fields self._next_id = _next_id """ @@ -34,6 +38,10 @@ def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False if self.mutation: self.mutation.block = self + for iterable in (self.fields.values(), ): + for subcomponent in iterable: + subcomponent.block = self + def __repr__(self): return f"Block<{self.opcode!r}>" @@ -76,14 +84,14 @@ def from_json(data: dict): _fields = {} for _field_code, _field_data in data.get("fields", {}).items(): - _fields[_field_code] = ... + _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, _next_id=_next_id, _parent_id=_parent_id) + return Block(_opcode, _shadow, _top_level, _mutation, _fields, _next_id=_next_id, _parent_id=_parent_id) def to_json(self) -> dict: pass @@ -104,3 +112,12 @@ def link_using_sprite(self): 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: + warnings.warn(f"Could not find {_field.id!r} in {self}") + else: + _field.value = new_value + _field.id = None diff --git a/scratchattach/editor/field.py b/scratchattach/editor/field.py new file mode 100644 index 00000000..fb25e16c --- /dev/null +++ b/scratchattach/editor/field.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from . import block, vlb + +from . import base + + +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 + """ + 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"" + + @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: + pass diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index a13b81cf..14d0b526 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -313,6 +313,21 @@ def find_broadcast(self, value: str, by: str = "name", multiple: bool = False) - 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 = "opcode", multiple: bool = False) -> block.Block | list[ block.Block]: _ret = [] From faccac4ddc918a839931910873fc357c06ba848a Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sun, 24 Nov 2024 21:16:24 +0000 Subject: [PATCH 051/101] inputs --- scratchattach/editor/block.py | 16 ++++-- scratchattach/editor/comment.py | 22 ++++---- scratchattach/editor/commons.py | 2 +- scratchattach/editor/field.py | 2 +- scratchattach/editor/inputs.py | 96 +++++++++++++++++++++++++++++++++ scratchattach/editor/prim.py | 36 ++++++++++++- scratchattach/editor/project.py | 8 ++- scratchattach/editor/sprite.py | 57 ++++++++++---------- scratchattach/utils/enums.py | 7 ++- 9 files changed, 193 insertions(+), 53 deletions(-) create mode 100644 scratchattach/editor/inputs.py diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 5850b6e7..2a75116c 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -2,16 +2,18 @@ import warnings -from . import base, sprite, mutation, field +from . import base, sprite, mutation, field, inputs class Block(base.SpriteSubComponent): - def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False, _mutation: mutation.Mutation=None, _fields:dict[str, field.Field]=None, _next: Block = None, + def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False, _mutation: mutation.Mutation=None, _fields:dict[str, field.Field]=None, _inputs: dict[str, inputs.Input]=None, _next: Block = None, _parent: Block = None, *, _next_id: str = None, _parent_id: str = None, _sprite: sprite.Sprite = None): # Defaulting for args if _fields is None: _fields = {} + if _inputs is None: + _inputs = {} self.opcode = _opcode self.is_shadow = _shadow @@ -19,6 +21,7 @@ def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False self.mutation = _mutation self.fields = _fields + self.inputs = _inputs self._next_id = _next_id """ @@ -38,7 +41,7 @@ def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False if self.mutation: self.mutation.block = self - for iterable in (self.fields.values(), ): + for iterable in (self.fields.values(), self.inputs.values()): for subcomponent in iterable: subcomponent.block = self @@ -80,7 +83,7 @@ def from_json(data: dict): _inputs = {} for _input_code, _input_data in data.get("inputs", {}).items(): - _inputs[_input_code] = ... + _inputs[_input_code] = inputs.Input.from_json(_input_data) _fields = {} for _field_code, _field_data in data.get("fields", {}).items(): @@ -91,7 +94,7 @@ def from_json(data: dict): else: _mutation = None - return Block(_opcode, _shadow, _top_level, _mutation, _fields, _next_id=_next_id, _parent_id=_parent_id) + return Block(_opcode, _shadow, _top_level, _mutation, _fields, _inputs, _next_id=_next_id, _parent_id=_parent_id) def to_json(self) -> dict: pass @@ -121,3 +124,6 @@ def link_using_sprite(self): else: _field.value = new_value _field.id = None + + for _input in self.inputs.values(): + _input.link_using_block() diff --git a/scratchattach/editor/comment.py b/scratchattach/editor/comment.py index c41e9b32..f9b3ec22 100644 --- a/scratchattach/editor/comment.py +++ b/scratchattach/editor/comment.py @@ -6,8 +6,12 @@ class Comment(base.IDComponent): def __init__(self, _id: str = None, _block: block.Block = None, x: int = 0, y: int = 0, width: int = 100, height: int = 100, - text: str = '', *, _sprite: sprite.Sprite = None): + text: str = '', *, _block_id: str = None, _sprite: sprite.Sprite = 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. + """ self.x = x self.y = y @@ -20,7 +24,7 @@ def __init__(self, _id: str = None, _block: block.Block = None, x: int = 0, y: i super().__init__(_id, _sprite) def __repr__(self): - return f"Comment<{self.text[:10]!r}>" + return f"Comment<{self.text[:10]!r}...>" @staticmethod def from_json(data: tuple[str, dict]): @@ -28,10 +32,6 @@ def from_json(data: tuple[str, dict]): _id, data = data _block_id = data.get("blockId") - if _block_id is not None: - _block = block.Block(_block_id) - else: - _block = None x = data.get("x", 0) y = data.get("y", 0) @@ -41,10 +41,14 @@ def from_json(data: tuple[str, dict]): text = data.get("text") - ret = Comment(_id, _block, x, y, width, height, text) - if _block is not None: - _block.comment = ret + ret = Comment(_id, None, x, y, width, height, text, _block_id=_block_id) return ret def to_json(self) -> dict: pass + + 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 index ce88188b..5d3b024f 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -151,7 +151,7 @@ def remove_nones(obj: dict) -> None: del obj[key] -def safe_get(lst: list | tuple, _i: int, default: Any=None) -> Any: +def safe_get(lst: list | tuple, _i: int, default: Any = None) -> Any: if len(lst) <= _i: return default else: diff --git a/scratchattach/editor/field.py b/scratchattach/editor/field.py index fb25e16c..5ce83479 100644 --- a/scratchattach/editor/field.py +++ b/scratchattach/editor/field.py @@ -12,7 +12,7 @@ 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 + 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 diff --git a/scratchattach/editor/inputs.py b/scratchattach/editor/inputs.py new file mode 100644 index 00000000..b5e17885 --- /dev/null +++ b/scratchattach/editor/inputs.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +if TYPE_CHECKING: + 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 + 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 = ShadowStatuses.HAS_SHADOW, _value: prim.Prim = None, _id: str = None, + _obscurer: block.Block = 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) + + self.shadow = _shadow + self.value = _value + self.obscurer = _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_id = commons.safe_get(data, 2) + return Input(_shadow, _value, _id, _obscurer_id) + + def to_json(self) -> list: + pass + + def link_using_block(self): + 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 + + 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 + + if isinstance(self.value, prim.Prim): + self.value.sprite = self.sprite + self.value.link_using_sprite() diff --git a/scratchattach/editor/prim.py b/scratchattach/editor/prim.py index 84ebcc11..70ebebfb 100644 --- a/scratchattach/editor/prim.py +++ b/scratchattach/editor/prim.py @@ -1,10 +1,11 @@ from __future__ import annotations +import warnings from dataclasses import dataclass from typing import Callable, Final from . import base, sprite, vlb -from ..utils import enums +from ..utils import enums, exceptions @dataclass(init=True, repr=True) @@ -52,7 +53,8 @@ def find(cls, value, by: str, apply_func: Callable = None) -> PrimType: class Prim(base.SpriteSubComponent): - def __init__(self, _primtype: PrimType, _value: str | vlb.Variable | vlb.List | vlb.Broadcast = None, _name: str = None, _id: str = None, _x: int = None, + def __init__(self, _primtype: PrimType, _value: str | vlb.Variable | vlb.List | vlb.Broadcast = None, + _name: str = None, _id: str = None, _x: int = None, _y: int = None, _sprite: sprite.Sprite = None): """ Class representing a Scratch string, number, angle, variable etc. @@ -127,3 +129,33 @@ def from_json(data: list): def to_json(self) -> list: pass + + 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.id, "id") + + elif self.type.name == "list": + self.value = self.sprite.find_list(self.id, "id") + + elif self.type.name == "broadcast": + self.value = self.sprite.find_broadcast(self.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.id, self.name) + self.sprite._add_local_global(new_vlb) + self.value = new_vlb + + else: + new_vlb = vlb.construct(self.type.name.lower(), self.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.id = None diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index 5ad6d296..66d32f07 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -3,7 +3,7 @@ import json import warnings from io import BytesIO, TextIOWrapper -from typing import Any, Iterable, Union +from typing import Any, Iterable from zipfile import ZipFile from . import base, meta, extension, monitor, sprite, asset @@ -11,7 +11,7 @@ class Project(base.JSONSerializable): - def __init__(self, _name:str=None, _meta: meta.Meta = None, _extensions: Iterable[extension.Extension] = (), + 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): # Defaulting for list parameters @@ -35,10 +35,8 @@ def __init__(self, _name:str=None, _meta: meta.Meta = None, _extensions: Iterabl for _sprite in self.sprites: if _sprite.is_stage: _stage_count += 1 - _sprite.project = self - _sprite.link_prims() - _sprite.link_blocks() + _sprite.link_subcomponents() if _stage_count != 1: raise exceptions.InvalidStageCount(f"Project {self}") diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index 14d0b526..ffadbb55 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -81,36 +81,17 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int for sub_component in iterable: sub_component.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) """ - assert self.project is not None - - # Link prims to vars/lists/broadcasts - for _id, _prim in self.prims.items(): - if _prim.is_vlb: - if _prim.type.name == "variable": - _prim.value = self.find_variable(_prim.id, "id") - elif _prim.type.name == "list": - _prim.value = self.find_list(_prim.id, "id") - elif _prim.type.name == "broadcast": - _prim.value = self.find_broadcast(_prim.id, "id") - else: - # This should never happen - raise exceptions.BadVLBPrimitiveError(f"{_prim} claims to be VLB, but is {_prim.type.name}") - if _prim.value is None: - if not self.project: - new_vlb = vlb.construct(_prim.type.name.lower(), _prim.id, _prim.name) - self._add_local_global(new_vlb) - _prim.value = new_vlb - else: - new_vlb = vlb.construct(_prim.type.name.lower(), _prim.id, _prim.name) - self.stage.add_vlb(new_vlb) - - warnings.warn(f"Prim has unknown {_prim.type.name} id; adding as global variable") - _prim.name = None - _prim.id = None + for _prim in self.prims.values(): + _prim.link_using_sprite() def link_blocks(self): """ @@ -119,6 +100,10 @@ def link_blocks(self): 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 @@ -328,20 +313,34 @@ def find_vlb(self, value: str, by: str = "name", multiple: bool = False) -> vlb. return _ret return self.find_broadcast(value, by) - def find_block(self, value: str | Any, by: str = "opcode", multiple: bool = False) -> block.Block | list[ + def find_block(self, value: str | Any, by: str, multiple: bool = False) -> block.Block | list[ block.Block]: _ret = [] by = by.lower() - for _block_id, _block in self.blocks.items(): + for _block_id, _block in (self.blocks | self.prims).items(): + 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 - else: + 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: diff --git a/scratchattach/utils/enums.py b/scratchattach/utils/enums.py index 67d4d914..b0b91762 100644 --- a/scratchattach/utils/enums.py +++ b/scratchattach/utils/enums.py @@ -37,7 +37,12 @@ def apply_func(x): item_obj = item.value try: - if apply_func(getattr(item_obj, by)) == value: + if by is None: + _val = item_obj + else: + _val = getattr(item_obj, by) + + if apply_func(_val) == value: return item_obj except TypeError: pass From 9b19ae7b5a6653559f025c30b8b0baccdd688648 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sun, 24 Nov 2024 21:38:34 +0000 Subject: [PATCH 052/101] minor meta edit --- scratchattach/editor/meta.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/scratchattach/editor/meta.py b/scratchattach/editor/meta.py index 60eba555..7078b32f 100644 --- a/scratchattach/editor/meta.py +++ b/scratchattach/editor/meta.py @@ -45,16 +45,15 @@ def __init__(self, semver: str = "3.0.0", vm: str = DEFAULT_VM, agent: str = DEF if platform is None and META_SET_PLATFORM: platform = DEFAULT_PLATFORM.copy() - # Thanks to TurboWarp for this pattern ↓↓↓↓, I just copied it - if re.match("^([0-9]+\\.[0-9]+\\.[0-9]+)($|-)", vm) is None: - raise ValueError( - f"\"{vm}\" does not match pattern \"^([0-9]+\\.[0-9]+\\.[0-9]+)($|-)\" - maybe try \"0.0.0\"?") - 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: @@ -62,6 +61,11 @@ def __repr__(self): 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, From 0841332a49f8933bcd0eef72b493c519f31477ec Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Mon, 25 Nov 2024 21:52:12 +0000 Subject: [PATCH 053/101] exporting projects (my ide is supper laggy rn bruh) --- scratchattach/editor/__init__.py | 1 + scratchattach/editor/asset.py | 26 ++++- scratchattach/editor/base.py | 6 ++ scratchattach/editor/block.py | 50 ++++++++-- scratchattach/editor/comment.py | 31 ++++-- scratchattach/editor/commons.py | 15 +++ scratchattach/editor/field.py | 23 ++++- scratchattach/editor/inputs.py | 49 +++++++--- scratchattach/editor/meta.py | 5 + scratchattach/editor/monitor.py | 35 +++++-- scratchattach/editor/prim.py | 18 ++-- scratchattach/editor/project.py | 88 +++++++++++++++-- scratchattach/editor/sprite.py | 57 +++++++++-- scratchattach/editor/vlb.py | 17 ++-- .../other/project_json_capabilities.py | 96 +------------------ scratchattach/site/project.py | 19 ++++ 16 files changed, 374 insertions(+), 162 deletions(-) diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py index 8339e797..f932cd64 100644 --- a/scratchattach/editor/__init__.py +++ b/scratchattach/editor/__init__.py @@ -2,3 +2,4 @@ from .project import Project from .extension import Extensions, Extension from .mutation import Mutation, Argument, parse_proc_code +from .meta import Meta, set_meta_platform diff --git a/scratchattach/editor/asset.py b/scratchattach/editor/asset.py index 6e05313d..6f622d6c 100644 --- a/scratchattach/editor/asset.py +++ b/scratchattach/editor/asset.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from hashlib import md5 +import requests from . import base, project, commons, sprite @@ -9,11 +10,23 @@ @dataclass(init=True) class AssetFile: filename: str - data: bytes + _data: bytes = None def __repr__(self): return f"AssetFile(filename={self.filename!r})" + @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 + class Asset(base.SpriteSubComponent): def __init__(self, @@ -47,6 +60,17 @@ def file_name(self): def md5ext(self): return self.file_name + @property + def asset_file(self) -> AssetFile: + for asset_file in self.project.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") diff --git a/scratchattach/editor/base.py b/scratchattach/editor/base.py index 7132c038..55f11263 100644 --- a/scratchattach/editor/base.py +++ b/scratchattach/editor/base.py @@ -5,6 +5,7 @@ from __future__ import annotations import copy +import json from abc import ABC, abstractmethod from typing import Any, TYPE_CHECKING @@ -32,6 +33,11 @@ def from_json(data: dict | list | Any): 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 ProjectSubcomponent(JSONSerializable, ABC): def __init__(self, _project: project.Project = None): diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 2a75116c..be9ffd4e 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -2,12 +2,15 @@ import warnings -from . import base, sprite, mutation, field, inputs +from . import base, sprite, mutation, field, inputs, commons class Block(base.SpriteSubComponent): - def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False, _mutation: mutation.Mutation=None, _fields:dict[str, field.Field]=None, _inputs: dict[str, inputs.Input]=None, _next: Block = None, - _parent: Block = None, + def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False, + _mutation: mutation.Mutation = None, _fields: dict[str, field.Field] = None, + _inputs: dict[str, inputs.Input] = None, x: int = None, y: int = None, + + _next: Block = None, _parent: Block = None, *, _next_id: str = None, _parent_id: str = None, _sprite: sprite.Sprite = None): # Defaulting for args if _fields is None: @@ -19,6 +22,8 @@ def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False 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 @@ -50,11 +55,26 @@ def __repr__(self): @property def id(self) -> str | None: - warnings.warn(f"Using block IDs can cause consistency issues and is not recommended") + # 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 + @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]: """ @@ -94,10 +114,28 @@ def from_json(data: dict): else: _mutation = None - return Block(_opcode, _shadow, _top_level, _mutation, _fields, _inputs, _next_id=_next_id, _parent_id=_parent_id) + _x, _y = data.get("x"), data.get("y") + + return Block(_opcode, _shadow, _top_level, _mutation, _fields, _inputs, _x, _y, _next_id=_next_id, + _parent_id=_parent_id) def to_json(self) -> dict: - pass + _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, + } + + commons.noneless_update(_json, { + "x": self.x, + "y": self.y, + }) + + return _json def link_using_sprite(self): if self.mutation: diff --git a/scratchattach/editor/comment.py b/scratchattach/editor/comment.py index f9b3ec22..ebe9c581 100644 --- a/scratchattach/editor/comment.py +++ b/scratchattach/editor/comment.py @@ -5,10 +5,10 @@ class Comment(base.IDComponent): def __init__(self, _id: str = None, _block: block.Block = None, x: int = 0, y: int = 0, width: int = 100, - height: int = 100, + height: int = 100, minimized: bool = False, text: str = '', *, _block_id: str = None, _sprite: sprite.Sprite = None): self.block = _block - self.block_id = _block_id + 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. """ @@ -19,6 +19,7 @@ def __init__(self, _id: str = None, _block: block.Block = None, x: int = 0, y: i self.width = width self.height = height + self.minimized = minimized self.text = text super().__init__(_id, _sprite) @@ -26,6 +27,15 @@ def __init__(self, _id: str = None, _block: block.Block = None, x: int = 0, y: i 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 @@ -39,16 +49,23 @@ def from_json(data: tuple[str, dict]): 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, text, _block_id=_block_id) + ret = Comment(_id, None, x, y, width, height, minimized, text, _block_id=_block_id) return ret def to_json(self) -> dict: - pass + 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_id is not None: + self.block = self.sprite.find_block(self._block_id, "id") if self.block is not None: - self.block_id = None + self._block_id = None diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index 5d3b024f..365b71fc 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -68,6 +68,8 @@ def read_exponent(sub: str): def consume_json(string: 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 @@ -156,3 +158,16 @@ def safe_get(lst: list | tuple, _i: int, default: Any = None) -> Any: 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] diff --git a/scratchattach/editor/field.py b/scratchattach/editor/field.py index 5ce83479..ad25eb16 100644 --- a/scratchattach/editor/field.py +++ b/scratchattach/editor/field.py @@ -5,7 +5,7 @@ if TYPE_CHECKING: from . import block, vlb -from . import base +from . import base, commons class Field(base.BlockSubComponent): @@ -28,6 +28,23 @@ def __repr__(self): 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 + @staticmethod def from_json(data: list[str, str | None]): # Sometimes you may have a stray field with no id. Not sure why @@ -39,4 +56,6 @@ def from_json(data: list[str, str | None]): return Field(_value, _id) def to_json(self) -> dict: - pass + return commons.trim_final_nones([ + self.value_str, self.value_id + ]) diff --git a/scratchattach/editor/inputs.py b/scratchattach/editor/inputs.py index b5e17885..95505bc7 100644 --- a/scratchattach/editor/inputs.py +++ b/scratchattach/editor/inputs.py @@ -1,10 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Final - -if TYPE_CHECKING: - from . import block +import warnings +from typing import Final +from . import block from . import base, commons, prim from dataclasses import dataclass @@ -35,8 +34,8 @@ def find(cls, idx: int) -> ShadowStatus: class Input(base.BlockSubComponent): - def __init__(self, _shadow: ShadowStatus = ShadowStatuses.HAS_SHADOW, _value: prim.Prim = None, _id: str = None, - _obscurer: block.Block = None, *, _obscurer_id: str = None, _block: block.Block = None): + def __init__(self, _shadow: ShadowStatus = ShadowStatuses.HAS_SHADOW, _value: prim.Prim | block.Block = None, _id: str = None, + _obscurer: prim.Prim | block.Block = 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 @@ -44,8 +43,8 @@ def __init__(self, _shadow: ShadowStatus = ShadowStatuses.HAS_SHADOW, _value: pr super().__init__(_block) self.shadow = _shadow - self.value = _value - self.obscurer = _obscurer + self.value: prim.Prim | block.Block = _value + self.obscurer: prim.Prim | block.Block = _obscurer self._id = _id """ @@ -72,11 +71,36 @@ def from_json(data: list): else: _id = data[1] - _obscurer_id = commons.safe_get(data, 2) - return Input(_shadow, _value, _id, _obscurer_id) + _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: - pass + 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): if self._id is not None: @@ -94,3 +118,6 @@ def link_using_block(self): if isinstance(self.value, prim.Prim): self.value.sprite = self.sprite self.value.link_using_sprite() + + if self.obscurer is not None: + self.obscurer.sprite = self.sprite diff --git a/scratchattach/editor/meta.py b/scratchattach/editor/meta.py index 7078b32f..0c9e38b6 100644 --- a/scratchattach/editor/meta.py +++ b/scratchattach/editor/meta.py @@ -35,6 +35,11 @@ def from_json(data: dict | None): 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): diff --git a/scratchattach/editor/monitor.py b/scratchattach/editor/monitor.py index 10f8eac7..52c27f8b 100644 --- a/scratchattach/editor/monitor.py +++ b/scratchattach/editor/monitor.py @@ -1,10 +1,15 @@ -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from . import project from . import base -class Monitor(base.JSONSerializable): - def __init__(self, reporter: base.SpriteSubComponent = None, +class Monitor(base.ProjectSubcomponent): + def __init__(self, reporter: base.NamedIDComponent = None, mode: str = "default", opcode: str = "data_variable", params: dict = None, @@ -17,13 +22,18 @@ def __init__(self, reporter: base.SpriteSubComponent = None, visible: bool = False, slider_min: int | float = 0, slider_max: int | float = 100, - is_discrete: bool = True): + 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 = {} @@ -45,18 +55,27 @@ def __init__(self, reporter: base.SpriteSubComponent = None, 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 reporter_id(self): + if self.reporter is not None: + return self.reporter.id + else: + return self._reporter_id + @staticmethod - def from_json(data: dict | list | Any): + def from_json(data: dict): _id = data["id"] # ^^ NEED TO FIND REPORTER OBJECT mode = data["mode"] opcode = data["opcode"] - params = data["params"] + params: dict = data["params"] sprite_name = data["spriteName"] @@ -74,11 +93,11 @@ def from_json(data: dict | list | Any): 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) + slider_max, is_discrete, reporter_id=_id) def to_json(self): _json = { - "id": f"PLEASE GET ID FROM VALUE {self.reporter}", + "id": self.reporter_id, "mode": self.mode, "opcode": self.opcode, diff --git a/scratchattach/editor/prim.py b/scratchattach/editor/prim.py index 70ebebfb..2305b3e4 100644 --- a/scratchattach/editor/prim.py +++ b/scratchattach/editor/prim.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Callable, Final -from . import base, sprite, vlb +from . import base, sprite, vlb, commons from ..utils import enums, exceptions @@ -34,8 +34,6 @@ def to_json(self) -> int: class PrimTypes(enums._EnumWrapper): - NULL = PrimType(1, "null") - BLOCK = PrimType(2, "block") NUMBER = PrimType(4, "number", BASIC_ATTRS) POSITIVE_NUMBER = PrimType(5, "positive number", BASIC_ATTRS) POSITIVE_INTEGER = PrimType(6, "positive integer", BASIC_ATTRS) @@ -109,12 +107,7 @@ def from_json(data: list): _prim_type = PrimTypes.find(_type_idx, "code") _value, _name, _id, _x, _y = (None,) * 5 - if _prim_type == PrimTypes.NULL: - pass - elif _prim_type == PrimTypes.BLOCK: - pass - - elif _prim_type.attrs == BASIC_ATTRS: + if _prim_type.attrs == BASIC_ATTRS: assert len(data) == 2 _value = data[1] @@ -123,12 +116,15 @@ def from_json(data: list): _name, _id = data[1:3] if len(data) == 5: - _x, _y = data[3:5] + _x, _y = data[3:] return Prim(_prim_type, _value, _name, _id, _x, _y) def to_json(self) -> list: - pass + 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 diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index 66d32f07..75bd0acc 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -1,14 +1,20 @@ from __future__ import annotations import json +import os +import shutil import warnings from io import BytesIO, TextIOWrapper -from typing import Any, Iterable +from typing import Iterable, TYPE_CHECKING, Generator from zipfile import ZipFile from . import base, meta, extension, monitor, sprite, asset +from ..site.project import get_project from ..utils import exceptions +if TYPE_CHECKING: + from . import vlb + class Project(base.JSONSerializable): def __init__(self, _name: str = None, _meta: meta.Meta = None, _extensions: Iterable[extension.Extension] = (), @@ -25,19 +31,31 @@ def __init__(self, _name: str = None, _meta: meta.Meta = None, _extensions: Iter self.meta = _meta self.extensions = _extensions self.monitors = _monitors - self.sprites = _sprites + self.sprites = list(_sprites) self.asset_data = _asset_data + # 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.project = self _sprite.link_subcomponents() + # Link monitors to their VLBs + for _monitor in self.monitors: + if _monitor.opcode in ("data_variable", "data_listcontents", "event_broadcast_menu"): + new_vlb = self.find_vlb(_monitor._reporter_id, "id") + if new_vlb is not None: + _monitor.reporter = new_vlb + _monitor._reporter_id = None + if _stage_count != 1: raise exceptions.InvalidStageCount(f"Project {self}") @@ -55,11 +73,24 @@ def stage(self) -> sprite.Sprite: if _sprite.is_stage: return _sprite - def to_json(self) -> dict | list | Any: - pass + 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 @staticmethod - def from_json(data: dict | list | Any): + def from_json(data: dict): assert isinstance(data, dict) # Load metadata @@ -133,3 +164,48 @@ def from_sb3(data: str | bytes | TextIOWrapper, load_assets: bool = True, _name: project.name = _name 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, 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 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}\"") diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index ffadbb55..28015abd 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -4,7 +4,6 @@ from typing import Any from . import base, project, vlb, asset, comment, prim, block -from ..utils import exceptions class Sprite(base.ProjectSubcomponent): @@ -41,9 +40,9 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int if _comments is None: _comments = [] if _prims is None: - _prims = [] + _prims = {} if _blocks is None: - _blocks = [] + _blocks = {} self.is_stage = is_stage self.name = name @@ -212,7 +211,44 @@ def read_idcomponent(attr_name: str, cls: type[base.IDComponent]): ) def to_json(self) -> dict: - pass + _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_variable(self, value: str, by: str = "name", multiple: bool = False) -> vlb.Variable | list[vlb.Variable]: @@ -298,12 +334,13 @@ def find_broadcast(self, value: str, by: str = "name", multiple: bool = False) - if multiple: return _ret - def find_vlb(self, value: str, by: str = "name", multiple: bool = False) -> vlb.Variable | vlb.List | vlb.Broadcast | list[ + 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) + self.find_list(value, by, True) + \ + self.find_broadcast(value, by, True) else: _ret = self.find_variable(value, by) if _ret is not None: @@ -313,11 +350,13 @@ def find_vlb(self, value: str, by: str = "name", multiple: bool = False) -> vlb. return _ret return self.find_broadcast(value, by) - def find_block(self, value: str | Any, by: str, multiple: bool = False) -> block.Block | list[ - block.Block]: + 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) diff --git a/scratchattach/editor/vlb.py b/scratchattach/editor/vlb.py index 5bbcead5..f86a2dc8 100644 --- a/scratchattach/editor/vlb.py +++ b/scratchattach/editor/vlb.py @@ -48,16 +48,16 @@ def from_json(data: tuple[str, tuple[str, str | int | float] | tuple[str, str | return Variable(_id, _name, _value, _is_cloud) - def to_json(self) -> tuple[str, tuple[str, str | int | float, bool] | tuple[str, str | int | float]]: + def to_json(self) -> tuple[str, str | int | float, bool] | tuple[str, str | int | float]: """ - Returns Variable data as the variable id, then a tuple representing it + Returns Variable data as a tuple """ if self.is_cloud: _ret = self.name, self.value, True else: _ret = self.name, self.value - return self.id, _ret + return _ret class List(base.NamedIDComponent): @@ -85,9 +85,9 @@ def from_json(data: tuple[str, tuple[str, str | int | float] | tuple[str, str | def to_json(self) -> tuple[str, tuple[str, str | int | float, bool] | tuple[str, str | int | float]]: """ - Returns List data as the list id, then a tuple representing it + Returns List data as a tuple """ - return self.id, (self.name, self.value) + return self.name, self.value class Broadcast(base.NamedIDComponent): @@ -105,8 +105,11 @@ def from_json(data: tuple[str, str]): return Broadcast(_id, _name) - def to_json(self) -> tuple[str, str]: - return self.id, self.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, diff --git a/scratchattach/other/project_json_capabilities.py b/scratchattach/other/project_json_capabilities.py index fe5670e2..78429961 100644 --- a/scratchattach/other/project_json_capabilities.py +++ b/scratchattach/other/project_json_capabilities.py @@ -1,6 +1,7 @@ """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 import hashlib @@ -9,20 +10,15 @@ import string import zipfile from abc import ABC, abstractmethod - from ..utils import exceptions from ..utils.commons import empty_project_json 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): @@ -30,28 +26,21 @@ def __init__(self, **entries): self.id = None # Update attributes from entries dict: self.__dict__.update(entries) - @abstractmethod 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 @@ -63,23 +52,18 @@ def from_json(self, data: dict): 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} 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 = [] @@ -88,7 +72,6 @@ def previous_chain(self): block = block.previous_block() chain.insert(0, block) return chain - def attached_chain(self): chain = [] block = self @@ -96,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 @@ -107,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 = [] @@ -122,62 +102,46 @@ def duplicate_chain(self): 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): self.isStage = data["isStage"] self.name = data["name"] @@ -221,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: @@ -239,42 +202,34 @@ 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)) 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)) 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)) 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)) 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)) 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, @@ -289,11 +244,9 @@ def create_sound(self, asset_content, *, name="new sound", dataFormat="mp3", rat 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): 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, @@ -309,76 +262,59 @@ def create_costume(self, asset_content, *, name="new costume", dataFormat="svg", 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): 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): 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): 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") @@ -387,22 +323,18 @@ 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): 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 + "/" @@ -420,7 +352,6 @@ def download(self, *, filename=None, dir=""): "Failed to download asset" ) ) - def __init__(self, *, sprites=[], monitors=[], extensions=[], meta=[{"agent": None}], _session=None): # sprites are called "targets" in the initial API response self.sprites = sprites @@ -428,7 +359,6 @@ def __init__(self, *, sprites=[], monitors=[], extensions=[], meta=[{"agent": No self.extensions = extensions self.meta = meta self._session = _session - def from_json(self, data: dict): """ Imports the project data from a dict that contains the raw project json @@ -445,10 +375,9 @@ def from_json(self, data: dict): # 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 @@ -459,47 +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] - 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)) 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. @@ -511,20 +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): 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: @@ -538,14 +450,10 @@ 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 + "/" diff --git a/scratchattach/site/project.py b/scratchattach/site/project.py index 065fff73..a8e6c8c5 100644 --- a/scratchattach/site/project.py +++ b/scratchattach/site/project.py @@ -267,6 +267,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. From 7b00168701045b9e480069554164b8e60fe37791 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Tue, 26 Nov 2024 07:18:22 +0000 Subject: [PATCH 054/101] no need for shutil --- scratchattach/editor/project.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index 75bd0acc..52ab51d9 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -2,7 +2,6 @@ import json import os -import shutil import warnings from io import BytesIO, TextIOWrapper from typing import Iterable, TYPE_CHECKING, Generator From 1fe260266b6fe7bfcba9a1025242ce4ca5aac5d5 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Tue, 26 Nov 2024 07:19:39 +0000 Subject: [PATCH 055/101] need to add mutation json --- scratchattach/editor/block.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index be9ffd4e..9542e8ec 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -135,6 +135,11 @@ def to_json(self) -> dict: "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): From 802b2aabfe95dd77c7fdd53e9f3fd1d9deee5b01 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Tue, 26 Nov 2024 07:21:11 +0000 Subject: [PATCH 056/101] mutation json --- scratchattach/editor/mutation.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/scratchattach/editor/mutation.py b/scratchattach/editor/mutation.py index e7ca92c3..d09f280d 100644 --- a/scratchattach/editor/mutation.py +++ b/scratchattach/editor/mutation.py @@ -241,7 +241,21 @@ def get(_lst: list | tuple | None, _idx: int): return Mutation(_tag_name, _children, _proc_code, _is_warp, _arguments, _has_next, _argument_settings) def to_json(self) -> dict | None: - pass + _json = { + "tagName": self.tag_name, + "children": self.children, + } + commons.noneless_update(_json, { + "proccode": self.proc_code, + "argumentids": self.argument_ids, + "warp": self.is_warp, + "argumentnames": self.argument_names, + "argumentdefaults": self.argument_defaults, + + "hasnext": self.has_next, + }) + return _json + def link_arguments(self): if self.arguments is None: From ed228a7fe41c282fdf087f73e99874d78888f485 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Tue, 26 Nov 2024 07:26:04 +0000 Subject: [PATCH 057/101] mutation json update (not done) --- scratchattach/editor/commons.py | 6 ++++++ scratchattach/editor/mutation.py | 11 +++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index 365b71fc..4317cd3d 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -171,3 +171,9 @@ def trim_final_nones(lst: list) -> list: break i -= 1 return lst[:i] + +def dumps_ifnn(obj: Any) -> str: + if obj is None: + return None + else: + return json.dumps(obj) diff --git a/scratchattach/editor/mutation.py b/scratchattach/editor/mutation.py index d09f280d..7ab01b5c 100644 --- a/scratchattach/editor/mutation.py +++ b/scratchattach/editor/mutation.py @@ -247,16 +247,15 @@ def to_json(self) -> dict | None: } commons.noneless_update(_json, { "proccode": self.proc_code, - "argumentids": self.argument_ids, - "warp": self.is_warp, - "argumentnames": self.argument_names, - "argumentdefaults": self.argument_defaults, + "argumentids": commons.dumps_ifnn(self.argument_ids), + "warp": commons.dumps_ifnn(self.is_warp), + "argumentnames": commons.dumps_ifnn(self.argument_names), + "argumentdefaults": commons.dumps_ifnn(self.argument_defaults), - "hasnext": self.has_next, + "hasnext": commons.dumps_ifnn(self.has_next), }) return _json - def link_arguments(self): if self.arguments is None: return From 6025639c1a598c4b1eb2feaa19b382e983a62353 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Tue, 26 Nov 2024 22:17:46 +0000 Subject: [PATCH 058/101] fixed mutations --- scratchattach/editor/asset.py | 9 +++++++- scratchattach/editor/monitor.py | 9 ++++++++ scratchattach/editor/mutation.py | 9 ++++++-- scratchattach/editor/project.py | 35 ++++++++++++++++---------------- scratchattach/editor/sprite.py | 25 +++++++++++++++++++---- 5 files changed, 63 insertions(+), 24 deletions(-) diff --git a/scratchattach/editor/asset.py b/scratchattach/editor/asset.py index 6f622d6c..28fdd388 100644 --- a/scratchattach/editor/asset.py +++ b/scratchattach/editor/asset.py @@ -4,13 +4,14 @@ from hashlib import md5 import requests -from . import base, project, commons, sprite +from . import base, commons, sprite @dataclass(init=True) class AssetFile: filename: str _data: bytes = None + _md5: str = None def __repr__(self): return f"AssetFile(filename={self.filename!r})" @@ -27,6 +28,12 @@ def data(self): 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, diff --git a/scratchattach/editor/monitor.py b/scratchattach/editor/monitor.py index 52c27f8b..a964642b 100644 --- a/scratchattach/editor/monitor.py +++ b/scratchattach/editor/monitor.py @@ -122,6 +122,15 @@ def to_json(self): 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, diff --git a/scratchattach/editor/mutation.py b/scratchattach/editor/mutation.py index 7ab01b5c..831381a5 100644 --- a/scratchattach/editor/mutation.py +++ b/scratchattach/editor/mutation.py @@ -196,6 +196,8 @@ def from_json(data: dict) -> Mutation: # 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 @@ -218,6 +220,8 @@ def from_json(data: dict) -> Mutation: # 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: @@ -247,13 +251,14 @@ def to_json(self) -> dict | None: } commons.noneless_update(_json, { "proccode": self.proc_code, - "argumentids": commons.dumps_ifnn(self.argument_ids), "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), + "hasNext": commons.dumps_ifnn(self.has_next) }) + return _json def link_arguments(self): diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index 52ab51d9..fa2ec095 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -4,27 +4,29 @@ import os import warnings from io import BytesIO, TextIOWrapper -from typing import Iterable, TYPE_CHECKING, Generator +from typing import Iterable, Generator, BinaryIO from zipfile import ZipFile -from . import base, meta, extension, monitor, sprite, asset +from . import base, meta, extension, monitor, sprite, asset, vlb + from ..site.project import get_project -from ..utils import exceptions +from ..site import session -if TYPE_CHECKING: - from . import vlb +from ..utils import exceptions class Project(base.JSONSerializable): 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): + _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 @@ -45,15 +47,12 @@ def __init__(self, _name: str = None, _meta: meta.Meta = None, _extensions: Iter for _sprite in self.sprites: if _sprite.is_stage: _stage_count += 1 - _sprite.link_subcomponents() - # Link monitors to their VLBs + _sprite.link_using_project() + + # Link monitors for _monitor in self.monitors: - if _monitor.opcode in ("data_variable", "data_listcontents", "event_broadcast_menu"): - new_vlb = self.find_vlb(_monitor._reporter_id, "id") - if new_vlb is not None: - _monitor.reporter = new_vlb - _monitor._reporter_id = None + _monitor.link_using_project() if _stage_count != 1: raise exceptions.InvalidStageCount(f"Project {self}") @@ -113,7 +112,7 @@ def from_json(data: dict): return Project(None, _meta, _extensions, _monitors, _sprites) @staticmethod - def from_sb3(data: str | bytes | TextIOWrapper, load_assets: bool = True, _name: str = None): + def from_sb3(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: str = None): """ Load a project from an .sb3 file/bytes/file path """ @@ -153,8 +152,10 @@ def from_sb3(data: str | bytes | TextIOWrapper, load_assets: bool = True, _name: asset_data = [] for filename in archive.namelist(): if filename != "project.json": + md5_hash = filename.split('.')[0] + asset_data.append( - asset.AssetFile(filename, archive.read(filename)) + asset.AssetFile(filename, archive.read(filename), md5_hash) ) project.asset_data = asset_data else: @@ -177,7 +178,7 @@ def from_id(project_id: int, _name: str = None): _proj.name = _name return _proj - def find_vlb(self, value: str, by: str = "name", + 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 = [] @@ -191,7 +192,7 @@ def find_vlb(self, value: str, by: str = "name", if multiple: return _ret - def export(self, fp: str, auto_open: bool = False, export_as_zip: bool=True): + def export(self, fp: str, *, auto_open: bool = False, export_as_zip: bool = True): data = self.to_json() if export_as_zip: diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index 28015abd..717507f1 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -1,7 +1,8 @@ from __future__ import annotations -import warnings +import json, warnings from typing import Any +from zipfile import ZipFile from . import base, project, vlb, asset, comment, prim, block @@ -80,7 +81,10 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int for sub_component in iterable: sub_component.sprite = self - def link_subcomponents(self): + def __repr__(self): + return f"Sprite<{self.name}>" + + def link_using_project(self): self.link_prims() self.link_blocks() self.link_comments() @@ -129,8 +133,6 @@ def add_vlb(self, _vlb: base.NamedIDComponent): else: warnings.warn(f"Invalid 'VLB' {_vlb} of type: {type(_vlb)}") - def __repr__(self): - return f"Sprite<{self.name}>" @property def vlbs(self) -> list[base.NamedIDComponent]: @@ -396,3 +398,18 @@ def find_block(self, value: str | Any, by: str, multiple: bool = False) -> block if multiple: return _ret + + def export(self, fp: str=None, *, 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("sprite.json", json.dumps(data)) + else: + with open(fp, "w") as json_file: + json.dump(data, json_file) From 1bebf6472d4c0df7a698cb1d1972d6035183191d Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 27 Nov 2024 06:42:00 +0000 Subject: [PATCH 059/101] functions for systematically making new ids. will need to make special functions to add sprites/blocks/vlbs/other stuff though --- scratchattach/editor/commons.py | 11 +++++++++++ scratchattach/editor/prim.py | 20 ++++++++++---------- scratchattach/editor/project.py | 13 ++++++++++++- scratchattach/editor/sprite.py | 25 +++++++++++++++++++++---- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index 4317cd3d..8dcf7599 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -172,8 +172,19 @@ def trim_final_nones(lst: list) -> list: 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(id_list: list[str]) -> str: + i = 0 + new_id = None + while new_id in id_list or new_id is None: + new_id = str(i) + i += 1 + + return new_id diff --git a/scratchattach/editor/prim.py b/scratchattach/editor/prim.py index 2305b3e4..98fda578 100644 --- a/scratchattach/editor/prim.py +++ b/scratchattach/editor/prim.py @@ -68,7 +68,7 @@ def __init__(self, _primtype: PrimType, _value: str | vlb.Variable | vlb.List | 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.id = _id + self.value_id = _id """ It's not an object accessed by id, but it may reference an object with an id. @@ -106,19 +106,19 @@ def from_json(data: list): _type_idx = data[0] _prim_type = PrimTypes.find(_type_idx, "code") - _value, _name, _id, _x, _y = (None,) * 5 + _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, _id = data[1:3] + _name, _value_id = data[1:3] if len(data) == 5: _x, _y = data[3:] - return Prim(_prim_type, _value, _name, _id, _x, _y) + return Prim(_prim_type, _value, _name, _value_id, _x, _y) def to_json(self) -> list: if self.type.attrs == BASIC_ATTRS: @@ -130,28 +130,28 @@ 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.id, "id") + self.value = self.sprite.find_variable(self.value_id, "id") elif self.type.name == "list": - self.value = self.sprite.find_list(self.id, "id") + self.value = self.sprite.find_list(self.value_id, "id") elif self.type.name == "broadcast": - self.value = self.sprite.find_broadcast(self.id, "id") + 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.id, self.name) + 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.id, self.name) + 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.id = None + self.value_id = None diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index fa2ec095..608839ab 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -7,7 +7,7 @@ from typing import Iterable, Generator, BinaryIO from zipfile import ZipFile -from . import base, meta, extension, monitor, sprite, asset, vlb +from . import base, meta, extension, monitor, sprite, asset, vlb, commons from ..site.project import get_project from ..site import session @@ -87,6 +87,13 @@ def assets(self) -> Generator[asset.Asset, None, None]: for _asset in _sprite.assets: yield _asset + @property + def all_ids(self): + _ret = [] + for _sprite in self.sprites: + _ret += _sprite.all_ids + return _ret + @staticmethod def from_json(data: dict): assert isinstance(data, dict) @@ -209,3 +216,7 @@ def export(self, fp: str, *, auto_open: bool = False, export_as_zip: bool = True if auto_open: os.system(f"explorer.exe \"{fp}\"") + + @property + def new_id(self): + return commons.gen_id(self.all_ids) diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index 717507f1..cf30ec1e 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -1,10 +1,11 @@ from __future__ import annotations -import json, warnings +import json +import warnings from typing import Any from zipfile import ZipFile -from . import base, project, vlb, asset, comment, prim, block +from . import base, project, vlb, asset, comment, prim, block, commons class Sprite(base.ProjectSubcomponent): @@ -133,9 +134,11 @@ def add_vlb(self, _vlb: base.NamedIDComponent): else: warnings.warn(f"Invalid 'VLB' {_vlb} of type: {type(_vlb)}") - @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 @@ -399,7 +402,7 @@ def find_block(self, value: str | Any, by: str, multiple: bool = False) -> block if multiple: return _ret - def export(self, fp: str=None, *, export_as_zip: bool = True): + def export(self, fp: str = None, *, export_as_zip: bool = True): data = self.to_json() if export_as_zip: @@ -413,3 +416,17 @@ def export(self, fp: str=None, *, export_as_zip: bool = True): 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 + + @property + def new_id(self): + return commons.gen_id(self.project.all_ids if self.project else self.all_ids) From 016cfe86e715273bf368f1f198fea1a2167f0ad5 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 27 Nov 2024 06:45:25 +0000 Subject: [PATCH 060/101] copy -> dcopy, minor change --- scratchattach/editor/base.py | 8 +++++++- scratchattach/editor/meta.py | 4 ++-- scratchattach/editor/mutation.py | 4 ++-- scratchattach/editor/vlb.py | 2 ++ scratchattach/site/session.py | 2 +- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/scratchattach/editor/base.py b/scratchattach/editor/base.py index 55f11263..f5953405 100644 --- a/scratchattach/editor/base.py +++ b/scratchattach/editor/base.py @@ -16,12 +16,18 @@ class Base(ABC): - def copy(self): + 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 diff --git a/scratchattach/editor/meta.py b/scratchattach/editor/meta.py index 0c9e38b6..98437adc 100644 --- a/scratchattach/editor/meta.py +++ b/scratchattach/editor/meta.py @@ -48,7 +48,7 @@ def __init__(self, semver: str = "3.0.0", vm: str = DEFAULT_VM, agent: str = DEF https://en.scratch-wiki.info/wiki/Scratch_File_Format#Metadata """ if platform is None and META_SET_PLATFORM: - platform = DEFAULT_PLATFORM.copy() + platform = DEFAULT_PLATFORM.dcopy() self.semver = semver self.vm = vm @@ -100,6 +100,6 @@ def from_json(data): if EDIT_META: if META_SET_PLATFORM and not platform: - platform = DEFAULT_PLATFORM.copy() + platform = DEFAULT_PLATFORM.dcopy() return Meta(semver, vm, agent, platform) diff --git a/scratchattach/editor/mutation.py b/scratchattach/editor/mutation.py index 831381a5..0731d82f 100644 --- a/scratchattach/editor/mutation.py +++ b/scratchattach/editor/mutation.py @@ -63,9 +63,9 @@ def parse_proc_code(_proc_code: str) -> list[str, ArgumentPlaceholder] | None: # Add the parameter token token = f"%{char}" if token == "%b": - tokens.append(ArgumentPlaceholders.BOOLEAN.value.copy()) + tokens.append(ArgumentPlaceholders.BOOLEAN.value.dcopy()) elif token == "%s": - tokens.append(ArgumentPlaceholders.NUMBER_OR_TEXT.value.copy()) + tokens.append(ArgumentPlaceholders.NUMBER_OR_TEXT.value.dcopy()) token = '' continue diff --git a/scratchattach/editor/vlb.py b/scratchattach/editor/vlb.py index f86a2dc8..53681669 100644 --- a/scratchattach/editor/vlb.py +++ b/scratchattach/editor/vlb.py @@ -1,6 +1,8 @@ """ 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 diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index 0c79f241..bbc40802 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -1015,7 +1015,7 @@ def login(username, password, *, timeout=10) -> Session: """ # Post request to login API: - _headers = headers.copy() + _headers = headers.dcopy() _headers["Cookie"] = "scratchcsrftoken=a;scratchlanguage=en;" request = requests.post( "https://scratch.mit.edu/login/", json={"username": username, "password": password}, headers=_headers, From 0f79f34c8a3f9f2c20a70c8e262c4252e68e2a11 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Thu, 28 Nov 2024 16:58:49 +0000 Subject: [PATCH 061/101] sprite exportation --- scratchattach/editor/__init__.py | 1 + scratchattach/editor/asset.py | 10 +++++- scratchattach/editor/block.py | 28 ++++++++++++++-- scratchattach/editor/commons.py | 16 ++++++++++ scratchattach/editor/field.py | 31 +++++++++++++++++- scratchattach/editor/prim.py | 2 +- scratchattach/editor/sprite.py | 55 ++++++++++++++++++++++++++++++-- scratchattach/editor/vlb.py | 10 ++++-- 8 files changed, 143 insertions(+), 10 deletions(-) diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py index f932cd64..beac6f6d 100644 --- a/scratchattach/editor/__init__.py +++ b/scratchattach/editor/__init__.py @@ -3,3 +3,4 @@ from .extension import Extensions, Extension from .mutation import Mutation, Argument, parse_proc_code from .meta import Meta, set_meta_platform +from .sprite import Sprite diff --git a/scratchattach/editor/asset.py b/scratchattach/editor/asset.py index 28fdd388..605d480d 100644 --- a/scratchattach/editor/asset.py +++ b/scratchattach/editor/asset.py @@ -35,6 +35,7 @@ def md5(self): return self._md5 + class Asset(base.SpriteSubComponent): def __init__(self, name: str = "costume1", @@ -67,9 +68,16 @@ def file_name(self): 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.project.asset_data: + for asset_file in self.parent.asset_data: if asset_file.filename == self.file_name: return asset_file diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 9542e8ec..40d13c02 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -2,7 +2,7 @@ import warnings -from . import base, sprite, mutation, field, inputs, commons +from . import base, sprite, mutation, field, inputs, commons, vlb class Block(base.SpriteSubComponent): @@ -163,8 +163,30 @@ def link_using_sprite(self): if _field.id is not None: new_value = self.sprite.find_vlb(_field.id, "id") if new_value is None: - warnings.warn(f"Could not find {_field.id!r} in {self}") - else: + # 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(self.sprite.new_id, + _field.value) + elif _type == field.Types.LIST: + # Create a list + new_value = vlb.List(self.sprite.new_id, + _field.value) + elif _type == field.Types.BROADCAST: + # Create a broadcast + new_value = vlb.Broadcast(self.sprite.new_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 diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index 8dcf7599..1d14238b 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import string from typing import Final, Any from ..utils import exceptions @@ -188,3 +189,18 @@ def gen_id(id_list: list[str]) -> str: i += 1 return new_id + + +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 diff --git a/scratchattach/editor/field.py b/scratchattach/editor/field.py index ad25eb16..34afed15 100644 --- a/scratchattach/editor/field.py +++ b/scratchattach/editor/field.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final + if TYPE_CHECKING: from . import block, vlb @@ -8,6 +9,13 @@ 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): """ @@ -45,6 +53,27 @@ def value_str(self): 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 diff --git a/scratchattach/editor/prim.py b/scratchattach/editor/prim.py index 98fda578..3dc9564e 100644 --- a/scratchattach/editor/prim.py +++ b/scratchattach/editor/prim.py @@ -144,7 +144,7 @@ def link_using_sprite(self): 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.sprite.add_local_global(new_vlb) self.value = new_vlb else: diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index cf30ec1e..a6c6be5b 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -2,9 +2,12 @@ import json import warnings -from typing import Any +from io import BytesIO, TextIOWrapper +from typing import Any, BinaryIO from zipfile import ZipFile +from beautifulprint import bprint + from . import base, project, vlb, asset, comment, prim, block, commons @@ -75,6 +78,8 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int self.draggable = _draggable self.rotation_style = _rotation_style + self.asset_data = [] + super().__init__(_project) # Assign sprite @@ -108,7 +113,7 @@ def link_comments(self): for _comment in self.comments: _comment.link_using_sprite() - def _add_local_global(self, _vlb: base.NamedIDComponent): + def add_local_global(self, _vlb: base.NamedIDComponent): self._local_globals.append(_vlb) _vlb.sprite = self @@ -403,6 +408,9 @@ def find_block(self, value: str | Any, by: str, multiple: bool = False) -> block 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: @@ -430,3 +438,46 @@ def all_ids(self): @property def new_id(self): return commons.gen_id(self.project.all_ids if self.project else self.all_ids) + + @staticmethod + def from_sprite3(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: str = None): + """ + Load a project from an .sb3 file/bytes/file path + """ + _dir_for_name = None + + if isinstance(data, bytes): + data = BytesIO(data) + + elif isinstance(data, str): + _dir_for_name = data + data = open(data, "rb") + + with data: + # For if the sprite3 is just JSON (e.g. if it's exported from scratchattach) + try: + _sprite = Sprite.from_json(json.load(data)) + + except ValueError or UnicodeDecodeError: + with ZipFile(data) as archive: + data = json.loads(archive.read("sprite.json")) + bprint(data) + _sprite = Sprite.from_json(data) + + # Also load assets + if load_assets: + asset_data = [] + 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) + ) + _sprite.asset_data = asset_data + 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 _sprite + diff --git a/scratchattach/editor/vlb.py b/scratchattach/editor/vlb.py index 53681669..bed39c44 100644 --- a/scratchattach/editor/vlb.py +++ b/scratchattach/editor/vlb.py @@ -13,12 +13,15 @@ class Variable(base.NamedIDComponent): - def __init__(self, _id: str, _name: str, _value: str | int | float, _is_cloud: bool = False, + def __init__(self, _id: str, _name: str, _value: str | int | float = None, _is_cloud: bool = False, _sprite: sprite.Sprite = None): """ 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 @@ -63,12 +66,15 @@ def to_json(self) -> tuple[str, str | int | float, bool] | tuple[str, str | int class List(base.NamedIDComponent): - def __init__(self, _id: str, _name: str, _value: str | int | float, + def __init__(self, _id: str, _name: str, _value: list[str | int | float] = None, _sprite: sprite.Sprite = None): """ 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) From c3614ea2ed82f50d242b31707cab270e1470a9b0 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sun, 8 Dec 2024 10:33:29 +0000 Subject: [PATCH 062/101] Added misc features + todo list --- scratchattach/editor/__init__.py | 2 + scratchattach/editor/block.py | 51 ++++++++++++---- scratchattach/editor/commons.py | 13 ++-- scratchattach/editor/field.py | 6 +- scratchattach/editor/inputs.py | 6 +- scratchattach/editor/pallete.py | 89 +++++++++++++++++++++++++++ scratchattach/editor/prim.py | 26 ++++---- scratchattach/editor/project.py | 3 - scratchattach/editor/sprite.py | 5 -- scratchattach/editor/todo.md | 90 ++++++++++++++++++++++++++++ scratchattach/site/backpack_asset.py | 37 ++++++------ scratchattach/site/session.py | 6 +- 12 files changed, 276 insertions(+), 58 deletions(-) create mode 100644 scratchattach/editor/pallete.py create mode 100644 scratchattach/editor/todo.md diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py index beac6f6d..5388f115 100644 --- a/scratchattach/editor/__init__.py +++ b/scratchattach/editor/__init__.py @@ -4,3 +4,5 @@ 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 diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 40d13c02..4d0d8caf 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -2,7 +2,23 @@ import warnings -from . import base, sprite, mutation, field, inputs, commons, vlb +from . import base, sprite, mutation, field, inputs, commons, vlb, prim + +# This probably should be seperated into a backpack json parser +def parse_prim_fields(_fields: dict[str]) -> tuple[str | None, str | None, str | None]: + 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 Block(base.SpriteSubComponent): @@ -92,9 +108,25 @@ def yield_block(_block: Block | None): return _ret @staticmethod - def from_json(data: dict): + def from_json(data: dict) -> Block | prim.Prim: + """ + Load a block from the JSON dictionary. Will automatically convert block such as 'data_variable' to a primitive + :param data: a dictionary (not list) + :return: The Block/Prim object + """ _opcode = data["opcode"] + _x, _y = data.get("x"), data.get("y") + + if prim.is_prim(_opcode): + _value, _name, _id = parse_prim_fields(data.get("fields")) + + return prim.Prim( + prim.PrimTypes.find(_opcode, "opcode"), + _value, _name, _id, + _x, _y + ) + _next_id = data.get("next") _parent_id = data.get("parent") @@ -114,8 +146,6 @@ def from_json(data: dict): else: _mutation = None - _x, _y = data.get("x"), data.get("y") - return Block(_opcode, _shadow, _top_level, _mutation, _fields, _inputs, _x, _y, _next_id=_next_id, _parent_id=_parent_id) @@ -168,19 +198,20 @@ def link_using_sprite(self): if _type == field.Types.VARIABLE: # Create a new variable - new_value = vlb.Variable(self.sprite.new_id, - _field.value) + new_value = vlb.Variable(commons.gen_id(), + _field.value) elif _type == field.Types.LIST: # Create a list - new_value = vlb.List(self.sprite.new_id, - _field.value) + new_value = vlb.List(commons.gen_id(), + _field.value) elif _type == field.Types.BROADCAST: # Create a broadcast - new_value = vlb.Broadcast(self.sprite.new_id, + 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") + 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) diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index 1d14238b..fe097d40 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import random import string from typing import Final, Any @@ -181,14 +182,10 @@ def dumps_ifnn(obj: Any) -> str: return json.dumps(obj) -def gen_id(id_list: list[str]) -> str: - i = 0 - new_id = None - while new_id in id_list or new_id is None: - new_id = str(i) - i += 1 - - return new_id +def gen_id() -> str: + # The old 'naiive' method but that chances of a repeat are so miniscule + # Have to check if whitespace chars break it + return ''.join(random.choices(string.printable, k=20)) def sanitize_fn(filename: str): diff --git a/scratchattach/editor/field.py b/scratchattach/editor/field.py index 34afed15..49ffd58e 100644 --- a/scratchattach/editor/field.py +++ b/scratchattach/editor/field.py @@ -75,7 +75,11 @@ def type(self): return Types.DEFAULT @staticmethod - def from_json(data: list[str, str | None]): + def from_json(data: list[str, str | None] | dict[str, str]): + # If this is an input from the backpack, then it will be in a weird dictionary format. + if isinstance(data, dict): + data = [data["value"], data.get("id")] + # Sometimes you may have a stray field with no id. Not sure why while len(data) < 2: data.append(None) diff --git a/scratchattach/editor/inputs.py b/scratchattach/editor/inputs.py index 95505bc7..44bff371 100644 --- a/scratchattach/editor/inputs.py +++ b/scratchattach/editor/inputs.py @@ -62,7 +62,11 @@ def __repr__(self): return f"" @staticmethod - def from_json(data: list): + def from_json(data: list | dict[str, str]): + # If this is an input from the backpack, then it will be in a dictionary format. This code is incomplete + if isinstance(data, dict): + data = [data.get("shadow", '') != '', data["block"]] + _shadow = ShadowStatuses.find(data[0]) _value, _id = None, None diff --git a/scratchattach/editor/pallete.py b/scratchattach/editor/pallete.py new file mode 100644 index 00000000..5c11151b --- /dev/null +++ b/scratchattach/editor/pallete.py @@ -0,0 +1,89 @@ +""" +Collection of block information, stating input/field names and opcodes +New version of sbuild.py +""" +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 index 3dc9564e..fd05dad7 100644 --- a/scratchattach/editor/prim.py +++ b/scratchattach/editor/prim.py @@ -13,6 +13,7 @@ class PrimType(base.JSONSerializable): code: int name: str attrs: list = None + opcode: str = None def __eq__(self, other): if isinstance(other, str): @@ -34,22 +35,27 @@ def to_json(self) -> int: class PrimTypes(enums._EnumWrapper): - NUMBER = PrimType(4, "number", BASIC_ATTRS) - POSITIVE_NUMBER = PrimType(5, "positive number", BASIC_ATTRS) - POSITIVE_INTEGER = PrimType(6, "positive integer", BASIC_ATTRS) - INTEGER = PrimType(7, "integer", BASIC_ATTRS) - ANGLE = PrimType(8, "angle", BASIC_ATTRS) - COLOR = PrimType(9, "color", BASIC_ATTRS) - STRING = PrimType(10, "string", BASIC_ATTRS) - BROADCAST = PrimType(11, "broadcast", VLB_ATTRS) - VARIABLE = PrimType(12, "variable", VLB_ATTRS) - LIST = PrimType(13, "list", VLB_ATTRS) + # 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: str): + return opcode in PrimTypes.all_of("opcode") and opcode is not None + + class Prim(base.SpriteSubComponent): def __init__(self, _primtype: PrimType, _value: str | vlb.Variable | vlb.List | vlb.Broadcast = None, _name: str = None, _id: str = None, _x: int = None, diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index 608839ab..4cf8ff74 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -217,6 +217,3 @@ def export(self, fp: str, *, auto_open: bool = False, export_as_zip: bool = True if auto_open: os.system(f"explorer.exe \"{fp}\"") - @property - def new_id(self): - return commons.gen_id(self.all_ids) diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index a6c6be5b..5980af7e 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -435,10 +435,6 @@ def all_ids(self): return ret - @property - def new_id(self): - return commons.gen_id(self.project.all_ids if self.project else self.all_ids) - @staticmethod def from_sprite3(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: str = None): """ @@ -480,4 +476,3 @@ def from_sprite3(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool "Loading sb3 without loading assets. When exporting the project, there may be errors due to assets not being uploaded to the Scratch website") return _sprite - diff --git a/scratchattach/editor/todo.md b/scratchattach/editor/todo.md new file mode 100644 index 00000000..e6579abc --- /dev/null +++ b/scratchattach/editor/todo.md @@ -0,0 +1,90 @@ +# Things to add to scratchattach.editor (sbeditor v2) + +## All + +- [ ] Docstrings +- [ ] Dealing with stuff from the backpack (it's in a weird format): This may require a whole seperate module +- [ ] Getter functions (`@property`) instead of directly editing attrs (make them protected attrs) +- [ ] Check if whitespace chars break IDs +## Project + +- [ ] Asset list +- [ ] Obfuscation +- [ ] Detection for twconfig +- [ ] Edit twconfig +- [ ] Find targets + +## Block + +### Finding blocks/attrs + +- [ ] Top level block (stack parent) +- [ ] Previous chain +- [ ] Attached chain +- [ ] Complete chain +- [ ] Block shape attr aka stack type (Stack/hat/c-mouth/end/reporter/boolean detection) +- [ ] `can_next` property +- [ ] `is_input` property: Check if block is an input obscurer +- [ ] `parent_input` property: Get input that this block obscures +- [ ] `block_tree` old 'subtree' property: Get the 'ast' of this blockchain (a tree structure, not just a list) +- [ ] `children` property - list of all blocks with this block as a parent (next block + any input obscurers) +- [ ] Detection for scratch addons debug blocks + (proc codes: + `"​​log​​ %s", + "​​breakpoint​​", + "​​error​​ %s", + "​​warn​​ %s"` - note: they all have ZWSPs) +- [ ] Detection for `` and `` and `` booleans + +### Adding/removing blocks + +- [ ] Duplicating (single) block +- [ ] Duplicating blockchain +- [ ] Attach block +- [ ] Slot above (if possible - raise error if not) +- [ ] Attach blockchain +- [ ] Delete block +- [ ] Delete blockchain +- [ ] Add/edit inputs +- [ ] Add/edit fields +- [ ] Add mutation +- [ ] Add comment + +## Mutation + +- [ ] Proc code builder + +## 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 + +## Vars/lists/broadcasts + +- [ ] + +## Monitors + +- [ ] Get relevant var/list if applicable +- [ ] Generate from block + +## Assets + +- [x] Download assets +- [ ] Upload asset +- [ ] Load from file (auto-detect type) + +## Pallete + +- [ ] 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/site/backpack_asset.py b/scratchattach/site/backpack_asset.py index fae2768b..82357ebd 100644 --- a/scratchattach/site/backpack_asset.py +++ b/scratchattach/site/backpack_asset.py @@ -1,8 +1,10 @@ +import json import time from ._base import BaseSiteComponent from ..utils.requests import Requests as requests from ..utils import exceptions + class BackpackAsset(BaseSiteComponent): """ Represents an asset from the backpack. @@ -33,7 +35,7 @@ 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"] @@ -52,27 +54,28 @@ def _update_from_dict(self, data) -> bool: except Exception: pass return True - def download(self, *, dir=""): + @property + def _data_str(self): + try: + response = requests.get(self.download_url) + return response.text + except Exception as e: + raise exceptions.FetchError(f"Failed to download asset: {e}") + + @property + def data(self): + return json.loads(self._data_str) + + def download(self, *, fp=""): """ 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_str) def delete(self): self._assert_auth() diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index bbc40802..2351bb9e 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -673,12 +673,12 @@ def mystuff_ended_classes(self, mode: str = "Last created", page: int = None) -> _session=self)) return classes - def backpack(self, limit: int = 20, offset: int = 0) -> list[dict]: + 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 as dicts """ data = commons.api_iterative( f"https://backpack.scratch.mit.edu/{self._username}", @@ -1015,7 +1015,7 @@ def login(username, password, *, timeout=10) -> Session: """ # Post request to login API: - _headers = headers.dcopy() + _headers = headers.copy() _headers["Cookie"] = "scratchcsrftoken=a;scratchlanguage=en;" request = requests.post( "https://scratch.mit.edu/login/", json={"username": username, "password": password}, headers=_headers, From ba029bac18462e90029549822b64574b4b46f5d4 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sun, 8 Dec 2024 12:46:42 +0000 Subject: [PATCH 063/101] Loading scripts from backpack + other backpack cleanup --- scratchattach/editor/backpack_json.py | 111 ++++++++++++++++++++++++++ scratchattach/editor/block.py | 34 ++------ scratchattach/editor/field.py | 6 +- scratchattach/editor/inputs.py | 22 ++--- scratchattach/editor/prim.py | 7 +- scratchattach/editor/project.py | 2 +- scratchattach/editor/sprite.py | 2 +- scratchattach/editor/todo.md | 3 + scratchattach/site/backpack_asset.py | 77 ++++++++++++------ 9 files changed, 191 insertions(+), 73 deletions(-) create mode 100644 scratchattach/editor/backpack_json.py diff --git a/scratchattach/editor/backpack_json.py b/scratchattach/editor/backpack_json.py new file mode 100644 index 00000000..83e61ac2 --- /dev/null +++ b/scratchattach/editor/backpack_json.py @@ -0,0 +1,111 @@ +""" +Module to deal with the backpack's weird JSON format, and reformat it into the normal format +""" +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: + print(data) + # 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.Sprite() + for _block_data in _script_data: + _block = BpBlock.from_json(_block_data) + _block.sprite = _sprite + _sprite.blocks[_block_data["id"]] = _block + + _sprite.link_subcomponents() + return _sprite diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 4d0d8caf..97cd70de 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -2,23 +2,7 @@ import warnings -from . import base, sprite, mutation, field, inputs, commons, vlb, prim - -# This probably should be seperated into a backpack json parser -def parse_prim_fields(_fields: dict[str]) -> tuple[str | None, str | None, str | None]: - 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 +from . import base, sprite, mutation, field, inputs, commons, vlb class Block(base.SpriteSubComponent): @@ -66,6 +50,7 @@ def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False for subcomponent in iterable: subcomponent.block = self + def __repr__(self): return f"Block<{self.opcode!r}>" @@ -108,25 +93,16 @@ def yield_block(_block: Block | None): return _ret @staticmethod - def from_json(data: dict) -> Block | prim.Prim: + def from_json(data: dict) -> Block: """ - Load a block from the JSON dictionary. Will automatically convert block such as 'data_variable' to a primitive + Load a block from the JSON dictionary. :param data: a dictionary (not list) - :return: The Block/Prim object + :return: The new Block object """ _opcode = data["opcode"] _x, _y = data.get("x"), data.get("y") - if prim.is_prim(_opcode): - _value, _name, _id = parse_prim_fields(data.get("fields")) - - return prim.Prim( - prim.PrimTypes.find(_opcode, "opcode"), - _value, _name, _id, - _x, _y - ) - _next_id = data.get("next") _parent_id = data.get("parent") diff --git a/scratchattach/editor/field.py b/scratchattach/editor/field.py index 49ffd58e..34afed15 100644 --- a/scratchattach/editor/field.py +++ b/scratchattach/editor/field.py @@ -75,11 +75,7 @@ def type(self): return Types.DEFAULT @staticmethod - def from_json(data: list[str, str | None] | dict[str, str]): - # If this is an input from the backpack, then it will be in a weird dictionary format. - if isinstance(data, dict): - data = [data["value"], data.get("id")] - + 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) diff --git a/scratchattach/editor/inputs.py b/scratchattach/editor/inputs.py index 44bff371..d42018a4 100644 --- a/scratchattach/editor/inputs.py +++ b/scratchattach/editor/inputs.py @@ -34,17 +34,19 @@ def find(cls, idx: int) -> ShadowStatus: class Input(base.BlockSubComponent): - def __init__(self, _shadow: ShadowStatus = ShadowStatuses.HAS_SHADOW, _value: prim.Prim | block.Block = None, _id: str = None, - _obscurer: prim.Prim | block.Block = None, *, _obscurer_id: str = None, _block: block.Block = None): + 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 + # If the value/obscurers are strings, they are ids that reference the actual value/obscurer, which need to be fetched + self.value: prim.Prim | block.Block | str = _value + self.obscurer: prim.Prim | block.Block | str = _obscurer self._id = _id """ @@ -62,11 +64,7 @@ def __repr__(self): return f"" @staticmethod - def from_json(data: list | dict[str, str]): - # If this is an input from the backpack, then it will be in a dictionary format. This code is incomplete - if isinstance(data, dict): - data = [data.get("shadow", '') != '', data["block"]] - + def from_json(data: list): _shadow = ShadowStatuses.find(data[0]) _value, _id = None, None @@ -96,8 +94,10 @@ def add_pblock(pblock: prim.Prim | block.Block | None): 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)}") @@ -107,21 +107,25 @@ def add_pblock(pblock: prim.Prim | block.Block | None): 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/prim.py b/scratchattach/editor/prim.py index fd05dad7..22df0826 100644 --- a/scratchattach/editor/prim.py +++ b/scratchattach/editor/prim.py @@ -24,10 +24,10 @@ def __eq__(self, other): @staticmethod def from_json(data: int): - pass + ... def to_json(self) -> int: - pass + return self.code BASIC_ATTRS: Final = ["value"] @@ -52,7 +52,7 @@ def find(cls, value, by: str, apply_func: Callable = None) -> PrimType: return super().find(value, by, apply_func=apply_func) -def is_prim(opcode: str): +def is_prim_opcode(opcode: str): return opcode in PrimTypes.all_of("opcode") and opcode is not None @@ -88,6 +88,7 @@ def __init__(self, _primtype: PrimType, _value: str | vlb.Variable | vlb.List | self.y = _y super().__init__(_sprite) + print(self, self.__dict__) def __repr__(self): if self.is_basic: diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index 4cf8ff74..c73e7c07 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -48,7 +48,7 @@ def __init__(self, _name: str = None, _meta: meta.Meta = None, _extensions: Iter if _sprite.is_stage: _stage_count += 1 - _sprite.link_using_project() + _sprite.link_subcomponents() # Link monitors for _monitor in self.monitors: diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index 5980af7e..a754ab61 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -90,7 +90,7 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int def __repr__(self): return f"Sprite<{self.name}>" - def link_using_project(self): + def link_subcomponents(self): self.link_prims() self.link_blocks() self.link_comments() diff --git a/scratchattach/editor/todo.md b/scratchattach/editor/todo.md index e6579abc..ba80facb 100644 --- a/scratchattach/editor/todo.md +++ b/scratchattach/editor/todo.md @@ -6,6 +6,9 @@ - [ ] Dealing with stuff from the backpack (it's in a weird format): This may require a whole seperate 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) + ## Project - [ ] Asset list diff --git a/scratchattach/site/backpack_asset.py b/scratchattach/site/backpack_asset.py index 82357ebd..78f481a6 100644 --- a/scratchattach/site/backpack_asset.py +++ b/scratchattach/site/backpack_asset.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import json -import time + 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): @@ -36,35 +38,60 @@ def __init__(self, **entries): def update(self): print("Warning: BackpackAsset objects can't 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 @property - def _data_str(self): + def _data_bytes(self) -> bytes: try: - response = requests.get(self.download_url) - return response.text + return requests.get(self.download_url).content except Exception as e: raise exceptions.FetchError(f"Failed to download asset: {e}") @property - def data(self): - return json.loads(self._data_str) + 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=""): """ @@ -75,13 +102,13 @@ def download(self, *, fp=""): """ if not (fp.endswith("/") or fp.endswith("\\")): fp = fp + "/" - open(f"{fp}{self.filename}", "wb").write(self._data_str) + 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() From 88ca79e4ec805e8894371752f639b1d46d8d9cc3 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sun, 8 Dec 2024 12:51:34 +0000 Subject: [PATCH 064/101] clarification --- scratchattach/editor/backpack_json.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scratchattach/editor/backpack_json.py b/scratchattach/editor/backpack_json.py index 83e61ac2..f57ff03e 100644 --- a/scratchattach/editor/backpack_json.py +++ b/scratchattach/editor/backpack_json.py @@ -1,5 +1,5 @@ """ -Module to deal with the backpack's weird JSON format, and reformat it into the normal format +Module to deal with the backpack's weird JSON format, by overriding with new load methods """ from __future__ import annotations @@ -100,7 +100,13 @@ def from_json(data: dict) -> prim.Prim | block.Block: _parent_id=_parent_id) -def load_script(_script_data: list[dict]): +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 sprite containing the script + """ + # Using a sprite since it simplifies things, e.g. local global loading _sprite = sprite.Sprite() for _block_data in _script_data: _block = BpBlock.from_json(_block_data) From 3a912271fc4d320bc218c710c6aa9fc2f390dc72 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sun, 8 Dec 2024 13:55:30 +0000 Subject: [PATCH 065/101] twconfig --- scratchattach/editor/__init__.py | 1 + scratchattach/editor/twconfig.py | 113 +++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 scratchattach/editor/twconfig.py diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py index 5388f115..e4e85b9f 100644 --- a/scratchattach/editor/__init__.py +++ b/scratchattach/editor/__init__.py @@ -6,3 +6,4 @@ from .sprite import Sprite from .block import Block from .prim import Prim, PrimTypes +from .backpack_json import load_script as load_script_from_backpack 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 From c9260e215f8b7b6575b6e1f8276d93db986e1851 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sun, 8 Dec 2024 15:05:58 +0000 Subject: [PATCH 066/101] import twconfig --- scratchattach/editor/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py index e4e85b9f..87da05eb 100644 --- a/scratchattach/editor/__init__.py +++ b/scratchattach/editor/__init__.py @@ -7,3 +7,4 @@ 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 197c01bf91753cad75e33c693729694f3771283c Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Mon, 9 Dec 2024 18:57:54 +0000 Subject: [PATCH 067/101] stack types + can next --- scratchattach/editor/backpack_json.py | 17 +- scratchattach/editor/block.py | 22 +- scratchattach/editor/blockchain.py | 12 + scratchattach/editor/blockshape.py | 361 ++++++++++++++++++++++++++ scratchattach/editor/prim.py | 5 +- scratchattach/editor/project.py | 1 + scratchattach/editor/sprite.py | 3 - scratchattach/editor/todo.md | 9 +- 8 files changed, 411 insertions(+), 19 deletions(-) create mode 100644 scratchattach/editor/blockchain.py create mode 100644 scratchattach/editor/blockshape.py diff --git a/scratchattach/editor/backpack_json.py b/scratchattach/editor/backpack_json.py index f57ff03e..7af9f070 100644 --- a/scratchattach/editor/backpack_json.py +++ b/scratchattach/editor/backpack_json.py @@ -3,7 +3,7 @@ """ from __future__ import annotations -from . import block, prim, field, inputs, mutation, sprite +from . import block, prim, field, inputs, mutation, blockchain def parse_prim_fields(_fields: dict[str]) -> tuple[str | None, str | None, str | None]: @@ -32,7 +32,6 @@ class BpField(field.Field): @staticmethod def from_json(data: dict[str, str]) -> field.Field: - print(data) # We can very simply convert it to the regular format data = [data.get("value"), data.get("id")] return field.Field.from_json(data) @@ -100,18 +99,18 @@ def from_json(data: dict) -> prim.Prim | block.Block: _parent_id=_parent_id) -def load_script(_script_data: list[dict]) -> sprite.Sprite: +def load_script(_script_data: list[dict]) -> blockchain.Blockchain: """ Loads a script into a sprite from the backpack JSON format :param _script_data: Backpack script JSON data - :return: a sprite containing the script + :return: a blockchain object containing the script """ # Using a sprite since it simplifies things, e.g. local global loading - _sprite = sprite.Sprite() + _blockchain = blockchain.Blockchain() for _block_data in _script_data: _block = BpBlock.from_json(_block_data) - _block.sprite = _sprite - _sprite.blocks[_block_data["id"]] = _block + _block.sprite = _blockchain + _blockchain.blocks[_block_data["id"]] = _block - _sprite.link_subcomponents() - return _sprite + _blockchain.link_subcomponents() + return _blockchain diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 97cd70de..659446fa 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -2,7 +2,7 @@ import warnings -from . import base, sprite, mutation, field, inputs, commons, vlb +from . import base, sprite, mutation, field, inputs, commons, vlb, blockshape class Block(base.SpriteSubComponent): @@ -50,10 +50,28 @@ def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False for subcomponent in iterable: subcomponent.block = self - def __repr__(self): return f"Block<{self.opcode!r}>" + @property + def block_shape(self) -> blockshape.BlockShape: + """ + Search for the blockshape stored in blockshape.py + :return: The block's block shape (by opcode) + """ + return blockshape.BlockShapes.find(self.opcode, "opcode") + + @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.YESNT: + return _shape.is_attachable + else: + return self.mutation.has_next + @property def id(self) -> str | None: # warnings.warn(f"Using block IDs can cause consistency issues and is not recommended") diff --git a/scratchattach/editor/blockchain.py b/scratchattach/editor/blockchain.py new file mode 100644 index 00000000..2b55158b --- /dev/null +++ b/scratchattach/editor/blockchain.py @@ -0,0 +1,12 @@ +""" +Special module for dealing with blockchains. +""" +from . import sprite + + +class Blockchain(sprite.Sprite): + """ + Blockchain class, which is actually just a sprite with extra functions. + """ + # Maybe raise errors when you try to access assets or smt, idk + ... diff --git a/scratchattach/editor/blockshape.py b/scratchattach/editor/blockshape.py new file mode 100644 index 00000000..cb732a41 --- /dev/null +++ b/scratchattach/editor/blockshape.py @@ -0,0 +1,361 @@ +""" +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 ..utils.enums import _EnumWrapper + +_singletons = [] + + +class _Yesnt: + """I can't really tell you if yesn't means yes or no; is it true or false? It depends.""" + + def __new__(cls, *args, **kwargs): + if len(_singletons) == 0: + return object.__new__(cls) + else: + return _singletons[0] + + def __bool__(self): + raise TypeError("I can't really tell you if yesn't means yes or no; is it true or false? It depends.") + + +YESNT: Final[_Yesnt] = _Yesnt() +"""Value used when neither True nor False is applicable""" + + +@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 | _Yesnt = False # Most blocks - e.g. move [10] steps + is_c_mouth: bool | _Yesnt = False # Has substack - e.g. repeat + is_cap: bool | _Yesnt = False # No next - e.g. forever + is_hat: bool | _Yesnt = False # No parent - e.g. when gf clicked + is_reporter: bool | _Yesnt = False # (reporter) + is_boolean: bool | _Yesnt = False # + is_menu: bool | _Yesnt = False # Shadow reporters, e.g. costume menu + opcode: str = None + + @property + def is_attachable(self): + if self.is_cap is YESNT: + 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=YESNT, 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/prim.py b/scratchattach/editor/prim.py index 22df0826..a6cdc0c5 100644 --- a/scratchattach/editor/prim.py +++ b/scratchattach/editor/prim.py @@ -88,7 +88,6 @@ def __init__(self, _primtype: PrimType, _value: str | vlb.Variable | vlb.List | self.y = _y super().__init__(_sprite) - print(self, self.__dict__) def __repr__(self): if self.is_basic: @@ -162,3 +161,7 @@ def link_using_sprite(self): 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 index c73e7c07..eb5ee4cb 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -70,6 +70,7 @@ 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 = { diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index a754ab61..9a427b46 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -6,8 +6,6 @@ from typing import Any, BinaryIO from zipfile import ZipFile -from beautifulprint import bprint - from . import base, project, vlb, asset, comment, prim, block, commons @@ -457,7 +455,6 @@ def from_sprite3(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool except ValueError or UnicodeDecodeError: with ZipFile(data) as archive: data = json.loads(archive.read("sprite.json")) - bprint(data) _sprite = Sprite.from_json(data) # Also load assets diff --git a/scratchattach/editor/todo.md b/scratchattach/editor/todo.md index ba80facb..f5962100 100644 --- a/scratchattach/editor/todo.md +++ b/scratchattach/editor/todo.md @@ -3,18 +3,19 @@ ## All - [ ] Docstrings -- [ ] Dealing with stuff from the backpack (it's in a weird format): This may require a whole seperate module +- [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 ## Project - [ ] Asset list - [ ] Obfuscation - [ ] Detection for twconfig -- [ ] Edit twconfig +- [x] Edit twconfig - [ ] Find targets ## Block @@ -25,8 +26,8 @@ - [ ] Previous chain - [ ] Attached chain - [ ] Complete chain -- [ ] Block shape attr aka stack type (Stack/hat/c-mouth/end/reporter/boolean detection) -- [ ] `can_next` property +- [x] Block shape attr aka stack type (Stack/hat/c-mouth/end/reporter/boolean detection) +- [x] `can_next` property - [ ] `is_input` property: Check if block is an input obscurer - [ ] `parent_input` property: Get input that this block obscures - [ ] `block_tree` old 'subtree' property: Get the 'ast' of this blockchain (a tree structure, not just a list) From c9e8d18b90a20819b0a31574c843d21061c3e36d Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Mon, 9 Dec 2024 18:58:13 +0000 Subject: [PATCH 068/101] added check in classroom.py for nonexistent classes --- scratchattach/site/classroom.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scratchattach/site/classroom.py b/scratchattach/site/classroom.py index 6a96ad4f..613b01c5 100644 --- a/scratchattach/site/classroom.py +++ b/scratchattach/site/classroom.py @@ -61,6 +61,12 @@ def update(self): 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 str), educator/username title = soup.find("title").contents[0][:-len(" on Scratch")] From 3a7e606788e30e67952b15f08442362620f172d3 Mon Sep 17 00:00:00 2001 From: faretek Date: Tue, 10 Dec 2024 18:54:54 +0000 Subject: [PATCH 069/101] docstring --- scratchattach/editor/__init__.py | 4 ++++ scratchattach/editor/block.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py index 87da05eb..f82effd5 100644 --- a/scratchattach/editor/__init__.py +++ b/scratchattach/editor/__init__.py @@ -1,3 +1,7 @@ +""" +scratchattach.editor (sbeditor v2) - library for all things sb3 +""" + from .asset import Asset, Costume, Sound from .project import Project from .extension import Extensions, Extension diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 659446fa..01e61173 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -74,11 +74,15 @@ def can_next(self): @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 + return None @property def parent_id(self): From 6e8d21cf38c0cd8aa40315a1acacac833d94f40e Mon Sep 17 00:00:00 2001 From: faretek Date: Thu, 12 Dec 2024 18:58:23 +0000 Subject: [PATCH 070/101] lots of new stuff for blocks --- scratchattach/editor/__init__.py | 3 + scratchattach/editor/backpack_json.py | 7 +- scratchattach/editor/block.py | 162 +- scratchattach/editor/blockchain.py | 12 - scratchattach/editor/blockshape.py | 1 + scratchattach/editor/commons.py | 42 +- scratchattach/editor/inputs.py | 6 +- scratchattach/editor/project.py | 18 +- scratchattach/editor/sbuild.py | 2835 +++++++++++++++++++++++++ scratchattach/editor/sprite.py | 12 + scratchattach/editor/todo.md | 27 +- scratchattach/utils/exceptions.py | 7 + 12 files changed, 3068 insertions(+), 64 deletions(-) delete mode 100644 scratchattach/editor/blockchain.py create mode 100644 scratchattach/editor/sbuild.py diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py index f82effd5..3c5dadf7 100644 --- a/scratchattach/editor/__init__.py +++ b/scratchattach/editor/__init__.py @@ -12,3 +12,6 @@ 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 +from .field import Field +from .vlb import Variable, List, Broadcast diff --git a/scratchattach/editor/backpack_json.py b/scratchattach/editor/backpack_json.py index 7af9f070..5e101ea6 100644 --- a/scratchattach/editor/backpack_json.py +++ b/scratchattach/editor/backpack_json.py @@ -3,7 +3,7 @@ """ from __future__ import annotations -from . import block, prim, field, inputs, mutation, blockchain +from . import block, prim, field, inputs, mutation, sprite def parse_prim_fields(_fields: dict[str]) -> tuple[str | None, str | None, str | None]: @@ -99,14 +99,15 @@ def from_json(data: dict) -> prim.Prim | block.Block: _parent_id=_parent_id) -def load_script(_script_data: list[dict]) -> blockchain.Blockchain: +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 = blockchain.Blockchain() + _blockchain = sprite.Sprite() + for _block_data in _script_data: _block = BpBlock.from_json(_block_data) _block.sprite = _blockchain diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 01e61173..3853cf2a 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -1,14 +1,16 @@ from __future__ import annotations import warnings +from typing import Self, Iterable -from . import base, sprite, mutation, field, inputs, commons, vlb, blockshape +from . import base, sprite, mutation, field, inputs, commons, vlb, blockshape, prim +from ..utils import exceptions class Block(base.SpriteSubComponent): - def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False, + 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 = None, y: int = 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 = None): @@ -18,6 +20,9 @@ def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False 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 @@ -40,9 +45,15 @@ def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False self.next = _next self.parent = _parent + self.check_toplevel() + super().__init__(_sprite) + self.link_subcomponents() + + def __repr__(self): + return f"Block<{self.opcode!r}>" - # Link subcomponents + def link_subcomponents(self): if self.mutation: self.mutation.block = self @@ -50,8 +61,24 @@ def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = False for subcomponent in iterable: subcomponent.block = self - def __repr__(self): - return f"Block<{self.opcode!r}>" + def add_input(self, name: str, _input: inputs.Input): + self.inputs[name] = _input + + def add_field(self, name: str, _field: field.Field): + self.fields[name] = _field + + 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: @@ -114,6 +141,87 @@ def yield_block(_block: Block | None): 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 compelete_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 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 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 + @staticmethod def from_json(data: dict) -> Block: """ @@ -148,6 +256,8 @@ def from_json(data: dict) -> Block: _parent_id=_parent_id) def to_json(self) -> dict: + self.check_toplevel() + _json = { "opcode": self.opcode, "next": self.next_id, @@ -157,11 +267,11 @@ def to_json(self) -> dict: "shadow": self.is_shadow, "topLevel": self.is_top_level, } - - commons.noneless_update(_json, { - "x": self.x, - "y": self.y, - }) + 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, { @@ -170,7 +280,10 @@ def to_json(self) -> dict: return _json - def link_using_sprite(self): + def link_using_sprite(self, link_subs: bool = True): + if link_subs: + self.link_subcomponents() + if self.mutation: self.mutation.link_arguments() @@ -221,3 +334,28 @@ def link_using_sprite(self): for _input in self.inputs.values(): _input.link_using_block() + + # Adding blocks (return self) + def attach_block(self, new: Block) -> Self: + 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 + self.sprite.add_block(new) + + return self + + def duplicate_single_block(self) -> Self: + return self.attach_block(self.copy()) + + def attach_chain(self, chain: Iterable[Block]) -> Self: + attaching_block = self + for _block in chain: + attaching_block = attaching_block.attach_block(_block) + + return self diff --git a/scratchattach/editor/blockchain.py b/scratchattach/editor/blockchain.py deleted file mode 100644 index 2b55158b..00000000 --- a/scratchattach/editor/blockchain.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Special module for dealing with blockchains. -""" -from . import sprite - - -class Blockchain(sprite.Sprite): - """ - Blockchain class, which is actually just a sprite with extra functions. - """ - # Maybe raise errors when you try to access assets or smt, idk - ... diff --git a/scratchattach/editor/blockshape.py b/scratchattach/editor/blockshape.py index cb732a41..1b4f5184 100644 --- a/scratchattach/editor/blockshape.py +++ b/scratchattach/editor/blockshape.py @@ -50,6 +50,7 @@ def is_attachable(self): 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") diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index fe097d40..55e56ab1 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -10,16 +10,17 @@ from ..utils import exceptions -DIGITS: Final = tuple("0123456789") +DIGITS: Final[tuple[str]] = tuple("0123456789") +ID_CHARS: Final[str] = string.ascii_letters + string.digits + string.punctuation -def _read_json_number(string: str) -> float | int: +def _read_json_number(_str: str) -> float | int: ret = '' - minus = string[0] == '-' + minus = _str[0] == '-' if minus: ret += '-' - string = string[1:] + _str = _str[1:] def read_fraction(sub: str): sub_ret = '' @@ -51,31 +52,31 @@ def read_exponent(sub: str): return sub_ret - if string[0] == '0': + if _str[0] == '0': ret += '0' - string = string[1:] + _str = _str[1:] - elif string[0] in DIGITS[1:9]: - while string[0] in DIGITS: - ret += string[0] - string = string[1:] + elif _str[0] in DIGITS[1:9]: + while _str[0] in DIGITS: + ret += _str[0] + _str = _str[1:] - frac, string = read_fraction(string) + frac, _str = read_fraction(_str) ret += frac - ret += read_exponent(string) + ret += read_exponent(_str) return json.loads(ret) -def consume_json(string: str, i: int = 0) -> str | float | int | dict | list | bool | None: +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(string[i:]) + section = ''.join(_str[i:]) if section.startswith("true"): return True elif section.startswith("false"): @@ -116,9 +117,9 @@ def consume_json(string: str, i: int = 0) -> str | float | int | dict | list | b raise exceptions.UnclosedJSONError(f"Unclosed JSON string, read {json_text}") -def is_partial_json(string: str, i: int = 0) -> bool: +def is_partial_json(_str: str, i: int = 0) -> bool: try: - consume_json(string, i) + consume_json(_str, i) return True except exceptions.UnclosedJSONError: @@ -128,9 +129,9 @@ def is_partial_json(string: str, i: int = 0) -> bool: return False -def is_valid_json(string: str) -> bool: +def is_valid_json(_str: str) -> bool: try: - json.loads(string) + json.loads(_str) return True except ValueError: return False @@ -183,9 +184,10 @@ def dumps_ifnn(obj: Any) -> str: def gen_id() -> str: - # The old 'naiive' method but that chances of a repeat are so miniscule + # The old 'naïve' method but that chances of a repeat are so miniscule # Have to check if whitespace chars break it - return ''.join(random.choices(string.printable, k=20)) + # 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): diff --git a/scratchattach/editor/inputs.py b/scratchattach/editor/inputs.py index d42018a4..83ca8a6a 100644 --- a/scratchattach/editor/inputs.py +++ b/scratchattach/editor/inputs.py @@ -44,9 +44,9 @@ def __init__(self, _shadow: ShadowStatus | None = ShadowStatuses.HAS_SHADOW, _va # If the shadow is None, we'll have to work it out later self.shadow = _shadow - # If the value/obscurers are strings, they are ids that reference the actual value/obscurer, which need to be fetched - self.value: prim.Prim | block.Block | str = _value - self.obscurer: prim.Prim | block.Block | str = _obscurer + + self.value: prim.Prim | block.Block = _value + self.obscurer: prim.Prim | block.Block = _obscurer self._id = _id """ diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index eb5ee4cb..f776c253 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -7,7 +7,7 @@ from typing import Iterable, Generator, BinaryIO from zipfile import ZipFile -from . import base, meta, extension, monitor, sprite, asset, vlb, commons +from . import base, meta, extension, monitor, sprite, asset, vlb, commons, twconfig, comment from ..site.project import get_project from ..site import session @@ -36,6 +36,8 @@ def __init__(self, _name: str = None, _meta: meta.Meta = None, _extensions: Iter self.asset_data = _asset_data + self._tw_config_comment = None + # Link subcomponents for iterable in (self.monitors, self.sprites): for _subcomponent in iterable: @@ -88,6 +90,20 @@ def assets(self) -> Generator[asset.Asset, None, None]: 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 = [] 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 index 9a427b46..6451eec7 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -137,6 +137,14 @@ def add_vlb(self, _vlb: base.NamedIDComponent): else: warnings.warn(f"Invalid 'VLB' {_vlb} of type: {type(_vlb)}") + def add_block(self, _block: block.Block) -> block.Block: + _block.sprite = self + + self.blocks[self.new_id] = _block + _block.link_using_sprite() + + return _block + @property def vlbs(self) -> list[base.NamedIDComponent]: """ @@ -152,6 +160,10 @@ def assets(self) -> list[asset.Costume | asset.Sound]: 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"] diff --git a/scratchattach/editor/todo.md b/scratchattach/editor/todo.md index f5962100..6f6db88a 100644 --- a/scratchattach/editor/todo.md +++ b/scratchattach/editor/todo.md @@ -12,9 +12,9 @@ ## Project -- [ ] Asset list +- [x] Asset list - [ ] Obfuscation -- [ ] Detection for twconfig +- [x] Detection for twconfig - [x] Edit twconfig - [ ] Find targets @@ -22,16 +22,17 @@ ### Finding blocks/attrs -- [ ] Top level block (stack parent) -- [ ] Previous chain -- [ ] Attached chain -- [ ] Complete chain +- [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 -- [ ] `is_input` property: Check if block is an input obscurer -- [ ] `parent_input` property: Get input that this block obscures -- [ ] `block_tree` old 'subtree' property: Get the 'ast' of this blockchain (a tree structure, not just a list) -- [ ] `children` property - list of all blocks with this block as a parent (next block + any input obscurers) +- [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) - [ ] Detection for scratch addons debug blocks (proc codes: `"​​log​​ %s", @@ -41,10 +42,10 @@ - [ ] Detection for `` and `` and `` booleans ### Adding/removing blocks - -- [ ] Duplicating (single) block +- [x] Add block to sprite +- [x] Duplicating (single) block +- [x] Attach block - [ ] Duplicating blockchain -- [ ] Attach block - [ ] Slot above (if possible - raise error if not) - [ ] Attach blockchain - [ ] Delete block diff --git a/scratchattach/utils/exceptions.py b/scratchattach/utils/exceptions.py index ec008e41..e595760a 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -245,3 +245,10 @@ 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 From 19e910e6ed2377f7af658937de377ad98261cf8a Mon Sep 17 00:00:00 2001 From: faretek Date: Thu, 12 Dec 2024 19:29:19 +0000 Subject: [PATCH 071/101] duplicating chains attach chain etc --- scratchattach/editor/__init__.py | 2 +- scratchattach/editor/block.py | 46 ++++++++++++++++++++++-------- scratchattach/editor/blockshape.py | 3 +- scratchattach/editor/todo.md | 6 ++-- 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py index 3c5dadf7..05f92f95 100644 --- a/scratchattach/editor/__init__.py +++ b/scratchattach/editor/__init__.py @@ -1,5 +1,5 @@ """ -scratchattach.editor (sbeditor v2) - library for all things sb3 +scratchattach.editor (sbeditor v2) - a library for all things sb3 """ from .asset import Asset, Costume, Sound diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 3853cf2a..6a6ca1ab 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from typing import Self, Iterable +from typing import Iterable, Self from . import base, sprite, mutation, field, inputs, commons, vlb, blockshape, prim from ..utils import exceptions @@ -61,11 +61,13 @@ def link_subcomponents(self): for subcomponent in iterable: subcomponent.block = self - def add_input(self, name: str, _input: inputs.Input): + def add_input(self, name: str, _input: inputs.Input) -> Self: self.inputs[name] = _input + return self - def add_field(self, name: str, _field: field.Field): + def add_field(self, name: str, _field: field.Field) -> Self: self.fields[name] = _field + return self def check_toplevel(self): self.is_top_level = self.parent is None @@ -86,7 +88,11 @@ def block_shape(self) -> blockshape.BlockShape: Search for the blockshape stored in blockshape.py :return: The block's block shape (by opcode) """ - return blockshape.BlockShapes.find(self.opcode, "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): @@ -97,6 +103,11 @@ def can_next(self): if _shape.is_cap is not blockshape.YESNT: 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 @@ -170,7 +181,7 @@ def attached_chain(self): return [self] + self.next.attached_chain @property - def compelete_chain(self): + def complete_chain(self): # Both previous and attached chains start with self return self.previous_chain[:1:-1] + self.attached_chain @@ -181,6 +192,10 @@ def top_level_block(self): """ return self.previous_chain[-1] + @property + def bottom_level_block(self): + return self.attached_chain[-1] + @property def stack_tree(self): """ @@ -335,8 +350,8 @@ def link_using_sprite(self, link_subs: bool = True): for _input in self.inputs.values(): _input.link_using_block() - # Adding blocks (return self) - def attach_block(self, new: Block) -> Self: + # Adding 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: @@ -346,16 +361,23 @@ def attach_block(self, new: Block) -> Self: new.next = self.next self.next = new + + new.check_toplevel() self.sprite.add_block(new) - return self + return new - def duplicate_single_block(self) -> Self: - return self.attach_block(self.copy()) + def duplicate_single_block(self) -> Block: + return self.attach_block(self.dcopy()) - def attach_chain(self, chain: Iterable[Block]) -> Self: + def attach_chain(self, *chain: Iterable[Block]) -> Block: attaching_block = self for _block in chain: attaching_block = attaching_block.attach_block(_block) - return self + return attaching_block + + def duplicate_chain(self) -> Block: + return self.bottom_level_block.attach_chain( + *map(Block.dcopy, self.attached_chain) + ) diff --git a/scratchattach/editor/blockshape.py b/scratchattach/editor/blockshape.py index 1b4f5184..45ee5073 100644 --- a/scratchattach/editor/blockshape.py +++ b/scratchattach/editor/blockshape.py @@ -26,7 +26,7 @@ def __bool__(self): YESNT: Final[_Yesnt] = _Yesnt() -"""Value used when neither True nor False is applicable""" +"""Value used when neither True nor False is applicable (when it depends on other factors)""" @dataclass(init=True, repr=True) @@ -50,7 +50,6 @@ def is_attachable(self): 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") diff --git a/scratchattach/editor/todo.md b/scratchattach/editor/todo.md index 6f6db88a..4fa1597d 100644 --- a/scratchattach/editor/todo.md +++ b/scratchattach/editor/todo.md @@ -45,9 +45,9 @@ - [x] Add block to sprite - [x] Duplicating (single) block - [x] Attach block -- [ ] Duplicating blockchain +- [x] Duplicating blockchain - [ ] Slot above (if possible - raise error if not) -- [ ] Attach blockchain +- [x] Attach blockchain - [ ] Delete block - [ ] Delete blockchain - [ ] Add/edit inputs @@ -88,7 +88,7 @@ - [ ] Upload asset - [ ] Load from file (auto-detect type) -## Pallete +## 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 From 3d3a7be0bc12960e89faa817e037949f5ccc4459 Mon Sep 17 00:00:00 2001 From: faretek Date: Thu, 12 Dec 2024 20:24:05 +0000 Subject: [PATCH 072/101] more block stuffies :D --- scratchattach/editor/__init__.py | 2 +- scratchattach/editor/block.py | 45 ++++++++++++++++++++++++++++++- scratchattach/editor/inputs.py | 1 + scratchattach/editor/prim.py | 5 +++- scratchattach/editor/sprite.py | 23 +++++++++++++++- scratchattach/editor/todo.md | 13 +++++---- scratchattach/utils/exceptions.py | 7 +++++ 7 files changed, 87 insertions(+), 9 deletions(-) diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py index 05f92f95..b494aae7 100644 --- a/scratchattach/editor/__init__.py +++ b/scratchattach/editor/__init__.py @@ -12,6 +12,6 @@ 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 +from .inputs import Input, ShadowStatuses from .field import Field from .vlb import Variable, List, Broadcast diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 6a6ca1ab..a3df20c2 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -63,6 +63,9 @@ def link_subcomponents(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: @@ -227,6 +230,13 @@ def is_input(self): """ 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: @@ -350,7 +360,7 @@ def link_using_sprite(self, link_subs: bool = True): for _input in self.inputs.values(): _input.link_using_block() - # Adding 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") @@ -381,3 +391,36 @@ 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/inputs.py b/scratchattach/editor/inputs.py index 83ca8a6a..ff14a01a 100644 --- a/scratchattach/editor/inputs.py +++ b/scratchattach/editor/inputs.py @@ -19,6 +19,7 @@ def __repr__(self): 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") diff --git a/scratchattach/editor/prim.py b/scratchattach/editor/prim.py index a6cdc0c5..0070aa81 100644 --- a/scratchattach/editor/prim.py +++ b/scratchattach/editor/prim.py @@ -57,7 +57,7 @@ def is_prim_opcode(opcode: str): class Prim(base.SpriteSubComponent): - def __init__(self, _primtype: PrimType, _value: str | vlb.Variable | vlb.List | vlb.Broadcast = None, + 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 = None): """ @@ -65,6 +65,9 @@ def __init__(self, _primtype: PrimType, _value: str | vlb.Variable | vlb.List | 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 diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index 6451eec7..54857382 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -5,7 +5,7 @@ from io import BytesIO, TextIOWrapper from typing import Any, BinaryIO from zipfile import ZipFile - +from typing import Iterable from . import base, project, vlb, asset, comment, prim, block, commons @@ -145,6 +145,27 @@ def add_block(self, _block: block.Block) -> block.Block: return _block + def add_chain(self, *chain: Iterable[block.Block]) -> block.Block: + """ + 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 remove_block(self, _block: block.Block): + for key, val in self.blocks.items(): + if val is _block: + del self.blocks[key] + return + @property def vlbs(self) -> list[base.NamedIDComponent]: """ diff --git a/scratchattach/editor/todo.md b/scratchattach/editor/todo.md index 4fa1597d..22b3dc1e 100644 --- a/scratchattach/editor/todo.md +++ b/scratchattach/editor/todo.md @@ -9,6 +9,7 @@ - [ ] 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) ## Project @@ -31,7 +32,8 @@ - [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] `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) - [ ] Detection for scratch addons debug blocks (proc codes: @@ -42,15 +44,16 @@ - [ ] Detection for `` and `` and `` booleans ### Adding/removing blocks + - [x] Add block to sprite - [x] Duplicating (single) block - [x] Attach block - [x] Duplicating blockchain -- [ ] Slot above (if possible - raise error if not) +- [x] Slot above (if possible - raise error if not) - [x] Attach blockchain -- [ ] Delete block -- [ ] Delete blockchain -- [ ] Add/edit inputs +- [x] Delete block +- [x] Delete blockchain +- [x] Add/edit inputs - [ ] Add/edit fields - [ ] Add mutation - [ ] Add comment diff --git a/scratchattach/utils/exceptions.py b/scratchattach/utils/exceptions.py index e595760a..edd747ef 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -252,3 +252,10 @@ 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 From f6da6f3cf5e69f8bed636364fabe0f87438e90aa Mon Sep 17 00:00:00 2001 From: faretek Date: Fri, 13 Dec 2024 18:28:05 +0000 Subject: [PATCH 073/101] more block featuresssssss --- scratchattach/editor/__init__.py | 1 + scratchattach/editor/block.py | 18 +++++++++++++++++- scratchattach/editor/comment.py | 6 ++++-- scratchattach/editor/mutation.py | 20 +++++++++++++++++--- scratchattach/editor/prim.py | 6 +++--- scratchattach/editor/project.py | 3 +-- scratchattach/editor/sprite.py | 14 ++++++++++++++ scratchattach/editor/todo.md | 10 +++++++--- 8 files changed, 64 insertions(+), 14 deletions(-) diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py index b494aae7..3a523c6b 100644 --- a/scratchattach/editor/__init__.py +++ b/scratchattach/editor/__init__.py @@ -15,3 +15,4 @@ from .inputs import Input, ShadowStatuses from .field import Field from .vlb import Variable, List, Broadcast +from .comment import Comment diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index a3df20c2..389e9f75 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -3,7 +3,7 @@ import warnings from typing import Iterable, Self -from . import base, sprite, mutation, field, inputs, commons, vlb, blockshape, prim +from . import base, sprite, mutation, field, inputs, commons, vlb, blockshape, prim, comment from ..utils import exceptions @@ -72,6 +72,18 @@ 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 @@ -247,6 +259,10 @@ def parent_input(self): return _input return None + @property + def new_id(self): + return self.sprite.new_id + @staticmethod def from_json(data: dict) -> Block: """ diff --git a/scratchattach/editor/comment.py b/scratchattach/editor/comment.py index ebe9c581..835e661c 100644 --- a/scratchattach/editor/comment.py +++ b/scratchattach/editor/comment.py @@ -5,13 +5,15 @@ class Comment(base.IDComponent): def __init__(self, _id: str = None, _block: block.Block = None, x: int = 0, y: int = 0, width: int = 100, - height: int = 100, minimized: bool = False, - text: str = '', *, _block_id: str = None, _sprite: sprite.Sprite = None): + height: int = 100, minimized: bool = False, text: str = '', *, _block_id: str = None, + _sprite: sprite.Sprite = None, 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 diff --git a/scratchattach/editor/mutation.py b/scratchattach/editor/mutation.py index 0731d82f..6a6a102d 100644 --- a/scratchattach/editor/mutation.py +++ b/scratchattach/editor/mutation.py @@ -1,8 +1,9 @@ from __future__ import annotations import json +import warnings from dataclasses import dataclass -from typing import TYPE_CHECKING, Iterable +from typing import TYPE_CHECKING, Iterable, Any from . import base, commons from ..utils import enums @@ -103,7 +104,7 @@ def __lt__(self, other): @dataclass(init=True, repr=True) -class Argument(base.Base): +class Argument(base.BlockSubComponent): name: str default: str = '' @@ -112,7 +113,16 @@ class Argument(base.Base): Argument ID: Will be used to replace other parameters during block instantiation. """ - _block: block.Block = None + @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_block(self): + if self._id is None: + self._id = self.block.new_id class Mutation(base.BlockSubComponent): @@ -285,3 +295,7 @@ def link_arguments(self): _arg_phs: Iterable[ArgumentPlaceholder] = filter(lambda tkn: isinstance(tkn, ArgumentPlaceholder), _parsed) for i, _arg_ph in enumerate(_arg_phs): self.arguments[i].default = _arg_ph.default + + for _argument in self.arguments: + _argument.block = self.block + _argument.link_using_block() diff --git a/scratchattach/editor/prim.py b/scratchattach/editor/prim.py index 0070aa81..83e05f41 100644 --- a/scratchattach/editor/prim.py +++ b/scratchattach/editor/prim.py @@ -24,14 +24,14 @@ def __eq__(self, other): @staticmethod def from_json(data: int): - ... + return PrimTypes.find(data, "code") def to_json(self) -> int: return self.code -BASIC_ATTRS: Final = ["value"] -VLB_ATTRS: Final = ["name", "id", "x", "y"] +BASIC_ATTRS: Final[tuple[str]] = ("value",) +VLB_ATTRS: Final[tuple[str]] = ("name", "id", "x", "y") class PrimTypes(enums._EnumWrapper): diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index f776c253..63f4f1cf 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -7,7 +7,7 @@ from typing import Iterable, Generator, BinaryIO from zipfile import ZipFile -from . import base, meta, extension, monitor, sprite, asset, vlb, commons, twconfig, comment +from . import base, meta, extension, monitor, sprite, asset, vlb, twconfig, comment from ..site.project import get_project from ..site import session @@ -233,4 +233,3 @@ def export(self, fp: str, *, auto_open: bool = False, export_as_zip: bool = True if auto_open: os.system(f"explorer.exe \"{fp}\"") - diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index 54857382..5e081d7d 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -138,6 +138,10 @@ def add_vlb(self, _vlb: base.NamedIDComponent): warnings.warn(f"Invalid 'VLB' {_vlb} of type: {type(_vlb)}") def add_block(self, _block: block.Block) -> block.Block: + if _block.sprite is self: + if _block in self.blocks: + return _block + _block.sprite = self self.blocks[self.new_id] = _block @@ -160,6 +164,16 @@ def add_chain(self, *chain: Iterable[block.Block]) -> 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: diff --git a/scratchattach/editor/todo.md b/scratchattach/editor/todo.md index 22b3dc1e..861b0b92 100644 --- a/scratchattach/editor/todo.md +++ b/scratchattach/editor/todo.md @@ -10,6 +10,7 @@ - [ ] 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) ## Project @@ -54,13 +55,16 @@ - [x] Delete block - [x] Delete blockchain - [x] Add/edit inputs -- [ ] Add/edit fields -- [ ] Add mutation -- [ ] Add comment +- [x] Add/edit fields +- [x] Add mutation +- [x] Add comment +- [ ] Get comment ## Mutation - [ ] Proc code builder +- [ ] get type of argument (bool/str) inside argument class +- [ ] to/from json for args? ## Sprite From 4209a8cc4bde855e93b3710970dc3a76a9b5c37d Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 14 Dec 2024 00:09:02 +0000 Subject: [PATCH 074/101] bugfix with comments and ids --- scratchattach/editor/block.py | 13 +++++++++++++ scratchattach/editor/comment.py | 4 ++-- scratchattach/editor/commons.py | 4 +++- scratchattach/editor/sprite.py | 2 +- scratchattach/editor/todo.md | 2 ++ 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 389e9f75..8a8bddfc 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -263,6 +263,13 @@ def parent_input(self): 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 + @staticmethod def from_json(data: dict) -> Block: """ @@ -308,6 +315,12 @@ def to_json(self) -> dict: "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, diff --git a/scratchattach/editor/comment.py b/scratchattach/editor/comment.py index 835e661c..6290a5e0 100644 --- a/scratchattach/editor/comment.py +++ b/scratchattach/editor/comment.py @@ -4,8 +4,8 @@ class Comment(base.IDComponent): - def __init__(self, _id: str = None, _block: block.Block = None, x: int = 0, y: int = 0, width: int = 100, - height: int = 100, minimized: bool = False, text: str = '', *, _block_id: str = None, + 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 = None, pos: tuple[int, int] = None): self.block = _block self._block_id = _block_id diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index 55e56ab1..1fd6f2c6 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -11,7 +11,9 @@ from ..utils import exceptions DIGITS: Final[tuple[str]] = tuple("0123456789") -ID_CHARS: Final[str] = string.ascii_letters + string.digits + string.punctuation + +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: diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index 5e081d7d..c9293f0e 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -139,7 +139,7 @@ def add_vlb(self, _vlb: base.NamedIDComponent): def add_block(self, _block: block.Block) -> block.Block: if _block.sprite is self: - if _block in self.blocks: + if _block in self.blocks.values(): return _block _block.sprite = self diff --git a/scratchattach/editor/todo.md b/scratchattach/editor/todo.md index 861b0b92..ddca56cf 100644 --- a/scratchattach/editor/todo.md +++ b/scratchattach/editor/todo.md @@ -11,6 +11,8 @@ - [ ] bs4 styled search function - [ ] ScratchJR project parser (lol) - [ ] Error checking (for when you need to specify sprite etc) +- [ ] 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 From 2a2960d6e53d2fb4137fb02e810c70583a6c9381 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 14 Dec 2024 13:28:52 +0000 Subject: [PATCH 075/101] tw block detection --- scratchattach/editor/block.py | 50 +++++++++++++++++++++++++++++++++ scratchattach/editor/project.py | 30 ++++++++++++++------ scratchattach/editor/todo.md | 6 ++-- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 8a8bddfc..788ea911 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -270,6 +270,55 @@ def comment(self) -> comment.Comment | None: 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: """ @@ -453,3 +502,4 @@ def delete_single_block(self): def delete_chain(self): for _block in self.attached_chain: _block.delete_single_block() + diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index 63f4f1cf..cb5d7e7b 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -136,9 +136,10 @@ def from_json(data: dict): return Project(None, _meta, _extensions, _monitors, _sprites) @staticmethod - def from_sb3(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: str = None): + def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: str = None): """ - Load a project from an .sb3 file/bytes/file path + Load project JSON and assets from an .sb3 file/bytes/file path + :return: Project name, asset data, json string """ _dir_for_name = None @@ -161,19 +162,17 @@ def from_sb3(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = T _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) try: project = Project.from_json(json.load(data)) except ValueError or UnicodeDecodeError: with ZipFile(data) as archive: - data = json.loads(archive.read("project.json")) - - project = Project.from_json(data) + json_str = archive.read("project.json") # Also load assets if load_assets: - asset_data = [] for filename in archive.namelist(): if filename != "project.json": md5_hash = filename.split('.')[0] @@ -181,13 +180,26 @@ def from_sb3(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = T asset_data.append( asset.AssetFile(filename, archive.read(filename), md5_hash) ) - project.asset_data = asset_data + 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") - project.name = _name - return project + 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 = Project.from_json(data) + project.name = _name + project.asset_data = asset_data + + return project @staticmethod def from_id(project_id: int, _name: str = None): diff --git a/scratchattach/editor/todo.md b/scratchattach/editor/todo.md index ddca56cf..4aaf54e8 100644 --- a/scratchattach/editor/todo.md +++ b/scratchattach/editor/todo.md @@ -38,13 +38,13 @@ - [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) -- [ ] Detection for scratch addons debug blocks +- [x] Detection for turbowarp debug blocks (proc codes: `"​​log​​ %s", "​​breakpoint​​", "​​error​​ %s", "​​warn​​ %s"` - note: they all have ZWSPs) -- [ ] Detection for `` and `` and `` booleans +- [x] Detection for `` and `` and `` booleans ### Adding/removing blocks @@ -60,7 +60,7 @@ - [x] Add/edit fields - [x] Add mutation - [x] Add comment -- [ ] Get comment +- [x] Get comment ## Mutation From b553d4987340b2ce50756e6224b1842b79488f1e Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 14 Dec 2024 13:45:36 +0000 Subject: [PATCH 076/101] argument types --- scratchattach/editor/base.py | 18 +++++ scratchattach/editor/mutation.py | 118 ++++++++++++++++++------------- scratchattach/editor/todo.md | 2 +- 3 files changed, 86 insertions(+), 52 deletions(-) diff --git a/scratchattach/editor/base.py b/scratchattach/editor/base.py index f5953405..a93acced 100644 --- a/scratchattach/editor/base.py +++ b/scratchattach/editor/base.py @@ -13,6 +13,7 @@ from . import project from . import sprite from . import block + from . import mutation class Base(ABC): @@ -92,3 +93,20 @@ def sprite(self) -> sprite.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/mutation.py b/scratchattach/editor/mutation.py index 6a6a102d..91b764fe 100644 --- a/scratchattach/editor/mutation.py +++ b/scratchattach/editor/mutation.py @@ -13,7 +13,7 @@ @dataclass(init=True) -class ArgumentPlaceholder(base.Base): +class ArgumentType(base.Base): type: str proc_str: str @@ -21,12 +21,12 @@ def __eq__(self, other): if isinstance(other, enums._EnumWrapper): other = other.value - assert isinstance(other, ArgumentPlaceholder) + assert isinstance(other, ArgumentType) return self.type == other.type def __repr__(self): - return f"" + return f"" @property def default(self) -> str | None: @@ -38,48 +38,6 @@ def default(self) -> str | None: return None -class ArgumentPlaceholders(enums._EnumWrapper): - BOOLEAN = ArgumentPlaceholder("boolean", "%b") - NUMBER_OR_TEXT = ArgumentPlaceholder("number or text", "%s") - - -def parse_proc_code(_proc_code: str) -> list[str, ArgumentPlaceholder] | 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(ArgumentPlaceholders.BOOLEAN.value.dcopy()) - elif token == "%s": - tokens.append(ArgumentPlaceholders.NUMBER_OR_TEXT.value.dcopy()) - - token = '' - continue - - token += char - last_char = char - - if token != '': - tokens.append(token) - - return tokens - - @dataclass(init=True, repr=True) class ArgSettings(base.Base): ids: bool @@ -104,7 +62,7 @@ def __lt__(self, other): @dataclass(init=True, repr=True) -class Argument(base.BlockSubComponent): +class Argument(base.MutationSubComponent): name: str default: str = '' @@ -113,6 +71,20 @@ class Argument(base.BlockSubComponent): 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?)") @@ -120,11 +92,53 @@ def from_json(data: dict | list | Any): def to_json(self) -> dict | list | Any: warnings.warn("No to_json method defined for Arguments (yet?)") - def link_using_block(self): + 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, @@ -137,6 +151,7 @@ def __init__(self, _tag_name: str = "mutation", _children: list = None, _proc_co # Defaulting for args if _children is None: _children = [] + if _argument_settings is None: if _arguments: _argument_settings = ArgSettings( @@ -193,7 +208,7 @@ def argument_settings(self) -> ArgSettings: bool(commons.safe_get(self.argument_defaults, 0))) @property - def parsed_proc_code(self) -> list[str, ArgumentPlaceholder] | None: + def parsed_proc_code(self) -> list[str, ArgumentType] | None: return parse_proc_code(self.proc_code) @staticmethod @@ -292,10 +307,11 @@ def link_arguments(self): # 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[ArgumentPlaceholder] = filter(lambda tkn: isinstance(tkn, ArgumentPlaceholder), _parsed) + _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.block = self.block - _argument.link_using_block() + _argument.mutation = self + _argument.link_using_mutation() diff --git a/scratchattach/editor/todo.md b/scratchattach/editor/todo.md index 4aaf54e8..47a71b73 100644 --- a/scratchattach/editor/todo.md +++ b/scratchattach/editor/todo.md @@ -65,7 +65,7 @@ ## Mutation - [ ] Proc code builder -- [ ] get type of argument (bool/str) inside argument class +- [x] get type of argument (bool/str) inside argument class - [ ] to/from json for args? ## Sprite From f0f8a9954447564358be4f2bba12a1104304e3e6 Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 15 Dec 2024 21:21:33 +0000 Subject: [PATCH 077/101] with statements with sprites to reduce the add_block calls needed. probably want to make a function run when a sprite is assigned, instead of just calling the link function when necessary --- scratchattach/editor/__init__.py | 3 + scratchattach/editor/asset.py | 19 +++---- scratchattach/editor/base.py | 46 ++++++++++++--- scratchattach/editor/block.py | 10 ++-- scratchattach/editor/blockshape.py | 15 ++--- scratchattach/editor/build_defaulting.py | 47 ++++++++++++++++ scratchattach/editor/comment.py | 4 +- scratchattach/editor/commons.py | 14 ++++- scratchattach/editor/extension.py | 3 + scratchattach/editor/meta.py | 4 +- scratchattach/editor/monitor.py | 16 ++++-- scratchattach/editor/pallete.py | 2 + scratchattach/editor/prim.py | 4 +- scratchattach/editor/project.py | 28 ++++++---- scratchattach/editor/sprite.py | 71 +++++++++++++++++------- scratchattach/editor/todo.md | 7 ++- scratchattach/editor/vlb.py | 10 ++-- 17 files changed, 219 insertions(+), 84 deletions(-) create mode 100644 scratchattach/editor/build_defaulting.py diff --git a/scratchattach/editor/__init__.py b/scratchattach/editor/__init__.py index 3a523c6b..0e3a2452 100644 --- a/scratchattach/editor/__init__.py +++ b/scratchattach/editor/__init__.py @@ -16,3 +16,6 @@ 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 index 605d480d..a6b164b2 100644 --- a/scratchattach/editor/asset.py +++ b/scratchattach/editor/asset.py @@ -1,20 +1,17 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from hashlib import md5 import requests -from . import base, commons, sprite +from . import base, commons, sprite, build_defaulting -@dataclass(init=True) +@dataclass(init=True, repr=True) class AssetFile: filename: str - _data: bytes = None - _md5: str = None - - def __repr__(self): - return f"AssetFile(filename={self.filename!r})" + _data: bytes = field(repr=False, default=None) + _md5: str = field(repr=False, default=None) @property def data(self): @@ -40,7 +37,7 @@ class Asset(base.SpriteSubComponent): def __init__(self, name: str = "costume1", file_name: str = "b7853f557e4426412e64bb3da6531a99.svg", - _sprite: sprite.Sprite = None): + _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 @@ -126,7 +123,7 @@ def __init__(self, bitmap_resolution=None, rotation_center_x: int | float = 48, rotation_center_y: int | float = 50, - _sprite: sprite.Sprite = None): + _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): """ A costume. An asset with additional properties https://en.scratch-wiki.info/wiki/Scratch_File_Format#Costumes @@ -166,7 +163,7 @@ def __init__(self, rate: int = None, sample_count: int = None, - _sprite: sprite.Sprite = 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 diff --git a/scratchattach/editor/base.py b/scratchattach/editor/base.py index a93acced..58bac9df 100644 --- a/scratchattach/editor/base.py +++ b/scratchattach/editor/base.py @@ -7,13 +7,13 @@ import copy import json from abc import ABC, abstractmethod -from typing import Any, TYPE_CHECKING +from io import TextIOWrapper +from typing import Any, TYPE_CHECKING, BinaryIO if TYPE_CHECKING: - from . import project - from . import sprite - from . import block - from . import mutation + from . import project, sprite, block, mutation, asset + +from . import build_defaulting class Base(ABC): @@ -46,22 +46,52 @@ def save_json(self, name: str = ''): 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 = None): + 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 = None): + def __init__(self, _id: str, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): self.id = _id super().__init__(_sprite) @@ -74,7 +104,7 @@ class NamedIDComponent(IDComponent, ABC): Base class for Variables, Lists and Broadcasts (Name + ID + sprite) """ - def __init__(self, _id: str, name: str, _sprite: sprite.Sprite = None): + def __init__(self, _id: str, name: str, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): self.name = name super().__init__(_id, _sprite) diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 788ea911..90d7a946 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -3,7 +3,7 @@ import warnings from typing import Iterable, Self -from . import base, sprite, mutation, field, inputs, commons, vlb, blockshape, prim, comment +from . import base, sprite, mutation, field, inputs, commons, vlb, blockshape, prim, comment, build_defaulting from ..utils import exceptions @@ -13,7 +13,7 @@ def __init__(self, _opcode: str, _shadow: bool = False, _top_level: bool = 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 = None): + *, _next_id: str = None, _parent_id: str = None, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): # Defaulting for args if _fields is None: _fields = {} @@ -75,7 +75,7 @@ def add_field(self, name: str, _field: field.Field) -> Self: def set_mutation(self, _mutation: mutation.Mutation) -> Self: self.mutation = _mutation _mutation.block = self - # _mutation.link_arguments() + _mutation.link_arguments() return self def set_comment(self, _comment: comment.Comment) -> Self: @@ -135,7 +135,9 @@ def id(self) -> str | None: for _block_id, _block in self.sprite.blocks.items(): if _block is self: return _block_id - return None + + # Let's just automatically assign ourselves an id + self.sprite.add_block(self) @property def parent_id(self): diff --git a/scratchattach/editor/blockshape.py b/scratchattach/editor/blockshape.py index 45ee5073..ca6b775e 100644 --- a/scratchattach/editor/blockshape.py +++ b/scratchattach/editor/blockshape.py @@ -7,27 +7,20 @@ from dataclasses import dataclass from typing import Final -from ..utils.enums import _EnumWrapper +from . import commons -_singletons = [] +from ..utils.enums import _EnumWrapper -class _Yesnt: +class _Yesnt(commons.Singleton): """I can't really tell you if yesn't means yes or no; is it true or false? It depends.""" - - def __new__(cls, *args, **kwargs): - if len(_singletons) == 0: - return object.__new__(cls) - else: - return _singletons[0] - def __bool__(self): raise TypeError("I can't really tell you if yesn't means yes or no; is it true or false? It depends.") YESNT: Final[_Yesnt] = _Yesnt() """Value used when neither True nor False is applicable (when it depends on other factors)""" - +print(id(YESNT.i_list)) @dataclass(init=True, repr=True) class BlockShape: 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 index 6290a5e0..d436e246 100644 --- a/scratchattach/editor/comment.py +++ b/scratchattach/editor/comment.py @@ -1,12 +1,12 @@ from __future__ import annotations -from . import base, block, sprite +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 = None, pos: tuple[int, int] = None): + _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT, pos: tuple[int, int] = None): self.block = _block self._block_id = _block_id """ diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index 1fd6f2c6..fe7d1c74 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -131,12 +131,14 @@ def is_partial_json(_str: str, i: int = 0) -> bool: return False -def is_valid_json(_str: str) -> bool: +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: @@ -205,3 +207,13 @@ def sanitize_fn(filename: str): else: ret += '_' return ret + + +class Singleton: + i_list = [] + + def __new__(cls, *args, **kwargs): + if len(cls.i_list) == 0: + return object.__new__(cls) + else: + return cls.i_list[0] diff --git a/scratchattach/editor/extension.py b/scratchattach/editor/extension.py index 634f10c1..81bfed06 100644 --- a/scratchattach/editor/extension.py +++ b/scratchattach/editor/extension.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + from dataclasses import dataclass from . import base diff --git a/scratchattach/editor/meta.py b/scratchattach/editor/meta.py index 98437adc..0f1fb562 100644 --- a/scratchattach/editor/meta.py +++ b/scratchattach/editor/meta.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from dataclasses import dataclass +from dataclasses import dataclass, field from . import base, commons @@ -9,7 +9,7 @@ @dataclass(init=True, repr=True) class PlatformMeta(base.JSONSerializable): name: str = None - url: str = None + url: str = field(repr=True, default=None) def __bool__(self): return self.name is not None or self.url is not None diff --git a/scratchattach/editor/monitor.py b/scratchattach/editor/monitor.py index a964642b..0c20486a 100644 --- a/scratchattach/editor/monitor.py +++ b/scratchattach/editor/monitor.py @@ -29,7 +29,7 @@ def __init__(self, reporter: base.NamedIDComponent = None, """ assert isinstance(reporter, base.SpriteSubComponent) or reporter is None - self._reporter_id = reporter_id + self.reporter_id = reporter_id """ ID referencing the VLB being referenced. Replaced with None during project instantiation, where the reporter attribute is updated """ @@ -61,11 +61,15 @@ def __repr__(self): return f"Monitor<{self.opcode}>" @property - def reporter_id(self): + 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 + return self.reporter_id @staticmethod def from_json(data: dict): @@ -97,7 +101,7 @@ def from_json(data: dict): def to_json(self): _json = { - "id": self.reporter_id, + "id": self.id, "mode": self.mode, "opcode": self.opcode, @@ -126,10 +130,10 @@ 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") + new_vlb = self.project.find_vlb(self.reporter_id, "id") if new_vlb is not None: self.reporter = new_vlb - self._reporter_id = None + self.reporter_id = None # @staticmethod # def from_reporter(reporter: Block, _id: str = None, mode: str = "default", diff --git a/scratchattach/editor/pallete.py b/scratchattach/editor/pallete.py index 5c11151b..587fb485 100644 --- a/scratchattach/editor/pallete.py +++ b/scratchattach/editor/pallete.py @@ -1,6 +1,8 @@ """ 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 diff --git a/scratchattach/editor/prim.py b/scratchattach/editor/prim.py index 83e05f41..b787b480 100644 --- a/scratchattach/editor/prim.py +++ b/scratchattach/editor/prim.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Callable, Final -from . import base, sprite, vlb, commons +from . import base, sprite, vlb, commons, build_defaulting from ..utils import enums, exceptions @@ -59,7 +59,7 @@ def is_prim_opcode(opcode: str): 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 = 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 diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index cb5d7e7b..b3374c69 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -7,15 +7,13 @@ from typing import Iterable, Generator, BinaryIO from zipfile import ZipFile -from . import base, meta, extension, monitor, sprite, asset, vlb, twconfig, comment - -from ..site.project import get_project +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.JSONSerializable): +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): @@ -31,7 +29,7 @@ def __init__(self, _name: str = None, _meta: meta.Meta = None, _extensions: Iter self.meta = _meta self.extensions = _extensions - self.monitors = _monitors + self.monitors = list(_monitors) self.sprites = list(_sprites) self.asset_data = _asset_data @@ -111,6 +109,10 @@ def all_ids(self): _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) @@ -165,9 +167,10 @@ def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = asset_data = [] with data: # For if the sb3 is just JSON (e.g. if it's exported from scratchattach) - try: - project = Project.from_json(json.load(data)) - except ValueError or UnicodeDecodeError: + if commons.is_valid_json(data): + json_str = data + + else: with ZipFile(data) as archive: json_str = archive.read("project.json") @@ -195,7 +198,7 @@ def from_sb3(cls, data: str | bytes | TextIOWrapper | BinaryIO, load_assets: boo _name, asset_data, json_str = cls.load_json(data, load_assets, _name) data = json.loads(json_str) - project = Project.from_json(data) + project = cls.from_json(data) project.name = _name project.asset_data = asset_data @@ -245,3 +248,8 @@ def export(self, fp: str, *, auto_open: bool = False, export_as_zip: bool = True 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/sprite.py b/scratchattach/editor/sprite.py index c9293f0e..940b4da6 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -6,10 +6,10 @@ from typing import Any, BinaryIO from zipfile import ZipFile from typing import Iterable -from . import base, project, vlb, asset, comment, prim, block, commons +from . import base, project, vlb, asset, comment, prim, block, commons, build_defaulting -class Sprite(base.ProjectSubcomponent): +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, @@ -88,6 +88,13 @@ def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int 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() @@ -137,19 +144,24 @@ def add_vlb(self, _vlb: base.NamedIDComponent): else: warnings.warn(f"Invalid 'VLB' {_vlb} of type: {type(_vlb)}") - def add_block(self, _block: block.Block) -> block.Block: + 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 - self.blocks[self.new_id] = _block - _block.link_using_sprite() + 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]) -> block.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: @@ -479,14 +491,17 @@ def all_ids(self): ret += list(iterator) return ret - @staticmethod - def from_sprite3(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: str = None): - """ - Load a project from an .sb3 file/bytes/file path - """ + 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) @@ -494,19 +509,24 @@ def from_sprite3(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool _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) - try: - _sprite = Sprite.from_json(json.load(data)) + if commons.is_valid_json(data): + json_str = data - except ValueError or UnicodeDecodeError: + else: with ZipFile(data) as archive: - data = json.loads(archive.read("sprite.json")) - _sprite = Sprite.from_json(data) + json_str = archive.read("sprite.json") # Also load assets if load_assets: - asset_data = [] + for filename in archive.namelist(): if filename != "sprite.json": md5_hash = filename.split('.')[0] @@ -514,9 +534,22 @@ def from_sprite3(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool asset_data.append( asset.AssetFile(filename, archive.read(filename), md5_hash) ) - _sprite.asset_data = asset_data 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 _sprite + 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 index 47a71b73..203a81e6 100644 --- a/scratchattach/editor/todo.md +++ b/scratchattach/editor/todo.md @@ -11,7 +11,7 @@ - [ ] bs4 styled search function - [ ] ScratchJR project parser (lol) - [ ] Error checking (for when you need to specify sprite etc) -- [ ] Split json unpacking and the use of .from_json method so that it is easy to just extract json data (but not parse +- [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 @@ -81,10 +81,11 @@ - [ ] 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 diff --git a/scratchattach/editor/vlb.py b/scratchattach/editor/vlb.py index bed39c44..174f0d89 100644 --- a/scratchattach/editor/vlb.py +++ b/scratchattach/editor/vlb.py @@ -8,13 +8,13 @@ from typing import Literal -from . import base, sprite +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 = None): + _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 @@ -67,7 +67,7 @@ def to_json(self) -> tuple[str, str | int | float, bool] | tuple[str, str | int class List(base.NamedIDComponent): def __init__(self, _id: str, _name: str, _value: list[str | int | float] = None, - _sprite: sprite.Sprite = 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 @@ -99,7 +99,7 @@ def to_json(self) -> tuple[str, tuple[str, str | int | float, bool] | tuple[str, class Broadcast(base.NamedIDComponent): - def __init__(self, _id: str, _name: str, _sprite: sprite.Sprite = None): + 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 @@ -121,7 +121,7 @@ def to_json(self) -> str: def construct(vlb_type: Literal["variable", "list", "broadcast"], _id: str = None, _name: str = None, - _sprite: sprite.Sprite = None) -> Variable | List | Broadcast: + _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT) -> Variable | List | Broadcast: if vlb_type == "variable": vlb_type = Variable elif vlb_type == "list": From 83f93466eda56d15972377efbaaefd2ff94b92f3 Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 15 Dec 2024 21:36:12 +0000 Subject: [PATCH 078/101] better singleton implementation --- scratchattach/editor/commons.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index fe7d1c74..f88a7bed 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -209,11 +209,12 @@ def sanitize_fn(filename: str): return ret -class Singleton: - i_list = [] +class Singleton(object): + _instance: Singleton def __new__(cls, *args, **kwargs): - if len(cls.i_list) == 0: - return object.__new__(cls) + if hasattr(cls, "_instance"): + return cls._instance else: - return cls.i_list[0] + cls._instance = super(Singleton, cls).__new__(cls) + return cls._instance From 2b01dae435ccc2c8c773fd16e45f7d4916c10922 Mon Sep 17 00:00:00 2001 From: faretek Date: Fri, 20 Dec 2024 15:27:11 +0000 Subject: [PATCH 079/101] adds reset link send (maybe review this) --- scratchattach/editor/blockshape.py | 7 ++++--- scratchattach/other/other_apis.py | 9 ++++++++- scratchattach/site/user.py | 3 ++- scratchattach/utils/commons.py | 3 +-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/scratchattach/editor/blockshape.py b/scratchattach/editor/blockshape.py index ca6b775e..875dc1a3 100644 --- a/scratchattach/editor/blockshape.py +++ b/scratchattach/editor/blockshape.py @@ -8,19 +8,19 @@ from typing import Final from . import commons - from ..utils.enums import _EnumWrapper class _Yesnt(commons.Singleton): """I can't really tell you if yesn't means yes or no; is it true or false? It depends.""" + def __bool__(self): raise TypeError("I can't really tell you if yesn't means yes or no; is it true or false? It depends.") YESNT: Final[_Yesnt] = _Yesnt() """Value used when neither True nor False is applicable (when it depends on other factors)""" -print(id(YESNT.i_list)) + @dataclass(init=True, repr=True) class BlockShape: @@ -39,7 +39,8 @@ class BlockShape: @property def is_attachable(self): if self.is_cap is YESNT: - 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)") + 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 diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index c9519b87..8ced2d13 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -3,9 +3,9 @@ import json 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 -from ..utils.enums import Languages, Language, TTSVoices, TTSVoice # --- Front page --- @@ -147,6 +147,13 @@ def scratch_team_members() -> dict: 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) diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index e2134bec..03e9b355 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -1,4 +1,5 @@ """Session class and login function""" +from __future__ import annotations import json import random @@ -262,7 +263,7 @@ 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 diff --git a/scratchattach/utils/commons.py b/scratchattach/utils/commons.py index f79f8188..19b5d1e0 100644 --- a/scratchattach/utils/commons.py +++ b/scratchattach/utils/commons.py @@ -15,8 +15,7 @@ "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: Final = { 'targets': [ { From 0a03800cf46b97a1d3150e24a1fb7ebbd1d4fc8e Mon Sep 17 00:00:00 2001 From: "." Date: Sat, 21 Dec 2024 11:36:23 +0000 Subject: [PATCH 080/101] class build rate limit --- scratchattach/site/session.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index 0c79f241..16ab37ac 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -33,6 +33,8 @@ CREATE_PROJECT_USES = [] CREATE_STUDIO_USES = [] +CREATE_CLASS_USES = [] + class Session(BaseSiteComponent): @@ -532,6 +534,28 @@ def create_studio(self, *, title=None, description: str = None): return new_studio def create_class(self, title: str, desc: str = ''): + """ + 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") From 569d9ecf5b7e542e1c30780c0c5e142a303bb26a Mon Sep 17 00:00:00 2001 From: "." Date: Sat, 21 Dec 2024 14:05:08 +0000 Subject: [PATCH 081/101] fix old type hints + add from future import annotations to top of all files --- scratchattach/cloud/_base.py | 2 + scratchattach/cloud/cloud.py | 4 +- scratchattach/eventhandlers/_base.py | 2 + scratchattach/eventhandlers/cloud_events.py | 1 + scratchattach/eventhandlers/cloud_recorder.py | 12 ++- scratchattach/eventhandlers/cloud_requests.py | 1 + scratchattach/eventhandlers/cloud_server.py | 2 + scratchattach/eventhandlers/cloud_storage.py | 1 + scratchattach/eventhandlers/combine.py | 2 + scratchattach/eventhandlers/filterbot.py | 1 + scratchattach/eventhandlers/message_events.py | 1 + scratchattach/other/other_apis.py | 1 + .../other/project_json_capabilities.py | 2 +- scratchattach/site/_base.py | 12 ++- scratchattach/site/activity.py | 35 ++++--- scratchattach/site/backpack_asset.py | 23 +++-- scratchattach/site/classroom.py | 59 ++++++------ scratchattach/site/cloud_activity.py | 4 + scratchattach/site/comment.py | 64 +++++++------ scratchattach/site/forum.py | 1 + scratchattach/site/project.py | 1 + scratchattach/site/session.py | 93 +++++++++---------- scratchattach/site/studio.py | 3 +- scratchattach/site/user.py | 3 +- scratchattach/utils/commons.py | 8 +- scratchattach/utils/encoder.py | 1 + scratchattach/utils/enums.py | 1 + scratchattach/utils/exceptions.py | 1 + scratchattach/utils/requests.py | 2 + 29 files changed, 202 insertions(+), 141 deletions(-) diff --git a/scratchattach/cloud/_base.py b/scratchattach/cloud/_base.py index debd9b67..86eba6c3 100644 --- a/scratchattach/cloud/_base.py +++ b/scratchattach/cloud/_base.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import ssl import time diff --git a/scratchattach/cloud/cloud.py b/scratchattach/cloud/cloud.py index a387fede..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__() 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 10121cd8..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 diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 1b545c40..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 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 c9519b87..76fa9a1c 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -1,4 +1,5 @@ """Other Scratch API-related functions""" +from __future__ import annotations import json diff --git a/scratchattach/other/project_json_capabilities.py b/scratchattach/other/project_json_capabilities.py index fe5670e2..5c022463 100644 --- a/scratchattach/other/project_json_capabilities.py +++ b/scratchattach/other/project_json_capabilities.py @@ -1,7 +1,7 @@ """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 diff --git a/scratchattach/site/_base.py b/scratchattach/site/_base.py index 010265ea..86c542e4 100644 --- a/scratchattach/site/_base.py +++ b/scratchattach/site/_base.py @@ -1,8 +1,10 @@ +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): @@ -23,14 +25,18 @@ def update(self): 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 @@ -45,14 +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 = requests.get + 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 30d1dabe..c53c97a8 100644 --- a/scratchattach/site/activity.py +++ b/scratchattach/site/activity.py @@ -1,14 +1,7 @@ """Activity and CloudActivity class""" +from __future__ import annotations -import json -import re -import time - -from . import user -from . import session -from . import project -from . import studio -from . import forum, comment +from . import user, project, studio, forum, comment from ..utils import exceptions from ._base import BaseSiteComponent from ..utils.commons import headers @@ -31,6 +24,17 @@ 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 + # Update attributes from entries dict: self.__dict__.update(entries) @@ -93,13 +97,13 @@ def target(self): 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__: @@ -108,20 +112,23 @@ def target(self): 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) 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) + 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..f0b42692 100644 --- a/scratchattach/site/backpack_asset.py +++ b/scratchattach/site/backpack_asset.py @@ -1,8 +1,13 @@ +from __future__ import annotations + import time +import logging + from ._base import BaseSiteComponent from ..utils.requests import Requests as requests from ..utils import exceptions + class BackpackAsset(BaseSiteComponent): """ Represents an asset from the backpack. @@ -32,8 +37,8 @@ def __init__(self, **entries): self.__dict__.update(entries) def update(self): - print("Warning: BackpackAsset objects can't be updated") - return False # Objects of this type cannot be updated + logging.warning("Warning: BackpackAsset objects can't be updated") + return False # Objects of this type cannot be updated def _update_from_dict(self, data) -> bool: try: self.id = data["id"] @@ -52,21 +57,21 @@ def _update_from_dict(self, data) -> bool: except Exception: pass return True - def download(self, *, dir=""): + 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+"/" + if not (fp.endswith("/") or fp.endswith("\\")): + fp = fp + "/" try: response = requests.get( self.download_url, timeout=10, ) - open(f"{dir}{self.filename}", "wb").write(response.content) + open(f"{fp}{self.filename}", "wb").write(response.content) except Exception as e: raise ( exceptions.FetchError( @@ -79,6 +84,6 @@ def delete(self): 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 6a96ad4f..d23f5bd7 100644 --- a/scratchattach/site/classroom.py +++ b/scratchattach/site/classroom.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import datetime import warnings -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import bs4 @@ -49,8 +51,8 @@ def __init__(self, **entries): self._json_headers["Content-Type"] = "application/json" self.is_closed = False - def __repr__(self): - return f"classroom called '{self.title}'" + def __repr__(self) -> str: + return f"classroom called {self.title!r}" def update(self): try: @@ -61,7 +63,8 @@ def update(self): if not success: response = requests.get(f"https://scratch.mit.edu/classes/{self.id}/") soup = BeautifulSoup(response.text, "html.parser") - # id, title, description, status, date_start (iso str), educator/username + + # 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"}) @@ -117,7 +120,7 @@ def _update_from_dict(self, classrooms): 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}/", @@ -125,7 +128,7 @@ def student_count(self): ).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. @@ -157,7 +160,7 @@ def student_names(self, *, page=1): textlist = [i.split('/">')[0] for i in text.split(' list[int]: + def class_studio_ids(self, *, page: int = 1) -> list[int]: """ Returns the class studio on the class. @@ -173,7 +176,7 @@ def class_studio_ids(self, *, page=1) -> list[int]: 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 = [] @@ -196,18 +199,18 @@ def class_studio_ids(self, *, page=1) -> list[int]: 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): + 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, @@ -225,7 +228,7 @@ def set_description(self, desc: str): warnings.warn(f"{self._session} may not be authenticated to edit {self}") raise e - def set_working_on(self, status: str): + 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, @@ -243,7 +246,7 @@ def set_working_on(self, status: str): warnings.warn(f"{self._session} may not be authenticated to edit {self}") raise e - def set_title(self, title: str): + 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, @@ -261,17 +264,17 @@ def set_title(self, title: str): warnings.warn(f"{self._session} may not be authenticated to edit {self}") raise e - def add_studio(self, name: str, description: str = ''): + 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}, + json={ + "classroom_id": str(self.id), + "classroom_token": self.classtoken, + "title": name, + "description": description}, headers=self._headers, cookies=self._cookies) - def reopen(self): + 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, @@ -279,23 +282,25 @@ def reopen(self): try: response.json() + except Exception as e: warnings.warn(f"{self._session} may not be authenticated to edit {self}") raise e - def close(self): + 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): + 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) @@ -335,9 +340,9 @@ def public_activity(self, *, limit=20): return activities - def activity(self, student: str = "all", mode: str = "Last created", page: int = None): + def activity(self, student: str = "all", mode: str = "Last created", page: int = None) -> list[dict[str, Any]]: """ - Get a list of actvity raw dictionaries. However, they are in a very annoying format. This method should be updated + Get a list of activity raw dictionaries. However, they are in a very annoying format. This method should be updated """ self._check_session() @@ -351,7 +356,7 @@ def activity(self, student: str = "all", mode: str = "Last created", page: int = return data -def get_classroom(class_id) -> Classroom: +def get_classroom(class_id: str) -> Classroom: """ Gets a class without logging in. @@ -390,7 +395,7 @@ def get_classroom_from_token(class_token) -> Classroom: 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): + gender: str, country: str, is_robot: bool = False) -> None: data = {"classroom_id": class_id, "classroom_token": class_token, 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..3a1256c3 100644 --- a/scratchattach/site/comment.py +++ b/scratchattach/site/comment.py @@ -1,27 +1,23 @@ """Comment class""" +from __future__ import annotations import json import re -from ..utils import commons +from ..utils import commons, exceptions +from ..utils.commons import headers +from ..utils.requests import Requests as requests -from . import user -from . import session -from . import project -from . import studio -from . import forum -from ..utils import exceptions +from . import user, session, project, studio, forum from ._base import BaseSiteComponent -from ..utils.commons import headers from bs4 import BeautifulSoup -from ..utils.requests import Requests as requests -class Comment(BaseSiteComponent): - ''' +class Comment(BaseSiteComponent): + """ Represents a Scratch comment (on a profile, studio or project) - ''' + """ def str(self): return str(self.content) @@ -36,12 +32,13 @@ def __init__(self, **entries): 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 @@ -73,10 +70,10 @@ def _update_from_dict(self, data): # 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,35 +86,43 @@ 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": + + 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": + + 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": + + 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": + + 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 @@ -160,7 +165,6 @@ def reply(self, content, *, commentee_id=None): 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) - def delete(self): """ Deletes the comment. @@ -168,11 +172,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 +188,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": + + elif self.source == "studio": studio.Studio(id=self.source_id, _session=self._session).report_comment(comment_id=self.id) \ No newline at end of file diff --git a/scratchattach/site/forum.py b/scratchattach/site/forum.py index 2f7cde56..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 diff --git a/scratchattach/site/project.py b/scratchattach/site/project.py index 065fff73..0cc33e7b 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 diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index 16ab37ac..fd9581ba 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -1,4 +1,5 @@ """Session class and login function""" +from __future__ import annotations import base64 import datetime @@ -15,14 +16,9 @@ from bs4 import BeautifulSoup -from . import activity -from . import classroom -from . import forum -from . import studio -from . import user, project, backpack_asset -from ._base import BaseSiteComponent +from . import activity, classroom, forum, studio, user, project, backpack_asset # noinspection PyProtectedMember -# Pycharm doesn't like that you are importing a protected member '_base' +from ._base import BaseSiteComponent from ..cloud import cloud, _base from ..eventhandlers import message_events, filterbot from ..other import project_json_capabilities @@ -36,7 +32,6 @@ CREATE_CLASS_USES = [] - class Session(BaseSiteComponent): """ Represents a Scratch log in / session. Stores authentication data (session id and xtoken). @@ -51,8 +46,8 @@ class Session(BaseSiteComponent): banned: Returns True if the associated account is banned """ - def __str__(self): - return f"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: @@ -113,7 +108,7 @@ def _update_from_dict(self, data: dict): f"Some features may not work properly.") return True - def connect_linked_user(self) -> 'user.User': + def connect_linked_user(self) -> user.User: """ Gets the user associated with the login / session. @@ -191,7 +186,7 @@ def logout(self): requests.post("https://scratch.mit.edu/accounts/logout/", headers=self._headers, cookies=self._cookies) - def messages(self, *, limit: int = 40, offset: int = 0, date_limit=None, filter_by=None) -> 'activity.Activity': + def messages(self, *, limit: int = 40, offset: int = 0, date_limit=None, filter_by=None) -> list[activity.Activity]: """ Returns the messages. @@ -223,7 +218,8 @@ def admin_messages(self, *, limit=40, offset=0) -> list[dict]: 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): + def classroom_alerts(self, _classroom: classroom.Classroom | int = None, mode: str = "Last created", + page: int = None): if isinstance(_classroom, classroom.Classroom): _classroom = _classroom.id @@ -266,7 +262,7 @@ def message_count(self) -> int: # Front-page-related stuff: - def feed(self, *, limit=20, offset=0, date_limit=None) -> list['activity.Activity']: + def feed(self, *, limit=20, offset=0, date_limit=None) -> list[activity.Activity]: """ Returns the "What's happening" section (frontpage). @@ -286,7 +282,7 @@ 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 - def loved_by_followed_users(self, *, limit=40, offset=0) -> list['project.Project']: + def loved_by_followed_users(self, *, limit=40, offset=0) -> list[project.Project]: """ Returns the "Projects loved by Scratchers I'm following" section (frontpage). @@ -302,7 +298,7 @@ def loved_by_followed_users(self, *, limit=40, offset=0) -> list['project.Projec """ 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) -> list['project.Project']: + 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). @@ -335,21 +331,21 @@ def in_followed_studios(self, *, limit=40, offset=0) -> list['project.Project']: return commons.parse_object_list(data, project.Project, self)""" # -- Project JSON editing capabilities --- - + # These are set to staticmethods right now, but they probably should not be @staticmethod - def connect_empty_project_pb() -> 'project_json_capabilities.ProjectBody': + def connect_empty_project_pb() -> project_json_capabilities.ProjectBody: pb = project_json_capabilities.ProjectBody() pb.from_json(empty_project_json) return pb @staticmethod - def connect_pb_from_dict(project_json: dict) -> 'project_json_capabilities.ProjectBody': + def connect_pb_from_dict(project_json: dict) -> project_json_capabilities.ProjectBody: pb = project_json_capabilities.ProjectBody() pb.from_json(project_json) return pb @staticmethod - def connect_pb_from_file(path_to_file) -> 'project_json_capabilities.ProjectBody': + 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 @@ -396,7 +392,7 @@ def upload_asset(self, asset_content, *, asset_id=None, file_ext=None): # --- Search --- def search_projects(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40, - offset: int = 0) -> list['project.Project']: + offset: int = 0) -> list[project.Project]: """ Uses the Scratch search to search projects. @@ -417,7 +413,7 @@ def search_projects(self, *, query: str = "", mode: str = "trending", language: return commons.parse_object_list(response, project.Project, self) def explore_projects(self, *, query: str = "*", mode: str = "trending", language: str = "en", limit: int = 40, - offset: int = 0) -> list['project.Project']: + offset: int = 0) -> list[project.Project]: """ Gets projects from the explore page. @@ -440,7 +436,7 @@ def explore_projects(self, *, query: str = "*", mode: str = "trending", language return commons.parse_object_list(response, project.Project, self) def search_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40, - offset: int = 0) -> list['studio.Studio']: + offset: int = 0) -> list[studio.Studio]: if not query: raise ValueError("The query can't be empty for search") response = commons.api_iterative( @@ -449,7 +445,7 @@ def search_studios(self, *, query: str = "", mode: str = "trending", language: s return commons.parse_object_list(response, studio.Studio, self) def explore_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40, - offset: int = 0) -> list['studio.Studio']: + offset: int = 0) -> list[studio.Studio]: if not query: raise ValueError("The query can't be empty for explore") response = commons.api_iterative( @@ -460,7 +456,7 @@ def explore_studios(self, *, query: str = "", mode: str = "trending", language: # --- Create project API --- def create_project(self, *, title: str = None, project_json: dict = empty_project_json, - parent_id=None) -> 'project.Project': # not working + parent_id=None) -> project.Project: # not working """ Creates a project on the Scratch website. @@ -495,7 +491,7 @@ def create_project(self, *, title: str = None, project_json: dict = empty_projec headers=self._headers, json=project_json).json() return self.connect_project(response["content-name"]) - def create_studio(self, *, title=None, description: str = None): + def create_studio(self, *, title: str = None, description: str = None) -> studio.Studio: """ Create a studio on the scratch website @@ -533,7 +529,7 @@ def create_studio(self, *, title=None, description: str = None): return new_studio - def create_class(self, title: str, desc: str = ''): + def create_class(self, title: str, desc: str = '') -> classroom.Classroom: """ Create a class on the scratch website @@ -555,7 +551,6 @@ def create_class(self, title: str, desc: str = ''): "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") @@ -569,7 +564,7 @@ def create_class(self, title: str, desc: str = ''): # --- My stuff page --- def mystuff_projects(self, filter_arg: str = "all", *, page: int = 1, sort_by: str = '', descending: bool = True) \ - -> list['project.Project']: + -> list[project.Project]: """ Gets the projects from the "My stuff" page. @@ -618,7 +613,7 @@ def mystuff_projects(self, filter_arg: str = "all", *, page: int = 1, sort_by: s raise exceptions.FetchError() def mystuff_studios(self, filter_arg: str = "all", *, page: int = 1, sort_by: str = "", descending: bool = True) \ - -> list['studio.Studio']: + -> list[studio.Studio]: if descending: ascsort = "" descsort = sort_by @@ -627,11 +622,11 @@ def mystuff_studios(self, filter_arg: str = "all", *, page: int = 1, sort_by: st descsort = "" try: targets = requests.get( - f"https://scratch.mit.edu/site-api/galleries/{filter_arg}/" - f"?page={page}&ascsort={ascsort}&descsort={descsort}", + f"https://scratch.mit.edu/site-api/galleries/{filter_arg}/", + params={"page": page, "ascsort": ascsort, "descsort": descsort}, headers=headers, cookies=self._cookies, - timeout=10, + timeout=10 ).json() studios = [] for target in targets: @@ -697,12 +692,12 @@ def mystuff_ended_classes(self, mode: str = "Last created", page: int = None) -> _session=self)) return classes - def backpack(self, limit: int = 20, offset: int = 0) -> list[dict]: + 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}", @@ -710,7 +705,7 @@ def backpack(self, limit: int = 20, offset: int = 0) -> list[dict]: ) return commons.parse_object_list(data, backpack_asset.BackpackAsset, self) - def delete_from_backpack(self, backpack_asset_id) -> 'backpack_asset.BackpackAsset': + def delete_from_backpack(self, backpack_asset_id) -> backpack_asset.BackpackAsset: """ Deletes an asset from the backpack. @@ -746,7 +741,7 @@ 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. @@ -754,7 +749,7 @@ def connect_scratch_cloud(self, project_id) -> 'cloud.ScratchCloud': 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': + cloud_host="wss://clouddata.turbowarp.org") -> cloud.TwCloud: """ Returns: scratchattach.cloud.TwCloud: An object representing the TurboWarp cloud of a project. @@ -780,7 +775,7 @@ def _make_linked_object(self, identificator_name, identificator, Class: BaseSite # _get_object is protected return commons._get_object(identificator_name, identificator, Class, NotFoundException, self) - def connect_user(self, username: str) -> '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 @@ -792,7 +787,7 @@ def connect_user(self, username: str) -> 'user.User': """ return self._make_linked_object("username", username, user.User, exceptions.UserNotFound) - def find_username_from_id(self, user_id: int): + 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. @@ -818,7 +813,7 @@ 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': + 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 @@ -828,7 +823,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 @@ -839,7 +834,7 @@ def connect_user_by_id(self, user_id: int) -> 'user.User': 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 @@ -851,7 +846,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 @@ -863,7 +858,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. @@ -875,7 +870,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. @@ -888,7 +883,7 @@ def connect_classroom_from_token(self, class_token) -> 'classroom.Classroom': 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. @@ -957,12 +952,12 @@ def connect_topic_list(self, category_id, *, page=1): # --- 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) - 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) diff --git a/scratchattach/site/studio.py b/scratchattach/site/studio.py index 9c9e2861..80875b2d 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 @@ -138,7 +139,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 e2134bec..bc0880da 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 diff --git a/scratchattach/utils/commons.py b/scratchattach/utils/commons.py index f79f8188..33ef63d1 100644 --- a/scratchattach/utils/commons.py +++ b/scratchattach/utils/commons.py @@ -1,4 +1,6 @@ """v2 ready: Common functions used by various internal modules""" +from __future__ import annotations + from types import FunctionType from typing import Final, Any, TYPE_CHECKING @@ -128,7 +130,7 @@ def fetch(off: int, lim: int): return api_data -def _get_object(identificator_name, identificator, Class, NotFoundException, session=None) -> 'BaseSiteComponent': +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 @@ -159,7 +161,7 @@ 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") -> list: +def parse_object_list(raw, Class, session=None, primary_key="id") -> list[BaseSiteComponent]: results = [] for raw_dict in raw: try: @@ -171,7 +173,7 @@ def parse_object_list(raw, Class, session=None, primary_key="id") -> list: return results -def get_class_sort_mode(mode: str): +def get_class_sort_mode(mode: str) -> tuple[str, str]: """ Returns the sort mode for the given mode for classes only """ 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 index 0fbc0ffc..67d4d914 100644 --- a/scratchattach/utils/enums.py +++ b/scratchattach/utils/enums.py @@ -2,6 +2,7 @@ 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 diff --git a/scratchattach/utils/exceptions.py b/scratchattach/utils/exceptions.py index 4b24a4b7..a8b7526c 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -1,4 +1,5 @@ # Authentication / Authorization: +from __future__ import annotations class Unauthenticated(Exception): """ diff --git a/scratchattach/utils/requests.py b/scratchattach/utils/requests.py index 35bb1be7..c9188aa6 100644 --- a/scratchattach/utils/requests.py +++ b/scratchattach/utils/requests.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import requests from . import exceptions From 0064dba1ca3f8568790c8d6038ba10c1037f9094 Mon Sep 17 00:00:00 2001 From: "." Date: Sat, 21 Dec 2024 14:06:21 +0000 Subject: [PATCH 082/101] reset email thing --- scratchattach/other/other_apis.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 76fa9a1c..383cc1c6 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -147,6 +147,13 @@ def scratch_team_members() -> dict: 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): From 2254b82cb35684841e2cbb40e5f334c4b19d8ef0 Mon Sep 17 00:00:00 2001 From: "." Date: Sat, 21 Dec 2024 14:08:21 +0000 Subject: [PATCH 083/101] remove email thing since i moved it --- scratchattach/other/other_apis.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 8ced2d13..d672d9c8 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -146,14 +146,6 @@ def scratch_team_members() -> dict: 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) From 9c2f6c4cc39c5c9b44a0c108cacb04882e685b28 Mon Sep 17 00:00:00 2001 From: "." Date: Sat, 21 Dec 2024 15:10:17 +0000 Subject: [PATCH 084/101] various basic scratchtools endpoints --- scratchattach/other/other_apis.py | 51 +++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 383cc1c6..e40097a4 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -2,11 +2,12 @@ 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 -from ..utils.enums import Languages, Language, TTSVoices, TTSVoice # --- Front page --- @@ -137,6 +138,52 @@ 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]: + data_list = requests.get("https://data.scratchtools.app/tutorials/").json() + return [ScratchToolsTutorial.from_json(data) for data in data_list] + # --- Misc --- # I'm not sure what to label this as def scratch_team_members() -> dict: @@ -147,6 +194,7 @@ def scratch_team_members() -> dict: 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, @@ -154,7 +202,6 @@ def send_password_reset_email(username: str = None, email: str = None): }, 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) From 9b63c3ed431876ec785e483558a2d1e612536ff5 Mon Sep 17 00:00:00 2001 From: "." Date: Sat, 21 Dec 2024 15:56:11 +0000 Subject: [PATCH 085/101] get emoji status --- scratchattach/other/other_apis.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index e40097a4..6f4c4fc9 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -180,10 +180,20 @@ def from_json(cls, data: dict[str, str]) -> ScratchToolsTutorial: 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 + + # --- Misc --- # I'm not sure what to label this as def scratch_team_members() -> dict: From decbf695368cc6d80c709a743b70d21cf4a2d1af Mon Sep 17 00:00:00 2001 From: "." Date: Sat, 21 Dec 2024 17:19:17 +0000 Subject: [PATCH 086/101] get pinned comment --- scratchattach/other/other_apis.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 6f4c4fc9..2bc9ed03 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -194,6 +194,12 @@ def scratchtools_emoji_status(username: str) -> str | None: '🍪') # 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: From 0daab1400ee7308d4f621c06fa21887cd03f166e Mon Sep 17 00:00:00 2001 From: "." Date: Sat, 21 Dec 2024 18:27:00 +0000 Subject: [PATCH 087/101] ctrl alt l (reformat) --- scratchattach/site/comment.py | 110 ++++++++++++++++++++-------------- 1 file changed, 66 insertions(+), 44 deletions(-) diff --git a/scratchattach/site/comment.py b/scratchattach/site/comment.py index 3a1256c3..3c66ece8 100644 --- a/scratchattach/site/comment.py +++ b/scratchattach/site/comment.py @@ -1,17 +1,9 @@ """Comment class""" from __future__ import annotations -import json -import re - -from ..utils import commons, exceptions -from ..utils.commons import headers -from ..utils.requests import Requests as requests - -from . import user, session, project, studio, forum +from . import user, project, studio from ._base import BaseSiteComponent -from bs4 import BeautifulSoup - +from ..utils import exceptions class Comment(BaseSiteComponent): @@ -27,7 +19,7 @@ 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 @@ -41,31 +33,53 @@ def __init__(self, **entries): 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 @@ -94,7 +108,8 @@ def parent_comment(self) -> Comment | 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) + 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) @@ -102,18 +117,20 @@ def parent_comment(self) -> Comment | None: self.cached_parent_comment = p.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) + 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: bool=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 (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] + 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) @@ -121,10 +138,11 @@ def replies(self, *, use_cache: bool=True, limit=40, offset=0): self.cached_replies = p.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) + 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): @@ -157,13 +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): """ @@ -195,4 +217,4 @@ def report(self): p.report_comment(comment_id=self.id) elif self.source == "studio": - studio.Studio(id=self.source_id, _session=self._session).report_comment(comment_id=self.id) \ No newline at end of file + studio.Studio(id=self.source_id, _session=self._session).report_comment(comment_id=self.id) From 3cf71e783900de9950000679a37e48cc7869393f Mon Sep 17 00:00:00 2001 From: Tim <53166177+TimMcCool@users.noreply.github.com> Date: Sat, 21 Dec 2024 19:46:58 +0100 Subject: [PATCH 088/101] . --- website/source/index.html | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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.

- - +
From 722e1705d906c60a9575aa486cdd0059a7dd2f7c Mon Sep 17 00:00:00 2001 From: Tim <53166177+TimMcCool@users.noreply.github.com> Date: Sat, 21 Dec 2024 19:51:07 +0100 Subject: [PATCH 089/101] fixed small typo --- scratchattach/site/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index c13dc3a7..55cf7266 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -54,7 +54,7 @@ class Session(BaseSiteComponent): ''' def __str__(self): - return "Login for account: {self.username}" + return f"Login for account: {self.username}" def __init__(self, **entries): From bbd83e1456dd3c62e5b97a57ad8d5e784e0f058d Mon Sep 17 00:00:00 2001 From: Tim <53166177+TimMcCool@users.noreply.github.com> Date: Sun, 22 Dec 2024 14:13:10 +0100 Subject: [PATCH 090/101] . --- .gitignore | 1 + CHANGELOG.md | 5 +++++ setup.py | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) 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/setup.py b/setup.py index 3e3df4d7..0e5a4d51 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import codecs import os -VERSION = '2.1.6' +VERSION = '2.1.7' DESCRIPTION = 'A Scratch API Wrapper' LONG_DESCRIPTION = DESCRIPTION From 17ac00f2783f47e8a67e9bd4f352f786fade4503 Mon Sep 17 00:00:00 2001 From: Tim <53166177+TimMcCool@users.noreply.github.com> Date: Sun, 22 Dec 2024 14:21:21 +0100 Subject: [PATCH 091/101] . --- scratchattach/site/session.py | 1 + scratchattach/utils/requests.py | 5 +++-- setup.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index 55cf7266..5b278f86 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -839,6 +839,7 @@ def login(username, password, *, timeout=10) -> Session: request = requests.post( "https://scratch.mit.edu/login/", data=data, headers=_headers, timeout = timeout, + errorhandling = False ) try: session_id = str(re.search('"(.*)"', request.headers["Set-Cookie"]).group()) diff --git a/scratchattach/utils/requests.py b/scratchattach/utils/requests.py index 1c90a749..f772db04 100644 --- a/scratchattach/utils/requests.py +++ b/scratchattach/utils/requests.py @@ -31,12 +31,13 @@ def get(url, *, data=None, json=None, headers=None, cookies=None, timeout=None, 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, errorhandling=True): try: r = requests.post(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) + if errorhandling: + Requests.check_response(r) return r @staticmethod diff --git a/setup.py b/setup.py index 0e5a4d51..12ee2976 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import codecs import os -VERSION = '2.1.7' +VERSION = '2.1.8' DESCRIPTION = 'A Scratch API Wrapper' LONG_DESCRIPTION = DESCRIPTION From 52853ce9ed58d02966db4d3e34b27afa78750290 Mon Sep 17 00:00:00 2001 From: Tim <53166177+TimMcCool@users.noreply.github.com> Date: Sun, 22 Dec 2024 14:25:12 +0100 Subject: [PATCH 092/101] . --- scratchattach/cloud/_base.py | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scratchattach/cloud/_base.py b/scratchattach/cloud/_base.py index f3076ed7..1bf4a98e 100644 --- a/scratchattach/cloud/_base.py +++ b/scratchattach/cloud/_base.py @@ -48,10 +48,10 @@ class BaseCloud(ABC): :self.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 diff --git a/setup.py b/setup.py index 12ee2976..cc2d7469 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import codecs import os -VERSION = '2.1.8' +VERSION = '2.1.9' DESCRIPTION = 'A Scratch API Wrapper' LONG_DESCRIPTION = DESCRIPTION From c0f2ee8dfdd569d5dcdb2c78e40b012ce55382b4 Mon Sep 17 00:00:00 2001 From: "." Date: Mon, 23 Dec 2024 11:28:07 +0000 Subject: [PATCH 093/101] actually parse private class activity (analyzed html/js to do so) --- scratchattach/site/activity.py | 8 + scratchattach/site/classroom.py | 286 +++++++++++++++++++++++++++++++- scratchattach/site/user.py | 4 +- 3 files changed, 295 insertions(+), 3 deletions(-) diff --git a/scratchattach/site/activity.py b/scratchattach/site/activity.py index c53c97a8..ac348e22 100644 --- a/scratchattach/site/activity.py +++ b/scratchattach/site/activity.py @@ -15,6 +15,9 @@ 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) @@ -27,14 +30,19 @@ def __init__(self, **entries): # 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.time = None + self.type = None + # Update attributes from entries dict: self.__dict__.update(entries) diff --git a/scratchattach/site/classroom.py b/scratchattach/site/classroom.py index d23f5bd7..d4837095 100644 --- a/scratchattach/site/classroom.py +++ b/scratchattach/site/classroom.py @@ -353,7 +353,291 @@ def activity(self, student: str = "all", mode: str = "Last created", page: int = params={"page": page, "ascsort": ascsort, "descsort": descsort}, headers=self._headers, cookies=self._cookies).json() - return data + _activity = [] + for activity_json in data: + activity_type = activity_json["type"] + + time = activity_json["datetime_created"] if "datetime_created" in activity_json else None + + if "actor" in activity_json: + username = activity_json["actor"]["username"] + elif "actor_username" in activity_json: + username = activity_json["actor_username"] + else: + username = None + + if activity_json.get("recipient") is not None: + recipient_username = activity_json["recipient"]["username"] + + if activity_json.get("recipient_username") is not None: + recipient_username = activity_json["recipient_username"] + + elif activity_json.get("project_creator") is not None: + recipient_username = activity_json["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 = activity_json["followed_username"] + + raw = f"{username} followed user {followed_username}" + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="followuser", + + username=username, + followed_username=followed_username + )) + + elif activity_type == 1: + # follow studio + studio_id = activity_json["gallery"] + + raw = f"{username} followed studio https://scratch.mit.edu/studios/{studio_id}" + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="followstudio", + + username=username, + gallery_id=studio_id + )) + + elif activity_type == 2: + # love project + project_id = activity_json["project"] + + raw = f"{username} loved project https://scratch.mit.edu/projects/{project_id}" + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="loveproject", + + username=username, + project_id=project_id, + recipient_username=recipient_username + )) + + elif activity_type == 3: + # Favorite project + project_id = activity_json["project"] + + raw = f"{username} favorited project https://scratch.mit.edu/projects/{project_id}" + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="favoriteproject", + + username=username, + project_id=project_id, + recipient_username=recipient_username + )) + + elif activity_type == 7: + # Add project to studio + + project_id = activity_json["project"] + studio_id = activity_json["gallery"] + + raw = f"{username} added the project https://scratch.mit.edu/projects/{project_id} to studio https://scratch.mit.edu/studios/{studio_id}" + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="addprojecttostudio", + + username=username, + project_id=project_id, + 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 = activity_json["project"] + is_reshare = activity_json["is_reshare"] + + raw_reshare = "reshared" if is_reshare else "shared" + + raw = f"{username} {raw_reshare} the project https://scratch.mit.edu/projects/{project_id}" + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="shareproject", + + username=username, + project_id=project_id, + recipient_username=recipient_username + )) + + elif activity_type == 11: + # Remix + parent_id = activity_json["parent"] + + raw = f"{username} remixed the project https://scratch.mit.edu/projects/{parent_id}" + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="remixproject", + + username=username, + project_id=parent_id, + recipient_username=recipient_username + )) + + elif activity_type == 12: + default_case = True + + elif activity_type == 13: + # Create ('add') studio + studio_id = activity_json["gallery"] + + raw = f"{username} created the studio https://scratch.mit.edu/studios/{studio_id}" + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="createstudio", + + username=username, + gallery_id=studio_id + )) + + elif activity_type == 15: + # Update studio + studio_id = activity_json["gallery"] + + raw = f"{username} updated the studio https://scratch.mit.edu/studios/{studio_id}" + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="updatestudio", + + username=username, + 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 = activity_json["project"] + studio_id = activity_json["gallery"] + + raw = f"{username} removed the project https://scratch.mit.edu/projects/{project_id} from studio https://scratch.mit.edu/studios/{studio_id}" + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="removeprojectfromstudio", + + username=username, + 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 = activity_json["gallery"] + + raw = f"{recipient_username} was promoted to manager by {username} for studio https://scratch.mit.edu/studios/{studio_id}" + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="promotetomanager", + + username=username, + recipient_username=recipient_username, + 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" + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="updateprofile", + + username=username, + )) + + elif activity_type == 26: + default_case = True + + elif activity_type == 27: + # Comment (quite complicated) + comment_type: int = activity_json["comment_type"] + fragment = activity_json["comment_fragment"] + comment_id = activity_json["comment_id"] + comment_obj_id = activity_json["comment_obj_id"] + comment_obj_title = activity_json["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 + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="addcomment", + + username=username, + + comment_type=comment_type, + comment_obj_id=comment_obj_id, + comment_obj_title=comment_obj_title, + comment_id=comment_id, + )) + + if default_case: + # This is coded in the scratch HTML, haven't found an example of it though + raw = f"{username} performed an action" + + _activity.append(activity.Activity( + raw=raw, _session=self._session, time=time, + type="performaction", + + username=username + )) + + return _activity def get_classroom(class_id: str) -> Classroom: diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index bc0880da..e05b792f 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -545,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=""): """ @@ -711,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. From 220c7037c413a06e91cd04625d8c9c32fdac445f Mon Sep 17 00:00:00 2001 From: "." Date: Mon, 23 Dec 2024 12:11:43 +0000 Subject: [PATCH 094/101] moved json activity parser to activity method --- scratchattach/site/activity.py | 299 ++++++++++++++++++++++++++++++-- scratchattach/site/classroom.py | 287 +----------------------------- scratchattach/site/session.py | 1 + 3 files changed, 291 insertions(+), 296 deletions(-) diff --git a/scratchattach/site/activity.py b/scratchattach/site/activity.py index ac348e22..f0371ac8 100644 --- a/scratchattach/site/activity.py +++ b/scratchattach/site/activity.py @@ -1,13 +1,11 @@ """Activity and CloudActivity class""" from __future__ import annotations -from . import user, project, studio, forum, comment -from ..utils import exceptions -from ._base import BaseSiteComponent -from ..utils.commons import headers -from bs4 import BeautifulSoup +from bs4 import PageElement -from ..utils.requests import Requests as requests +from . import user, project, studio +from ._base import BaseSiteComponent +from ..utils import exceptions class Activity(BaseSiteComponent): @@ -55,7 +53,280 @@ def _update_from_dict(self, 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.time = _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.time = _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.time = _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.time = _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.time = _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.time = _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.time = _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.time = _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.time = _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.time = _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.time = _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.time = _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.time = _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.time = _time, + self.type = "performaction", + + self.username = username + + def _update_from_html(self, data: PageElement): self.raw = data @@ -104,13 +375,13 @@ 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 "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 "target_id" in self.__dict__: return self._make_linked_object("id", self.target_id, studio.Studio, exceptions.StudioNotFound) @@ -119,7 +390,7 @@ 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 "target_name" in self.__dict__: return self._make_linked_object("username", self.target_name, user.User, exceptions.UserNotFound) @@ -127,16 +398,16 @@ def target(self): 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 return self._make_linked_object("username", self.recipient_username, user.User, exceptions.UserNotFound) - + 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/classroom.py b/scratchattach/site/classroom.py index d4837095..6e7fb2b6 100644 --- a/scratchattach/site/classroom.py +++ b/scratchattach/site/classroom.py @@ -342,7 +342,9 @@ def public_activity(self, *, limit=20): def activity(self, student: str = "all", mode: str = "Last created", page: int = None) -> list[dict[str, Any]]: """ - Get a list of activity raw dictionaries. However, they are in a very annoying format. This method should be updated + 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() @@ -355,287 +357,8 @@ def activity(self, student: str = "all", mode: str = "Last created", page: int = _activity = [] for activity_json in data: - activity_type = activity_json["type"] - - time = activity_json["datetime_created"] if "datetime_created" in activity_json else None - - if "actor" in activity_json: - username = activity_json["actor"]["username"] - elif "actor_username" in activity_json: - username = activity_json["actor_username"] - else: - username = None - - if activity_json.get("recipient") is not None: - recipient_username = activity_json["recipient"]["username"] - - if activity_json.get("recipient_username") is not None: - recipient_username = activity_json["recipient_username"] - - elif activity_json.get("project_creator") is not None: - recipient_username = activity_json["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 = activity_json["followed_username"] - - raw = f"{username} followed user {followed_username}" - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="followuser", - - username=username, - followed_username=followed_username - )) - - elif activity_type == 1: - # follow studio - studio_id = activity_json["gallery"] - - raw = f"{username} followed studio https://scratch.mit.edu/studios/{studio_id}" - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="followstudio", - - username=username, - gallery_id=studio_id - )) - - elif activity_type == 2: - # love project - project_id = activity_json["project"] - - raw = f"{username} loved project https://scratch.mit.edu/projects/{project_id}" - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="loveproject", - - username=username, - project_id=project_id, - recipient_username=recipient_username - )) - - elif activity_type == 3: - # Favorite project - project_id = activity_json["project"] - - raw = f"{username} favorited project https://scratch.mit.edu/projects/{project_id}" - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="favoriteproject", - - username=username, - project_id=project_id, - recipient_username=recipient_username - )) - - elif activity_type == 7: - # Add project to studio - - project_id = activity_json["project"] - studio_id = activity_json["gallery"] - - raw = f"{username} added the project https://scratch.mit.edu/projects/{project_id} to studio https://scratch.mit.edu/studios/{studio_id}" - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="addprojecttostudio", - - username=username, - project_id=project_id, - 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 = activity_json["project"] - is_reshare = activity_json["is_reshare"] - - raw_reshare = "reshared" if is_reshare else "shared" - - raw = f"{username} {raw_reshare} the project https://scratch.mit.edu/projects/{project_id}" - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="shareproject", - - username=username, - project_id=project_id, - recipient_username=recipient_username - )) - - elif activity_type == 11: - # Remix - parent_id = activity_json["parent"] - - raw = f"{username} remixed the project https://scratch.mit.edu/projects/{parent_id}" - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="remixproject", - - username=username, - project_id=parent_id, - recipient_username=recipient_username - )) - - elif activity_type == 12: - default_case = True - - elif activity_type == 13: - # Create ('add') studio - studio_id = activity_json["gallery"] - - raw = f"{username} created the studio https://scratch.mit.edu/studios/{studio_id}" - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="createstudio", - - username=username, - gallery_id=studio_id - )) - - elif activity_type == 15: - # Update studio - studio_id = activity_json["gallery"] - - raw = f"{username} updated the studio https://scratch.mit.edu/studios/{studio_id}" - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="updatestudio", - - username=username, - 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 = activity_json["project"] - studio_id = activity_json["gallery"] - - raw = f"{username} removed the project https://scratch.mit.edu/projects/{project_id} from studio https://scratch.mit.edu/studios/{studio_id}" - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="removeprojectfromstudio", - - username=username, - 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 = activity_json["gallery"] - - raw = f"{recipient_username} was promoted to manager by {username} for studio https://scratch.mit.edu/studios/{studio_id}" - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="promotetomanager", - - username=username, - recipient_username=recipient_username, - 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" - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="updateprofile", - - username=username, - )) - - elif activity_type == 26: - default_case = True - - elif activity_type == 27: - # Comment (quite complicated) - comment_type: int = activity_json["comment_type"] - fragment = activity_json["comment_fragment"] - comment_id = activity_json["comment_id"] - comment_obj_id = activity_json["comment_obj_id"] - comment_obj_title = activity_json["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 - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="addcomment", - - username=username, - - comment_type=comment_type, - comment_obj_id=comment_obj_id, - comment_obj_title=comment_obj_title, - comment_id=comment_id, - )) - - if default_case: - # This is coded in the scratch HTML, haven't found an example of it though - raw = f"{username} performed an action" - - _activity.append(activity.Activity( - raw=raw, _session=self._session, time=time, - type="performaction", - - username=username - )) + _activity.append(activity.Activity(_session=self._session)) + _activity[-1]._update_from_json(activity_json) return _activity diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index fd9581ba..07fab67d 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -233,6 +233,7 @@ def classroom_alerts(self, _classroom: classroom.Classroom | int = None, mode: s 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): From f349826f07dc48c299d23bfe007ca2f3c0c22271 Mon Sep 17 00:00:00 2001 From: "." Date: Mon, 23 Dec 2024 12:14:57 +0000 Subject: [PATCH 095/101] using datetime_created because it is more applicable?? --- scratchattach/site/activity.py | 48 +++++++++++++++++----------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/scratchattach/site/activity.py b/scratchattach/site/activity.py index f0371ac8..e150adfd 100644 --- a/scratchattach/site/activity.py +++ b/scratchattach/site/activity.py @@ -87,7 +87,7 @@ def _update_from_json(self, data: dict): self.raw = f"{username} followed user {followed_username}" - self.time = _time + self.datetime_created = _time self.type = "followuser" self.username = username self.followed_username = followed_username @@ -99,7 +99,7 @@ def _update_from_json(self, data: dict): raw = f"{username} followed studio https://scratch.mit.edu/studios/{studio_id}" self.raw = raw - self.time = _time + self.datetime_created = _time self.type = "followstudio" self.username = username @@ -112,7 +112,7 @@ def _update_from_json(self, data: dict): raw = f"{username} loved project https://scratch.mit.edu/projects/{project_id}" self.raw = raw - self.time = _time, + self.datetime_created = _time, self.type = "loveproject" self.username = username @@ -126,7 +126,7 @@ def _update_from_json(self, data: dict): raw = f"{username} favorited project https://scratch.mit.edu/projects/{project_id}" self.raw = raw - self.time = _time + self.datetime_created = _time self.type = "favoriteproject" self.username = username @@ -142,7 +142,7 @@ def _update_from_json(self, data: dict): 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.time = _time + self.datetime_created = _time self.type = "addprojecttostudio" self.username = username @@ -165,7 +165,7 @@ def _update_from_json(self, data: dict): raw = f"{username} {raw_reshare} the project https://scratch.mit.edu/projects/{project_id}" self.raw = raw - self.time = _time + self.datetime_created = _time self.type = "shareproject" self.username = username @@ -179,7 +179,7 @@ def _update_from_json(self, data: dict): raw = f"{username} remixed the project https://scratch.mit.edu/projects/{parent_id}" self.raw = raw - self.time = _time + self.datetime_created = _time self.type = "remixproject" self.username = username @@ -196,7 +196,7 @@ def _update_from_json(self, data: dict): raw = f"{username} created the studio https://scratch.mit.edu/studios/{studio_id}" self.raw = raw - self.time = _time + self.datetime_created = _time self.type = "createstudio" self.username = username @@ -209,7 +209,7 @@ def _update_from_json(self, data: dict): raw = f"{username} updated the studio https://scratch.mit.edu/studios/{studio_id}" self.raw = raw - self.time = _time + self.datetime_created = _time self.type = "updatestudio" self.username = username @@ -233,7 +233,7 @@ def _update_from_json(self, data: dict): 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.time = _time + self.datetime_created = _time self.type = "removeprojectfromstudio" self.username = username @@ -252,7 +252,7 @@ def _update_from_json(self, data: dict): raw = f"{recipient_username} was promoted to manager by {username} for studio https://scratch.mit.edu/studios/{studio_id}" self.raw = raw - self.time = _time + self.datetime_created = _time self.type = "promotetomanager" self.username = username @@ -270,10 +270,10 @@ def _update_from_json(self, data: dict): raw = f"{username} made a profile update" self.raw = raw - self.time = _time, - self.type = "updateprofile", + self.datetime_created = _time + self.type = "updateprofile" - self.username = username, + self.username = username elif activity_type == 26: default_case = True @@ -303,15 +303,15 @@ def _update_from_json(self, data: dict): raw = f"{username} commented {fragment!r}" # This should never happen self.raw = raw - self.time = _time, - self.type = "addcomment", + self.datetime_created = _time + self.type = "addcomment" - self.username = username, + 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, + 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 @@ -321,8 +321,8 @@ def _update_from_json(self, data: dict): raw = f"{username} performed an action" self.raw = raw - self.time = _time, - self.type = "performaction", + self.datetime_created = _time + self.type = "performaction" self.username = username @@ -336,7 +336,7 @@ def _update_from_html(self, data: PageElement): while '\xa0' in _time: _time = _time.replace('\xa0', ' ') - self.time = _time + self.datetime_created = _time self.actor_username = data.find('div').find('span').text self.target_name = data.find('div').find('span').findNext().text From 66f37821876f666ccbae35d54f43bea1c033482c Mon Sep 17 00:00:00 2001 From: "." Date: Mon, 23 Dec 2024 12:15:33 +0000 Subject: [PATCH 096/101] add datetime_created as attribute for hinting --- scratchattach/site/activity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scratchattach/site/activity.py b/scratchattach/site/activity.py index e150adfd..6d90e7c3 100644 --- a/scratchattach/site/activity.py +++ b/scratchattach/site/activity.py @@ -38,6 +38,7 @@ def __init__(self, **entries): self.comment_obj_title = None self.comment_id = None + self.datetime_created = None self.time = None self.type = None From b0e429ef47caf26dbf53604aaf2813ed697f65c1 Mon Sep 17 00:00:00 2001 From: "." Date: Tue, 24 Dec 2024 15:50:50 +0000 Subject: [PATCH 097/101] folder support and some new find methods + a few type hints --- scratchattach/editor/asset.py | 10 +++++++++- scratchattach/editor/commons.py | 23 ++++++++++++++++++++++ scratchattach/editor/project.py | 18 +++++++++++++++++ scratchattach/editor/sprite.py | 35 +++++++++++++++++++++++++++++++-- scratchattach/site/studio.py | 4 +++- 5 files changed, 86 insertions(+), 4 deletions(-) diff --git a/scratchattach/editor/asset.py b/scratchattach/editor/asset.py index a6b164b2..30280e5c 100644 --- a/scratchattach/editor/asset.py +++ b/scratchattach/editor/asset.py @@ -55,7 +55,15 @@ def __init__(self, super().__init__(_sprite) def __repr__(self): - return f"Asset<{self.file_name}>" + 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): diff --git a/scratchattach/editor/commons.py b/scratchattach/editor/commons.py index f88a7bed..2341aa95 100644 --- a/scratchattach/editor/commons.py +++ b/scratchattach/editor/commons.py @@ -13,6 +13,8 @@ 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 @@ -209,6 +211,27 @@ def sanitize_fn(filename: str): 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 diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index b3374c69..029601c6 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -231,6 +231,24 @@ def find_vlb(self, value: str | None, by: str = "name", 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() diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index 940b4da6..5cfd1ec4 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -5,9 +5,10 @@ from io import BytesIO, TextIOWrapper from typing import Any, BinaryIO from zipfile import ZipFile -from typing import Iterable +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, @@ -192,6 +193,14 @@ def remove_block(self, _block: block.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]: """ @@ -318,6 +327,28 @@ def to_json(self) -> dict: 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() diff --git a/scratchattach/site/studio.py b/scratchattach/site/studio.py index 9c9e2861..fadbfc9f 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`). From 59a80083293cbf1820f8128aa03b50d221b476fc Mon Sep 17 00:00:00 2001 From: "." Date: Tue, 24 Dec 2024 16:41:26 +0000 Subject: [PATCH 098/101] line break --- scratchattach/other/other_apis.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index d672d9c8..30e5de79 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -146,6 +146,7 @@ def scratch_team_members() -> dict: return json.loads(text) + 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) From 4ba9ed02192c9285fd0ee4b0c3bff6ba58f63cc5 Mon Sep 17 00:00:00 2001 From: "." Date: Tue, 24 Dec 2024 16:43:06 +0000 Subject: [PATCH 099/101] fix files param --- scratchattach/utils/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scratchattach/utils/requests.py b/scratchattach/utils/requests.py index 30bfd0cc..c015cfe6 100644 --- a/scratchattach/utils/requests.py +++ b/scratchattach/utils/requests.py @@ -35,7 +35,7 @@ def get(url, *, data=None, json=None, headers=None, cookies=None, timeout=None, return r @staticmethod - def post(url, *, data=None, json=None, headers=None, cookies=None, timeout=None, params=None, errorhandling=True): + 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, files=files) From e829df88dee9b2ad31357b72c6210f1b57786e01 Mon Sep 17 00:00:00 2001 From: "." Date: Tue, 24 Dec 2024 17:32:25 +0000 Subject: [PATCH 100/101] rename yesnt --- scratchattach/editor/block.py | 2 +- scratchattach/editor/blockshape.py | 28 +++++++++++++--------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index 90d7a946..fa43a8c2 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -115,7 +115,7 @@ 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.YESNT: + if _shape.is_cap is not blockshape.MUTATION_DEPENDENT: return _shape.is_attachable else: if self.mutation is None: diff --git a/scratchattach/editor/blockshape.py b/scratchattach/editor/blockshape.py index 875dc1a3..ebfa6614 100644 --- a/scratchattach/editor/blockshape.py +++ b/scratchattach/editor/blockshape.py @@ -11,15 +11,13 @@ from ..utils.enums import _EnumWrapper -class _Yesnt(commons.Singleton): - """I can't really tell you if yesn't means yes or no; is it true or false? It depends.""" - +class _MutationDependent(commons.Singleton): def __bool__(self): - raise TypeError("I can't really tell you if yesn't means yes or no; is it true or false? It depends.") + raise TypeError("Need mutation data to work out attribute value.") -YESNT: Final[_Yesnt] = _Yesnt() -"""Value used when neither True nor False is applicable (when it depends on other factors)""" +MUTATION_DEPENDENT: Final[_MutationDependent] = _MutationDependent() +"""Value used when mutation data is required to work out the attribute value""" @dataclass(init=True, repr=True) @@ -27,18 +25,18 @@ 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 | _Yesnt = False # Most blocks - e.g. move [10] steps - is_c_mouth: bool | _Yesnt = False # Has substack - e.g. repeat - is_cap: bool | _Yesnt = False # No next - e.g. forever - is_hat: bool | _Yesnt = False # No parent - e.g. when gf clicked - is_reporter: bool | _Yesnt = False # (reporter) - is_boolean: bool | _Yesnt = False # - is_menu: bool | _Yesnt = False # Shadow reporters, e.g. costume menu + 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 YESNT: + 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 @@ -129,7 +127,7 @@ class BlockShapes(_EnumWrapper): 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=YESNT, opcode="control_stop") + 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") From 3987c6f3220eeef13fcf5fd3251ff342a33c8afe Mon Sep 17 00:00:00 2001 From: TheCommCraft <79996518+TheCommCraft@users.noreply.github.com> Date: Wed, 1 Jan 2025 18:15:35 +0100 Subject: [PATCH 101/101] Update session.py Signed-off-by: TheCommCraft <79996518+TheCommCraft@users.noreply.github.com> --- scratchattach/site/session.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index 6788d175..cc02afd8 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -13,6 +13,10 @@ # import secrets # import zipfile from typing import Type +try: + from warnings import deprecated +except ImportError: + deprecated = lambda x: (lambda y: y) from bs4 import BeautifulSoup @@ -788,6 +792,7 @@ def connect_user(self, username: str) -> user.User: """ return self._make_linked_object("username", username, user.User, exceptions.UserNotFound) + @deprecated("Finding usernames by user ids has been fixed.") def find_username_from_id(self, user_id: int) -> str: """ Warning: @@ -814,6 +819,7 @@ def find_username_from_id(self, user_id: int) -> str: raise exceptions.UserNotFound() return username + @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