From 645606af5c543e848f8fc095ef4da8f9c95ef564 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Sat, 9 Nov 2024 20:02:02 +0000 Subject: [PATCH 01/39] 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 02/39] 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 03/39] 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 04/39] 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 05/39] 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 06/39] 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 6cb631f17eb1e7568f851b8862a039643bee3c09 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Tue, 12 Nov 2024 22:27:17 +0000 Subject: [PATCH 07/39] 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 f3ce6a27098fc4c4acaf4db79e8d0273e75c90f9 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 13 Nov 2024 17:53:04 +0000 Subject: [PATCH 08/39] 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 1a9e4d8b5e261f0b1678d573402ecca2a8d185be Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 13 Nov 2024 18:09:08 +0000 Subject: [PATCH 09/39] 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 10/39] 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 dbe7c321b93ab7ec043cb4d6d1cc7d2dee0e300d Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Mon, 18 Nov 2024 18:33:14 +0000 Subject: [PATCH 11/39] 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 12/39] 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 13/39] 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 14/39] 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 15/39] 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 16/39] 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 17/39] 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 18/39] 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 19/39] 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 20/39] 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 6ce4ff7c4781b0fd493b864f0aace3e0232fd815 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 18:43:00 +0000 Subject: [PATCH 21/39] 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 a6c6168caed23cf862ac6ec4e4c1e78c718e48e8 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 21:51:11 +0000 Subject: [PATCH 22/39] 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 7d71d969e5cf1ccc3e1a11ca6cfc330085bd7e45 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Wed, 20 Nov 2024 22:08:47 +0000 Subject: [PATCH 23/39] 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 24c7f1fb754be616a74df8b7b3e65b7503e87e06 Mon Sep 17 00:00:00 2001 From: FA ReTek Date: Thu, 21 Nov 2024 17:06:56 +0000 Subject: [PATCH 24/39] 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 25/39] 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 26/39] 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 27/39] 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 0a03800cf46b97a1d3150e24a1fb7ebbd1d4fc8e Mon Sep 17 00:00:00 2001 From: "." Date: Sat, 21 Dec 2024 11:36:23 +0000 Subject: [PATCH 28/39] 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 29/39] 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 30/39] 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 9c2f6c4cc39c5c9b44a0c108cacb04882e685b28 Mon Sep 17 00:00:00 2001 From: "." Date: Sat, 21 Dec 2024 15:10:17 +0000 Subject: [PATCH 31/39] 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 32/39] 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 33/39] 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 34/39] 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 c0f2ee8dfdd569d5dcdb2c78e40b012ce55382b4 Mon Sep 17 00:00:00 2001 From: "." Date: Mon, 23 Dec 2024 11:28:07 +0000 Subject: [PATCH 35/39] 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 36/39] 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 37/39] 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 38/39] 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 4ba9ed02192c9285fd0ee4b0c3bff6ba58f63cc5 Mon Sep 17 00:00:00 2001 From: "." Date: Tue, 24 Dec 2024 16:43:06 +0000 Subject: [PATCH 39/39] 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)