diff --git a/pyproject.toml b/pyproject.toml index 9ceac42b..ffe61d4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,14 +88,20 @@ select = [ "F63", "F7", "F82", -] # , "C901"] # uncomment to re-enable mccabe complexity - see https://github.com/TimMcCool/scratchattach/issues/566 + "C901", +] # uncomment to re-enable mccabe complexity - see https://github.com/TimMcCool/scratchattach/issues/566 [tool.ruff.lint.mccabe] -# max-complexity = 10 +max-complexity = 10 [tool.setuptools.packages.find] where = ["."] include = ["scratchattach*"] [dependency-groups] -dev = ["cryptography>=46.0.3", "pytest>=9.0.2", "python-dotenv>=1.2.2"] +dev = [ + "cryptography>=46.0.3", + "pytest>=9.0.2", + "python-dotenv>=1.2.2", + "ruff>=0.15.12", +] diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index f14ff463..c77fac62 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -22,12 +22,12 @@ def main(): ) # Using walrus operator & ifs for artificial indentation + # TODO: there may be a better way to do this if commands := parser.add_subparsers(dest="command"): commands.add_parser("profile", help="View your profile") commands.add_parser("sessions", help="View session list") if login := commands.add_parser("login", help="Login to Scratch"): - login.add_argument("--sessid", dest="sessid", nargs="?", default=False, const=True, - help="Login by session ID") + login.add_argument("--sessid", dest="sessid", nargs="?", default=False, const=True, help="Login by session ID") if group := commands.add_parser("group", help="View current session group"): if group_commands := group.add_subparsers(dest="group_command"): group_commands.add_parser("list", help="List all session groups") @@ -46,10 +46,13 @@ def main(): parser.add_argument("-U", "--username", dest="username", help="Name of user to look at") parser.add_argument("-P", "--project", dest="project_id", help="ID of project to look at") parser.add_argument("-S", "--studio", dest="studio_id", help="ID of studio to look at") - parser.add_argument("-L", "--session_name", dest="session_name", - help="Name of (registered) session/login to look at") + parser.add_argument("-L", "--session_name", dest="session_name", help="Name of (registered) session/login to look at") args = parser.parse_args(namespace=cli.ArgSpace()) + handle_args(args, parser) + + +def handle_args(args: cli.ArgSpace, parser: argparse.ArgumentParser): cli.ctx.args = args cli.ctx.parser = parser @@ -63,31 +66,36 @@ def main(): case "profile": cmd.profile() case None: - if args.username: - user = ctx.session.connect_user(args.username) - console.print(cli.try_get_img(user.icon, (30, 30))) - console.print(user) - return - if args.studio_id: - studio = ctx.session.connect_studio(args.studio_id) - console.print(cli.try_get_img(studio.thumbnail, (34, 20))) - console.print(studio) - return - if args.project_id: - project = ctx.session.connect_project(args.project_id) - console.print(cli.try_get_img(project.thumbnail, (30, 23))) - console.print(project) - return - if args.session_name: - if sess := ctx.db_get_sess(args.session_name): - console.print(sess) - else: - raise ValueError(f"No session logged in called {args.session_name!r} " - f"- try using `scratch sessions` to see available sessions") - return - - parser.print_help() - - -if __name__ == '__main__': + handle_nocmd(args, parser) + + +def handle_nocmd(args: cli.ArgSpace, parser: argparse.ArgumentParser): + if args.username: + user = ctx.session.connect_user(args.username) + console.print(cli.try_get_img(user.icon, (30, 30))) + console.print(user) + return + if args.studio_id: + studio = ctx.session.connect_studio(args.studio_id) + console.print(cli.try_get_img(studio.thumbnail, (34, 20))) + console.print(studio) + return + if args.project_id: + project = ctx.session.connect_project(args.project_id) + console.print(cli.try_get_img(project.thumbnail, (30, 23))) + console.print(project) + return + if args.session_name: + if sess := ctx.db_get_sess(args.session_name): + console.print(sess) + else: + raise ValueError( + f"No session logged in called {args.session_name!r} - try using `scratch sessions` to see available sessions" + ) + return + + parser.print_help() + + +if __name__ == "__main__": main() diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index b28c5685..7c577fea 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -4,6 +4,7 @@ from rich.markup import escape from rich.table import Table + def _list(): table = Table(title="All groups") table.add_column("Name") @@ -15,40 +16,41 @@ def _list(): db.cursor.execute("SELECT USERNAME FROM GROUP_USERS WHERE GROUP_NAME=?", (name,)) usernames = db.cursor.fetchall() - table.add_row(escape(name), escape(description), - '\n'.join(f"{i}. {u}" for i, (u,) in enumerate(usernames))) + table.add_row(escape(name), escape(description), "\n".join(f"{i}. {u}" for i, (u,) in enumerate(usernames))) console.print(table) + def add(group_name: str): accounts = input("Add accounts (split by space): ").split() for account in accounts: ctx.db_add_to_group(group_name, account) + def remove(group_name: str): accounts = input("Remove accounts (split by space): ").split() for account in accounts: ctx.db_remove_from_group(group_name, account) + def new(): console.rule(f"New group {escape(ctx.args.group_name)}") if ctx.db_group_exists(ctx.args.group_name): raise ValueError(f"Group {escape(ctx.args.group_name)} already exists") db.conn.execute("BEGIN") - db.cursor.execute("INSERT INTO GROUPS (NAME, DESCRIPTION) " - "VALUES (?, ?)", (ctx.args.group_name, input("Description: "))) + db.cursor.execute("INSERT INTO GROUPS (NAME, DESCRIPTION) VALUES (?, ?)", (ctx.args.group_name, input("Description: "))) db.conn.commit() add(ctx.args.group_name) _group(ctx.args.group_name) + def _group(group_name: str): """ Display information about a group """ - db.cursor.execute( - "SELECT NAME, DESCRIPTION FROM GROUPS WHERE NAME = ?", (group_name,)) + db.cursor.execute("SELECT NAME, DESCRIPTION FROM GROUPS WHERE NAME = ?", (group_name,)) result = db.cursor.fetchone() if result is None: print("No group selected!!") @@ -61,13 +63,13 @@ def _group(group_name: str): table = Table(title=escape(group_name)) table.add_column(escape(name)) - table.add_column('Usernames') + table.add_column("Usernames") - table.add_row(escape(description), - '\n'.join(f"{i}. {u}" for i, u in enumerate(usernames))) + table.add_row(escape(description), "\n".join(f"{i}. {u}" for i, u in enumerate(usernames))) console.print(table) + def switch(): console.rule(f"Switching to {escape(ctx.args.group_name)}") if not ctx.db_group_exists(ctx.args.group_name): @@ -76,6 +78,7 @@ def switch(): ctx.current_group_name = ctx.args.group_name _group(ctx.current_group_name) + def delete(group_name: str): print(f"Deleting {group_name}") if not ctx.db_group_exists(group_name): @@ -85,6 +88,7 @@ def delete(group_name: str): ctx.db_group_delete(group_name) + def copy(group_name: str, new_name: str): print(f"Copying {group_name} as {new_name}") if not ctx.db_group_exists(group_name): @@ -92,10 +96,22 @@ def copy(group_name: str, new_name: str): ctx.db_group_copy(group_name, new_name) + def rename(group_name: str, new_name: str): copy(group_name, new_name) delete(group_name) + +def delete_cmd(): + if input("Are you sure? (y/N): ").lower() != "y": + return + delete(ctx.current_group_name) + new_current = ctx.db_first_group_name + print(f"Switching to {new_current}") + ctx.current_group_name = new_current + _group(new_current) + + def group(): match ctx.args.group_command: case "list": @@ -109,14 +125,7 @@ def group(): case "remove": remove(ctx.current_group_name) case "delete": - if input("Are you sure? (y/N): ").lower() != "y": - return - delete(ctx.current_group_name) - new_current = ctx.db_first_group_name - print(f"Switching to {new_current}") - ctx.current_group_name = new_current - _group(new_current) - + delete_cmd() case "copy": copy(ctx.current_group_name, ctx.args.group_name) case "rename": diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index 7d61f184..9902fc3d 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -3,6 +3,7 @@ Holds objects that should be available for the whole CLI system Also provides wrappers for some SQL info. """ + import argparse from dataclasses import dataclass, field @@ -54,9 +55,8 @@ def session(self): @property def current_group_name(self): - return db.cursor \ - .execute("SELECT * FROM CURRENT WHERE GROUP_NAME IS NOT NULL") \ - .fetchone()[0] + # FIXME: raises error when there are no groups + return db.cursor.execute("SELECT * FROM CURRENT WHERE GROUP_NAME IS NOT NULL").fetchone()[0] @current_group_name.setter def current_group_name(self, value: str): @@ -75,14 +75,12 @@ def db_session_exists(name: str) -> bool: @staticmethod def db_users_in_group(name: str) -> list[str]: - return [i for (i,) in db.cursor.execute( - "SELECT USERNAME FROM GROUP_USERS WHERE GROUP_NAME = ?", (name,)).fetchall()] + return [i for (i,) in db.cursor.execute("SELECT USERNAME FROM GROUP_USERS WHERE GROUP_NAME = ?", (name,)).fetchall()] def db_remove_from_group(self, group_name: str, username: str): if username in self.db_users_in_group(group_name): db.conn.execute("BEGIN") - db.cursor.execute("DELETE FROM GROUP_USERS " - "WHERE USERNAME = ? AND GROUP_NAME = ?", (username, group_name)) + db.cursor.execute("DELETE FROM GROUP_USERS WHERE USERNAME = ? AND GROUP_NAME = ?", (username, group_name)) db.conn.commit() @staticmethod @@ -96,8 +94,7 @@ def db_add_to_group(self, group_name: str, username: str): if username in self.db_users_in_group(group_name) or not self.db_session_exists(username): return db.conn.execute("BEGIN") - db.cursor.execute("INSERT INTO GROUP_USERS (GROUP_NAME, USERNAME) " - "VALUES (?, ?)", (group_name, username)) + db.cursor.execute("INSERT INTO GROUP_USERS (GROUP_NAME, USERNAME) VALUES (?, ?)", (group_name, username)) db.conn.commit() @staticmethod @@ -113,11 +110,21 @@ def db_group_delete(group_name: str): def db_group_copy(group_name: str, copy_name: str): db.conn.execute("BEGIN") # copy group metadata - db.cursor.execute("INSERT INTO GROUPS (NAME, DESCRIPTION) " - "SELECT ?, DESCRIPTION FROM GROUPS WHERE NAME = ?", (copy_name, group_name,)) + db.cursor.execute( + "INSERT INTO GROUPS (NAME, DESCRIPTION) SELECT ?, DESCRIPTION FROM GROUPS WHERE NAME = ?", + ( + copy_name, + group_name, + ), + ) # copy sessions - db.cursor.execute("INSERT INTO GROUP_USERS (GROUP_NAME, USERNAME) " - "SELECT ?, USERNAME FROM GROUP_USERS WHERE GROUP_NAME = ?", (copy_name, group_name,)) + db.cursor.execute( + "INSERT INTO GROUP_USERS (GROUP_NAME, USERNAME) SELECT ?, USERNAME FROM GROUP_USERS WHERE GROUP_NAME = ?", + ( + copy_name, + group_name, + ), + ) db.conn.commit() diff --git a/scratchattach/editor/block.py b/scratchattach/editor/block.py index ed71deeb..304316c0 100644 --- a/scratchattach/editor/block.py +++ b/scratchattach/editor/block.py @@ -12,13 +12,27 @@ class Block(base.SpriteSubComponent): """ Represents a block in the scratch editor, as a subcomponent of a sprite. """ + _id: Optional[str] = None - def __init__(self, _opcode: str, _shadow: bool = False, _top_level: Optional[bool] = None, - _mutation: Optional[mutation.Mutation] = None, _fields: Optional[dict[str, field.Field]] = None, - _inputs: Optional[dict[str, inputs.Input]] = None, x: int = 0, y: int = 0, pos: Optional[tuple[int, int]] = None, - _next: Optional[Block] = None, _parent: Optional[Block] = None, - *, _next_id: Optional[str] = None, _parent_id: Optional[str] = None, _sprite: commons.SpriteInput = build_defaulting.SPRITE_DEFAULT): + def __init__( + self, + _opcode: str, + _shadow: bool = False, + _top_level: Optional[bool] = None, + _mutation: Optional[mutation.Mutation] = None, + _fields: Optional[dict[str, field.Field]] = None, + _inputs: Optional[dict[str, inputs.Input]] = None, + x: int = 0, + y: int = 0, + pos: Optional[tuple[int, int]] = None, + _next: Optional[Block] = None, + _parent: Optional[Block] = None, + *, + _next_id: Optional[str] = None, + _parent_id: Optional[str] = None, + _sprite: commons.SpriteInput = build_defaulting.SPRITE_DEFAULT, + ): # Defaulting for args if _fields is None: _fields = {} @@ -166,7 +180,7 @@ def id(self) -> str: # Let's just automatically assign ourselves an id self.sprite.add_block(self) return self._id - + @id.setter def id(self, value: str) -> None: self._id = value @@ -229,7 +243,7 @@ def previous_chain(self): Recursive getter method to get all previous blocks in the blockchain (until hitting a top-level block) """ # TODO: check if this hits the recursion limit - if self.parent is None: # TODO: use is_top_level? + if self.parent is None: # TODO: use is_top_level? return [self] return [self] + self.parent.previous_chain @@ -289,7 +303,7 @@ def category(self): """ Works out what category of block this is as a string, using the opcode. Does not perform validation """ - return self.opcode.split('_')[0] + return self.opcode.split("_")[0] @property def is_input(self): @@ -333,7 +347,7 @@ def comment(self) -> comment.Comment | None: return None @property - def turbowarp_block_opcode(self): + def turbowarp_block_opcode(self): # noqa: C901 """ :return: The 'opcode' if this is a turbowarp block: e.g. - log @@ -349,13 +363,13 @@ def turbowarp_block_opcode(self): if self.mutation: if self.mutation.proc_code: # \u200B is a zero-width space - if self.mutation.proc_code == "\u200B\u200Bbreakpoint\u200B\u200B": + if self.mutation.proc_code == "\u200b\u200bbreakpoint\u200b\u200b": return "breakpoint" - elif self.mutation.proc_code == "\u200B\u200Blog\u200B\u200B %s": + elif self.mutation.proc_code == "\u200b\u200blog\u200b\u200b %s": return "log" - elif self.mutation.proc_code == "\u200B\u200Berror\u200B\u200B %s": + elif self.mutation.proc_code == "\u200b\u200berror\u200b\u200b %s": return "error" - elif self.mutation.proc_code == "\u200B\u200Bwarn\u200B\u200B %s": + elif self.mutation.proc_code == "\u200b\u200bwarn\u200b\u200b %s": return "warn" elif self.opcode == "argument_reporter_boolean": @@ -414,8 +428,9 @@ def from_json(data: dict) -> Block: else: _mutation = None - return Block(_opcode, _shadow, _top_level, _mutation, _fields, _inputs, _x, _y, _next_id=_next_id, - _parent_id=_parent_id) + return Block( + _opcode, _shadow, _top_level, _mutation, _fields, _inputs, _x, _y, _next_id=_next_id, _parent_id=_parent_id + ) def to_json(self) -> dict: """ @@ -434,24 +449,28 @@ def to_json(self) -> dict: } _comment = self.comment if _comment: - commons.noneless_update(_json, { - "comment": _comment.id - }) + commons.noneless_update(_json, {"comment": _comment.id}) if self.is_top_level: - commons.noneless_update(_json, { - "x": self.x, - "y": self.y, - }) + commons.noneless_update( + _json, + { + "x": self.x, + "y": self.y, + }, + ) if self.mutation is not None: - commons.noneless_update(_json, { - "mutation": self.mutation.to_json(), - }) + commons.noneless_update( + _json, + { + "mutation": self.mutation.to_json(), + }, + ) return _json - def link_using_sprite(self, link_subs: bool = True): + def link_using_sprite(self, link_subs: bool = True): # noqa: C901 """ Link this block to various other blocks once the sprite has been assigned """ @@ -483,20 +502,18 @@ def link_using_sprite(self, link_subs: bool = True): if _type == field.Types.VARIABLE: # Create a new variable - new_value = vlb.Variable(commons.gen_id(), - _field.value) + new_value = vlb.Variable(commons.gen_id(), _field.value) elif _type == field.Types.LIST: # Create a list - new_value = vlb.List(commons.gen_id(), - _field.value) + new_value = vlb.List(commons.gen_id(), _field.value) elif _type == field.Types.BROADCAST: # Create a broadcast - new_value = vlb.Broadcast(commons.gen_id(), - _field.value) + new_value = vlb.Broadcast(commons.gen_id(), _field.value) else: # Something probably went wrong warnings.warn( - f"Could not find {_field.id!r} in {self.sprite}. Can't create a new {_type} so we gave a warning") + f"Could not find {_field.id!r} in {self.sprite}. Can't create a new {_type} so we gave a warning" + ) if new_value is not None: self.sprite.add_local_global(new_value) @@ -540,9 +557,7 @@ def attach_chain(self, *chain: Block) -> Block: return attaching_block def duplicate_chain(self) -> Block: - return self.bottom_level_block.attach_chain( - *map(Block.dcopy, self.attached_chain) - ) + return self.bottom_level_block.attach_chain(*map(Block.dcopy, self.attached_chain)) def slot_above(self, new: Block) -> Block: """ diff --git a/scratchattach/editor/mutation.py b/scratchattach/editor/mutation.py index ffb64581..92c3e87d 100644 --- a/scratchattach/editor/mutation.py +++ b/scratchattach/editor/mutation.py @@ -38,7 +38,7 @@ def default(self) -> str | None: if self.proc_str == "%b": return "false" elif self.proc_str == "%s": - return '' + return "" else: return None @@ -48,19 +48,16 @@ class ArgSettings(base.Base): """ Contains whether the ids, names, and defaults of arguments in a mutation are None or not - i.e. the configuration of the arguments """ + ids: bool names: bool defaults: bool def __int__(self): - return (int(self.ids) + - int(self.names) + - int(self.defaults)) + return int(self.ids) + int(self.names) + int(self.defaults) def __eq__(self, other): - return (self.ids == other.ids and - self.names == other.names and - self.defaults == other.defaults) + return self.ids == other.ids and self.names == other.names and self.defaults == other.defaults def __gt__(self, other): return int(self) > int(other) @@ -72,7 +69,7 @@ def __lt__(self, other): @dataclass class Argument(base.MutationSubComponent): name: str - default: str = '' + default: str = "" _type: Optional[ArgumentType] = None _id: str = None @@ -91,8 +88,9 @@ def index(self): def type(self) -> Optional[ArgumentType]: if not self._type: if not self.mutation: - raise ValueError(f"Cannot infer 'type' of {self} when there is no mutation attached. " - f"Consider providing a type manually.") + raise ValueError( + f"Cannot infer 'type' of {self} when there is no mutation attached. Consider providing a type manually." + ) i = 0 goal = self.index @@ -130,22 +128,22 @@ def parse_proc_code(_proc_code: str) -> Optional[list[str | ArgumentType]]: if _proc_code is None: return None - token = '' + token = "" tokens = [] - last_char = '' + last_char = "" for char in _proc_code: - if last_char == '%': + if last_char == "%": if char in "sb": # If we've hit an %s or %b token = token[:-1] # Clip the % sign off the token - if token.endswith(' '): + if token.endswith(" "): # A space is required before params, but this should not be part of the parsed output token = token[:-1] - if token != '': + if token != "": # Make sure not to append an empty token tokens.append(token) @@ -156,17 +154,18 @@ def parse_proc_code(_proc_code: str) -> Optional[list[str | ArgumentType]]: elif token == "%s": tokens.append(ArgTypes.NUMBER_OR_TEXT.value.dcopy()) - token = '' + token = "" continue token += char last_char = char - if token != '': + if token != "": tokens.append(token) return tokens + def construct_proccode(*components: ArgumentType | ArgTypes | Argument | str) -> str: """ Create a proccode from strings/ArgumentType enum members/Argument instances @@ -194,16 +193,24 @@ def construct_proccode(*components: ArgumentType | ArgTypes | Argument | str) -> else: raise TypeError(f"Unsupported component type: {type(comp)}") - result += ' ' + result += " " return result class Mutation(base.BlockSubComponent): - def __init__(self, _tag_name: str = "mutation", _children: Optional[list] = None, _proc_code: Optional[str] = None, - _is_warp: Optional[bool] = None, _arguments: Optional[list[Argument]] = None, _has_next: Optional[bool] = None, - _argument_settings: Optional[ArgSettings] = None, *, - _block: Optional[block.Block] = None): + def __init__( + self, + _tag_name: str = "mutation", + _children: Optional[list] = None, + _proc_code: Optional[str] = None, + _is_warp: Optional[bool] = None, + _arguments: Optional[list[Argument]] = None, + _has_next: Optional[bool] = None, + _argument_settings: Optional[ArgSettings] = None, + *, + _block: Optional[block.Block] = None, + ): """ Mutation for Control:stop block and procedures https://en.scratch-wiki.info/wiki/Scratch_File_Format#Mutations @@ -215,9 +222,7 @@ def __init__(self, _tag_name: str = "mutation", _children: Optional[list] = None if _argument_settings is None: if _arguments: _argument_settings = ArgSettings( - _arguments[0]._id is None, - _arguments[0].name is None, - _arguments[0].default is None + _arguments[0]._id is None, _arguments[0].name is None, _arguments[0].default is None ) else: _argument_settings = ArgSettings(False, False, False) @@ -263,9 +268,11 @@ def argument_defaults(self): @property def argument_settings(self) -> ArgSettings: - return ArgSettings(bool(commons.safe_get(self.argument_ids, 0)), - bool(commons.safe_get(self.argument_names, 0)), - bool(commons.safe_get(self.argument_defaults, 0))) + return ArgSettings( + bool(commons.safe_get(self.argument_ids, 0)), + bool(commons.safe_get(self.argument_names, 0)), + bool(commons.safe_get(self.argument_defaults, 0)), + ) @property def parsed_proc_code(self) -> list[str | ArgumentType] | None: @@ -275,7 +282,7 @@ def parsed_proc_code(self) -> list[str | ArgumentType] | None: return parse_proc_code(self.proc_code) @staticmethod - def from_json(data: dict) -> Mutation: + def from_json(data: dict) -> Mutation: # noqa: C901 assert isinstance(data, dict) _tag_name = data.get("tagName", "mutation") @@ -302,9 +309,9 @@ def from_json(data: dict) -> Mutation: if _argument_defaults is not None: assert isinstance(_argument_defaults, str) _argument_defaults = json.loads(_argument_defaults) - _argument_settings = ArgSettings(_argument_ids is not None, - _argument_names is not None, - _argument_defaults is not None) + _argument_settings = ArgSettings( + _argument_ids is not None, _argument_names is not None, _argument_defaults is not None + ) # control_stop attrs _has_next = data.get("hasnext") @@ -337,15 +344,17 @@ def to_json(self) -> dict | None: "tagName": self.tag_name, "children": self.children, } - commons.noneless_update(_json, { - "proccode": self.proc_code, - "warp": commons.dumps_ifnn(self.is_warp), - "argumentids": commons.dumps_ifnn(self.argument_ids), - "argumentnames": commons.dumps_ifnn(self.argument_names), - "argumentdefaults": commons.dumps_ifnn(self.argument_defaults), - - "hasNext": commons.dumps_ifnn(self.has_next) - }) + commons.noneless_update( + _json, + { + "proccode": self.proc_code, + "warp": commons.dumps_ifnn(self.is_warp), + "argumentids": commons.dumps_ifnn(self.argument_ids), + "argumentnames": commons.dumps_ifnn(self.argument_names), + "argumentdefaults": commons.dumps_ifnn(self.argument_defaults), + "hasNext": commons.dumps_ifnn(self.has_next), + }, + ) return _json @@ -370,12 +379,10 @@ def link_arguments(self): # We can still work out argument defaults from parsing the proc code if self.arguments[0].default is None: _parsed = self.parsed_proc_code - _arg_phs: Iterable[ArgumentType] = filter(lambda tkn: isinstance(tkn, ArgumentType), - _parsed) + _arg_phs: Iterable[ArgumentType] = filter(lambda tkn: isinstance(tkn, ArgumentType), _parsed) for i, _arg_ph in enumerate(_arg_phs): self.arguments[i].default = _arg_ph.default for _argument in self.arguments: _argument.mutation = self _argument.link_using_mutation() - diff --git a/scratchattach/editor/project.py b/scratchattach/editor/project.py index 3ab2336e..7fbff664 100644 --- a/scratchattach/editor/project.py +++ b/scratchattach/editor/project.py @@ -18,9 +18,18 @@ class Project(base.JSONExtractable): """ A Project (body). Represents the editor contents of a scratch project """ - def __init__(self, _name: Optional[str] = None, _meta: Optional[meta.Meta] = None, _extensions: Iterable[extension.Extension] = (), - _monitors: Iterable[monitor.Monitor] = (), _sprites: Iterable[sprite.Sprite] = (), *, - _asset_data: Optional[list[asset.AssetFile]] = None, _session: Optional[session.Session] = None): + + def __init__( + self, + _name: Optional[str] = None, + _meta: Optional[meta.Meta] = None, + _extensions: Iterable[extension.Extension] = (), + _monitors: Iterable[monitor.Monitor] = (), + _sprites: Iterable[sprite.Sprite] = (), + *, + _asset_data: Optional[list[asset.AssetFile]] = None, + _session: Optional[session.Session] = None, + ): # Defaulting for list parameters if _meta is None: _meta = meta.Meta() @@ -66,7 +75,7 @@ def __repr__(self): if self.name is not None: _ret += f"name={self.name}, " _ret += f"meta={self.meta}" - _ret += '>' + _ret += ">" return _ret @property @@ -143,7 +152,7 @@ def from_json(data: dict): return Project(None, _meta, _extensions, _monitors, _sprites) @staticmethod - def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: Optional[str] = None): + def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: Optional[str] = None): # noqa: C901 """ Load project JSON and assets from an .sb3 file/bytes/file path :return: Project name, asset data, json string @@ -166,8 +175,8 @@ def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = if _name is None and _dir_for_name is not None: # Remove any directory names and the file extension - _name = _dir_for_name.split('/')[-1] - _name = '.'.join(_name.split('.')[:-1]) + _name = _dir_for_name.split("/")[-1] + _name = ".".join(_name.split(".")[:-1]) asset_data = [] with data: @@ -183,15 +192,14 @@ def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = if load_assets: for filename in archive.namelist(): if filename != "project.json": - md5_hash = filename.split('.')[0] + md5_hash = filename.split(".")[0] - asset_data.append( - asset.AssetFile(filename, archive.read(filename), md5_hash) - ) + asset_data.append(asset.AssetFile(filename, archive.read(filename), md5_hash)) else: warnings.warn( - "Loading sb3 without loading assets. When exporting the project, there may be errors due to assets not being uploaded to the Scratch website") + "Loading sb3 without loading assets. When exporting the project, there may be errors due to assets not being uploaded to the Scratch website" + ) return _name, asset_data, json_str @@ -224,9 +232,9 @@ def from_id(project_id: int, _name: Optional[str] = None): # _proj.name = _name # return _proj - def find_vlb(self, value: str | None, by: str = "name", - multiple: bool = False) -> Optional[vlb.Variable | vlb.List | vlb.Broadcast | list[ - vlb.Variable | vlb.List | vlb.Broadcast]]: + def find_vlb( + self, value: str | None, by: str = "name", multiple: bool = False + ) -> Optional[vlb.Variable | vlb.List | vlb.Broadcast | list[vlb.Variable | vlb.List | vlb.Broadcast]]: _ret: list[vlb.Variable | vlb.List | vlb.Broadcast] = [] for _sprite in self.sprites: @@ -242,8 +250,7 @@ def find_vlb(self, value: str | None, by: str = "name", return None - def find_sprite(self, value: str | None, by: str = "name", - multiple: bool = False) -> sprite.Sprite | list[sprite.Sprite]: + def find_sprite(self, value: str | None, by: str = "name", multiple: bool = False) -> sprite.Sprite | list[sprite.Sprite]: _ret = [] for _sprite in self.sprites: if by == "name": @@ -276,7 +283,7 @@ def export(self, fp: str, *, auto_open: bool = False, export_as_zip: bool = True json.dump(data, json_file) if auto_open: - os.system(f"explorer.exe \"{fp}\"") + os.system(f'explorer.exe "{fp}"') def add_monitor(self, _monitor: monitor.Monitor) -> monitor.Monitor: """ @@ -287,7 +294,7 @@ def add_monitor(self, _monitor: monitor.Monitor) -> monitor.Monitor: self.monitors.append(_monitor) return _monitor - def obfuscate(self, *, goto_origin: bool=True) -> None: + def obfuscate(self, *, goto_origin: bool = True) -> None: # noqa: C901 """ Randomly set all the variable names etc. Do not upload this project to the scratch website, as it is against the community guidelines. @@ -296,7 +303,7 @@ def obfuscate(self, *, goto_origin: bool=True) -> None: chars = string.ascii_letters + string.digits + string.punctuation def b10_to_cbase(b10: int | float): - ret = '' + ret = "" new_base = len(chars) while b10 >= 1: ret = chars[int(b10 % new_base)] + ret @@ -373,7 +380,6 @@ def arg_get(name: str) -> str: assert isinstance(arg_name, str) _block.fields["VALUE"].value = arg_get(arg_name) - # print(argument_mappings) if goto_origin: diff --git a/scratchattach/editor/sprite.py b/scratchattach/editor/sprite.py index 14589e28..50a13ac4 100644 --- a/scratchattach/editor/sprite.py +++ b/scratchattach/editor/sprite.py @@ -7,27 +7,46 @@ from zipfile import ZipFile from typing import Iterable, TYPE_CHECKING from . import base, project, vlb, asset, comment, prim, block, commons, build_defaulting + if TYPE_CHECKING: from . import asset + class Sprite(base.ProjectSubcomponent, base.JSONExtractable): _local_globals: list[base.NamedIDComponent] asset_data: list[asset.AssetFile] - def __init__(self, is_stage: bool = False, name: str = '', _current_costume: int = 1, _layer_order: Optional[int] = None, - _volume: int = 100, - _broadcasts: Optional[list[vlb.Broadcast]] = None, - _variables: Optional[list[vlb.Variable]] = None, _lists: Optional[list[vlb.List]] = None, - _costumes: Optional[list[asset.Costume]] = None, _sounds: Optional[list[asset.Sound]] = None, - _comments: Optional[list[comment.Comment]] = None, _prims: Optional[dict[str, prim.Prim]] = None, - _blocks: Optional[dict[str, block.Block]] = None, - # Stage only: - _tempo: int | float = 60, _video_state: str = "off", _video_transparency: int | float = 50, - _text_to_speech_language: str = "en", _visible: bool = True, - # Sprite only: - _x: int | float = 0, _y: int | float = 0, _size: int | float = 100, _direction: int | float = 90, - _draggable: bool = False, _rotation_style: str = "all around", - - *, _project: Optional[project.Project] = None): + + def __init__( # noqa: C901 + self, + is_stage: bool = False, + name: str = "", + _current_costume: int = 1, + _layer_order: Optional[int] = None, + _volume: int = 100, + _broadcasts: Optional[list[vlb.Broadcast]] = None, + _variables: Optional[list[vlb.Variable]] = None, + _lists: Optional[list[vlb.List]] = None, + _costumes: Optional[list[asset.Costume]] = None, + _sounds: Optional[list[asset.Sound]] = None, + _comments: Optional[list[comment.Comment]] = None, + _prims: Optional[dict[str, prim.Prim]] = None, + _blocks: Optional[dict[str, block.Block]] = None, + # Stage only: + _tempo: int | float = 60, + _video_state: str = "off", + _video_transparency: int | float = 50, + _text_to_speech_language: str = "en", + _visible: bool = True, + # Sprite only: + _x: int | float = 0, + _y: int | float = 0, + _size: int | float = 100, + _direction: int | float = 90, + _draggable: bool = False, + _rotation_style: str = "all around", + *, + _project: Optional[project.Project] = None, + ): """ Represents a sprite or the stage (known internally as a Target) https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets @@ -296,13 +315,32 @@ def read_idcomponent(attr_name: str, cls: type[base.IDComponent]): _draggable = data.get("draggable", False) _rotation_style = data.get("rotationStyle", "all-around") - return Sprite(_is_stage, _name, _current_costume, _layer_order, _volume, _broadcasts, _variables, _lists, - _costumes, - _sounds, _comments, _prims, _blocks, - - _tempo, _video_state, _video_transparency, _text_to_speech_language, - _visible, _x, _y, _size, _direction, _draggable, _rotation_style - ) + return Sprite( + _is_stage, + _name, + _current_costume, + _layer_order, + _volume, + _broadcasts, + _variables, + _lists, + _costumes, + _sounds, + _comments, + _prims, + _blocks, + _tempo, + _video_state, + _video_transparency, + _text_to_speech_language, + _visible, + _x, + _y, + _size, + _direction, + _draggable, + _rotation_style, + ) def to_json(self) -> dict: _json = { @@ -311,41 +349,43 @@ def to_json(self) -> dict: "currentCostume": self.current_costume, "volume": self.volume, "layerOrder": self.layer_order, - "variables": {_variable.id: _variable.to_json() for _variable in self.variables}, "lists": {_list.id: _list.to_json() for _list in self.lists}, "broadcasts": {_broadcast.id: _broadcast.to_json() for _broadcast in self.broadcasts}, - "blocks": {_block_id: _block.to_json() for _block_id, _block in (self.blocks | self.prims).items()}, "comments": {_comment.id: _comment.to_json() for _comment in self.comments}, - "costumes": [_costume.to_json() for _costume in self.costumes], - "sounds": [_sound.to_json() for _sound in self.sounds] + "sounds": [_sound.to_json() for _sound in self.sounds], } if self.is_stage: - _json.update({ - "tempo": self.tempo, - "videoTransparency": self.video_transparency, - "videoState": self.video_state, - "textToSpeechLanguage": self.text_to_speech_language - }) + _json.update( + { + "tempo": self.tempo, + "videoTransparency": self.video_transparency, + "videoState": self.video_state, + "textToSpeechLanguage": self.text_to_speech_language, + } + ) else: - _json.update({ - "visible": self.visible, - - "x": self.x, "y": self.y, - "size": self.size, - "direction": self.direction, - - "draggable": self.draggable, - "rotationStyle": self.rotation_style - }) + _json.update( + { + "visible": self.visible, + "x": self.x, + "y": self.y, + "size": self.size, + "direction": self.direction, + "draggable": self.draggable, + "rotationStyle": self.rotation_style, + } + ) return _json # Finding/getting from list/dict attributes - def find_asset(self, value: str, by: str = "name", multiple: bool = False, a_type: Optional[type]=None) -> None | asset.Asset | asset.Sound | asset.Costume | list[asset.Asset | asset.Sound | asset.Costume]: + def find_asset( + self, value: str, by: str = "name", multiple: bool = False, a_type: Optional[type] = None + ) -> None | asset.Asset | asset.Sound | asset.Costume | list[asset.Asset | asset.Sound | asset.Costume]: if a_type is None: a_type = asset.Asset @@ -425,8 +465,9 @@ def find_list(self, value: str, by: str = "name", multiple: bool = False) -> Non return _ret return None - def find_broadcast(self, value: str, by: str = "name", multiple: bool = False) -> None | vlb.Broadcast | list[ - vlb.Broadcast]: + def find_broadcast( + self, value: str, by: str = "name", multiple: bool = False + ) -> None | vlb.Broadcast | list[vlb.Broadcast]: _ret = [] by = by.lower() for _broadcast in self.broadcasts + self._local_globals: @@ -454,13 +495,11 @@ def find_broadcast(self, value: str, by: str = "name", multiple: bool = False) - return _ret return None - def find_vlb(self, value: str, by: str = "name", - multiple: bool = False) -> vlb.Variable | vlb.List | vlb.Broadcast | list[ - vlb.Variable | vlb.List | vlb.Broadcast]: + def find_vlb( + self, value: str, by: str = "name", multiple: bool = False + ) -> vlb.Variable | vlb.List | vlb.Broadcast | list[vlb.Variable | vlb.List | vlb.Broadcast]: if multiple: - return self.find_variable(value, by, True) + \ - self.find_list(value, by, True) + \ - self.find_broadcast(value, by, True) + return self.find_variable(value, by, True) + self.find_list(value, by, True) + self.find_broadcast(value, by, True) else: _ret = self.find_variable(value, by) if _ret is not None: @@ -470,8 +509,9 @@ def find_vlb(self, value: str, by: str = "name", return _ret return self.find_broadcast(value, by) - def find_block(self, value: str | Any, by: str, multiple: bool = False) -> None | block.Block | prim.Prim | list[ - block.Block | prim.Prim]: + def find_block( # noqa: C901 + self, value: str | Any, by: str, multiple: bool = False + ) -> None | block.Block | prim.Prim | list[block.Block | prim.Prim]: _ret = [] by = by.lower() for _block_id, _block in (self.blocks | self.prims).items(): @@ -545,8 +585,9 @@ def all_ids(self): ret += list(iterator) return ret + @staticmethod - def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: Optional[str] = None): + def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: Optional[str] = None): # noqa: C901 _dir_for_name = None if _name is None: @@ -565,8 +606,8 @@ def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = if _name is None and _dir_for_name is not None: # Remove any directory names and the file extension - _name = _dir_for_name.split('/')[-1] - _name = '.'.join(_name.split('.')[:-1]) + _name = _dir_for_name.split("/")[-1] + _name = ".".join(_name.split(".")[:-1]) asset_data = [] with data: @@ -580,17 +621,15 @@ def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = # Also load assets if load_assets: - for filename in archive.namelist(): if filename != "sprite.json": - md5_hash = filename.split('.')[0] + md5_hash = filename.split(".")[0] - asset_data.append( - asset.AssetFile(filename, archive.read(filename), md5_hash) - ) + asset_data.append(asset.AssetFile(filename, archive.read(filename), md5_hash)) else: warnings.warn( - "Loading sb3 without loading assets. When exporting the project, there may be errors due to assets not being uploaded to the Scratch website") + "Loading sb3 without loading assets. When exporting the project, there may be errors due to assets not being uploaded to the Scratch website" + ) return _name, asset_data, json_str diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 5edd3d3f..d950f56f 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -9,7 +9,120 @@ from scratchattach.site.user import User from ._base import BaseEventHandler import traceback + + class TwCloudSocket(WebSocket): + server: TwCloudServer + + def handle_set(self, data: dict): + # cloud variable set received + # check if project_id is in whitelisted projects (if there's a list of whitelisted projects) + if self.server.whitelisted_projects is not None: + if data["project_id"] not in self.server.whitelisted_projects: + self.close(4002) + if self.server.log_var_sets: + print( + self.address[0] + ":" + str(self.address[1]), + "tried to set a var on non-whitelisted project and was disconnected, project:", + data["project_id"], + "user:", + data["user"], + ) + return + # check if value is valid + if not self.server._check_value(data["value"]): + if self.server.log_var_sets: + print(self.address[0] + ":" + str(self.address[1]), "sent an invalid var value") + return + # perform cloud var and forward to other players + if self.server.log_var_sets: + print( + self.address[0] + ":" + str(self.address[1]), + f"set {data['name']} to {data['value']}, project:", + str(data["project_id"]), + "user:", + data["user"], + ) + self.server.set_var(data["project_id"], data["name"], data["value"], user=data["user"], skip_forward=self) + send_to_clients = { + "method": "set", + "user": data["user"], + "project_id": data["project_id"], + "name": data["name"], + "value": data["value"], + "timestamp": round(time.time() * 1000), + "server": "scratchattach/2.0.0", + } + # raise event + _a = cloud_activity.CloudActivity(timestamp=time.time() * 1000) + data["name"] = data["name"].replace("☁ ", "") + _a._update_from_dict(send_to_clients) + self.server.call_event("on_set", [_a, self]) + + def handle_handshake(self, data: dict): + # check if handshake is valid + if not "user" in data: + print(self.address[0] + ":" + str(self.address[1]), "tried to handshake without providing a username") + self.close(4002) + return + if not "project_id" in data: + print(self.address[0] + ":" + str(self.address[1]), "tried to handshake without providing a project_id") + self.close(4002) + return + # check if project_id is in username is allowed + if self.server.allow_nonscratch_names is False: + if not User(username=data["user"]).does_exist(): + print( + self.address[0] + ":" + str(self.address[1]), + "tried to handshake using a username not existing on Scratch, project:", + data["project_id"], + "user:", + data["user"], + ) + self.close(4002) + return + # check if project_id is in whitelisted projects (if there's a list of whitelisted projects) + if self.server.whitelisted_projects is not None: + if str(data["project_id"]) not in self.server.whitelisted_projects: + self.close(4002) + print( + self.address[0] + ":" + str(self.address[1]), + "tried to handshake on a non-whitelisted project:", + data["project_id"], + "user:", + data["user"], + ) + return + # register handshake in users list (save username and project_id) + print( + self.address[0] + ":" + str(self.address[1]), + "handshaked, project:", + data["project_id"], + "user:", + data["user"], + ) + self.server.tw_clients[self.address]["username"] = data["user"] + self.server.tw_clients[self.address]["project_id"] = data["project_id"] + # send current cloud variable values to the user who handshaked + self.sendMessage( + "\n".join( + [ + json.dumps( + { + "method": "set", + "project_id": data["project_id"], + "name": "☁ " + varname, + "value": self.server.tw_variables[str(data["project_id"])][varname], + "server": "scratchattach/2.0.0", + } + ) + for varname in self.server.get_project_vars(str(data["project_id"])) + ] + ) + ) + self.sendMessage("This server uses @TimMcCool's scratchattach 2.0.0") + # raise event + self.server.call_event("on_handshake", [data["user"], data["project_id"], self]) def handleMessage(self): if not self.server.running: @@ -17,78 +130,20 @@ def handleMessage(self): try: if self.server.check_for_ip_ban(self): return - + data = json.loads(self.data) print(data) if data["method"] == "set": - # cloud variable set received - # check if project_id is in whitelisted projects (if there's a list of whitelisted projects) - if self.server.whitelisted_projects is not None: - if data["project_id"] not in self.server.whitelisted_projects: - self.close(4002) - if self.server.log_var_sets: - print(self.address[0]+":"+str(self.address[1]), "tried to set a var on non-whitelisted project and was disconnected, project:", data["project_id"], "user:",data["user"]) - return - # check if value is valid - if not self.server._check_value(data["value"]): - if self.server.log_var_sets: - print(self.address[0]+":"+str(self.address[1]), "sent an invalid var value") - return - # perform cloud var and forward to other players - if self.server.log_var_sets: - print(self.address[0]+":"+str(self.address[1]), f"set {data['name']} to {data['value']}, project:", str(data["project_id"]), "user:",data["user"]) - self.server.set_var(data["project_id"], data["name"], data["value"], user=data["user"], skip_forward=self) - send_to_clients = { - "method" : "set", "user" : data["user"], "project_id" : data["project_id"], "name" : data["name"], - "value" : data["value"], "timestamp" : round(time.time() * 1000), "server" : "scratchattach/2.0.0", - } - # raise event - _a = cloud_activity.CloudActivity(timestamp=time.time()*1000) - data["name"] = data["name"].replace("☁ ", "") - _a._update_from_dict(send_to_clients) - self.server.call_event("on_set", [_a, self]) - + self.handle_set(data) elif data["method"] == "handshake": - data = json.loads(self.data) - # check if handshake is valid - if not "user" in data: - print(self.address[0]+":"+str(self.address[1]), "tried to handshake without providing a username") - self.close(4002) - return - if not "project_id" in data: - print(self.address[0]+":"+str(self.address[1]), "tried to handshake without providing a project_id") - self.close(4002) - return - # check if project_id is in username is allowed - if self.server.allow_nonscratch_names is False: - if not User(username=data["user"]).does_exist(): - print(self.address[0]+":"+str(self.address[1]), "tried to handshake using a username not existing on Scratch, project:", data["project_id"], "user:",data["user"]) - self.close(4002) - return - # check if project_id is in whitelisted projects (if there's a list of whitelisted projects) - if self.server.whitelisted_projects is not None: - if str(data["project_id"]) not in self.server.whitelisted_projects: - self.close(4002) - print(self.address[0]+":"+str(self.address[1]), "tried to handshake on a non-whitelisted project:", data["project_id"], "user:",data["user"]) - return - # register handshake in users list (save username and project_id) - print(self.address[0]+":"+str(self.address[1]), "handshaked, project:", data["project_id"], "user:",data["user"]) - self.server.tw_clients[self.address]["username"] = data["user"] - self.server.tw_clients[self.address]["project_id"] = data["project_id"] - # send current cloud variable values to the user who handshaked - self.sendMessage("\n".join([ - json.dumps({ - "method" : "set", "project_id" : data["project_id"], "name" : "☁ "+varname, - "value" : self.server.tw_variables[str(data["project_id"])][varname], "server" : "scratchattach/2.0.0", - }) for varname in self.server.get_project_vars(str(data["project_id"]))]) - ) - self.sendMessage("This server uses @TimMcCool's scratchattach 2.0.0") - # raise event - self.server.call_event("on_handshake", [data["user"], data["project_id"], self]) - + self.handle_handshake(data) else: - print("Error:", self.address[0]+":"+str(self.address[1]), "sent a message without providing a valid method (set, handshake)") + print( + "Error:", + self.address[0] + ":" + str(self.address[1]), + "sent a message without providing a valid method (set, handshake)", + ) except Exception as e: print("Internal error in handleMessage:", e, traceback.format_exc()) @@ -100,8 +155,8 @@ def handleConnected(self): if self.server.check_for_ip_ban(self): return - print(self.address[0]+":"+str(self.address[1]), "connected") - self.server.tw_clients[self.address] = {"client":self, "username":None, "project_id":None} + print(self.address[0] + ":" + str(self.address[1]), "connected") + self.server.tw_clients[self.address] = {"client": self, "username": None, "project_id": None} # raise event self.server.call_event("on_connect", [self]) except Exception as e: @@ -113,137 +168,215 @@ def handleClose(self): try: if self.address in self.server.tw_clients: # raise event - self.server.call_event("on_disconnect", [self.server.tw_clients[self.address]["username"], self.server.tw_clients[self.address]["project_id"], self]) - print(self.address[0]+":"+str(self.address[1]), "disconnected") + self.server.call_event( + "on_disconnect", + [ + self.server.tw_clients[self.address]["username"], + self.server.tw_clients[self.address]["project_id"], + self, + ], + ) + print(self.address[0] + ":" + str(self.address[1]), "disconnected") except Exception as e: print("Internal error in handleClose:", e) -def init_cloud_server(hostname='127.0.0.1', port=8080, *, thread=True, length_limit=None, allow_non_numeric=True, whitelisted_projects=None, allow_nonscratch_names=True, blocked_ips=[], sync_players=True, log_var_sets=True): + +class TwCloudServer(SimpleWebSocketServer, BaseEventHandler): + def __init__( + self, + hostname, + *, + port, + websocketclass, + length_limit=None, + allow_non_numeric=True, + whitelisted_projects=None, + allow_nonscratch_names=True, + blocked_ips=None, + sync_players=True, + log_var_sets=True, + ): + if blocked_ips is None: + blocked_ips = [] + + SimpleWebSocketServer.__init__(self, hostname, port=port, websocketclass=websocketclass) + BaseEventHandler.__init__(self) + + self.running = False + self._events = {} # saves event functions called on cloud updates + + self.tw_clients = {} # saves connected clients + self.tw_variables = {} # holds cloud variable states + + self.hostname = hostname + self.port = port + + # server config + self.allow_non_numeric = allow_non_numeric + self.whitelisted_projects = whitelisted_projects + self.length_limit = length_limit + self.allow_nonscratch_names = allow_nonscratch_names + self.blocked_ips = blocked_ips + self.sync_players = sync_players + self.log_var_sets = log_var_sets + + def check_for_ip_ban(self, client): + if ( + client.address[0] in self.blocked_ips + or client.address[0] + ":" + str(client.address[1]) in self.blocked_ips + or client.address in self.blocked_ips + ): + client.sendMessage("You have been banned from this server") + client.close(4002) + print(client.address[0] + ":" + str(client.address[1]), "(IP-banned) was disconnected") + return True + return False + + def active_projects(self): + only_active = {} + for project_id in self.tw_variables: + if self.active_user_ips(project_id) != []: + only_active[project_id] = self.tw_variables[project_id] + return only_active + + def active_user_names(self, project_id): + return [self.tw_clients[user]["username"] for user in self.active_user_ips(project_id)] + + def active_user_ips(self, project_id): + return list(filter(lambda user: str(self.tw_clients[user]["project_id"]) == str(project_id), self.tw_clients)) + + def get_global_vars(self): + return self.tw_variables + + def get_project_vars(self, project_id): + project_id = str(project_id) + if project_id in self.tw_variables: + return self.tw_variables[project_id] + else: + return {} + + def get_var(self, project_id, var_name): + project_id = str(project_id) + var_name = var_name.replace("☁ ", "") + if project_id in self.tw_variables: + if var_name in self.tw_variables[project_id]: + return self.tw_variables[project_id][var_name] + else: + return None + else: + return None + + def set_global_vars(self, data): + for project_id in data: + self.set_project_vars(project_id, data[project_id]) + + def set_project_vars(self, project_id, data, *, user="@server"): + project_id = str(project_id) + self.tw_variables[project_id] = data + for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: + client.sendMessage( + "\n".join( + [ + json.dumps( + { + "method": "set", + "project_id": project_id, + "name": "☁ " + varname, + "value": data[varname], + "server": "scratchattach/2.0.0", + "timestamp": time.time() * 1000, + "user": user, + } + ) + for varname in data + ] + ) + ) + + def set_var(self, project_id, var_name, value, *, user="@server", skip_forward=None): + var_name = var_name.replace("☁ ", "") + project_id = str(project_id) + if project_id not in self.tw_variables: + self.tw_variables[project_id] = {} + self.tw_variables[project_id][var_name] = value + + if self.sync_players is True: + for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: + if client == skip_forward: + continue + client.sendMessage( + json.dumps( + { + "method": "set", + "project_id": project_id, + "name": "☁ " + var_name, + "value": value, + "timestamp": time.time() * 1000, + "user": user, + } + ) + ) + + def _check_value(self, value): + # Checks if a received cloud value satisfies the server's constraints + if self.length_limit is not None: + if len(str(value)) > self.length_limit: + return False + if self.allow_non_numeric is False: + x = value.replace(".", "") + x = x.replace("-", "") + if not (x.isnumeric() or x == ""): + return False + return True + + def _updater(self): + try: + # Function called when .start() is executed (.start is inherited from BaseEventHandler) + print(f"Serving websocket server: ws://{self.hostname}:{self.port}") + self.serveforever() + except Exception as e: + raise exceptions.WebsocketServerError(str(e)) + + def pause(self): + self.running = False + + def resume(self): + self.running = True + + def stop(self, wait_call_threads: bool = True): + BaseEventHandler.stop(self, wait_call_threads) + self.close() + + +def init_cloud_server( + hostname="127.0.0.1", + port=8080, + *, + length_limit=None, + allow_non_numeric=True, + whitelisted_projects=None, + allow_nonscratch_names=True, + blocked_ips=None, + sync_players=True, + log_var_sets=True, +): """ Inits a websocket server which can be used with TurboWarp's ?cloud_host URL parameter. - + Prints out the websocket address in the console. """ - class TwCloudServer(SimpleWebSocketServer, BaseEventHandler): - def __init__(self, hostname, *, port, websocketclass): - SimpleWebSocketServer.__init__(self, hostname, port=port, websocketclass=websocketclass) - BaseEventHandler.__init__(self) - - self.running = False - self._events = {} # saves event functions called on cloud updates - - self.tw_clients = {} # saves connected clients - self.tw_variables = {} # holds cloud variable states - - # server config - self.allow_non_numeric = allow_non_numeric - self.whitelisted_projects = whitelisted_projects - self.length_limit = length_limit - self.allow_nonscratch_names = allow_nonscratch_names - self.blocked_ips = blocked_ips - self.sync_players = sync_players - self.log_var_sets = log_var_sets - - def check_for_ip_ban(self, client): - if client.address[0] in self.blocked_ips or client.address[0]+":"+str(client.address[1]) in self.blocked_ips or client.address in self.blocked_ips: - client.sendMessage("You have been banned from this server") - client.close(4002) - print(client.address[0]+":"+str(client.address[1]), "(IP-banned) was disconnected") - return True - return False - - def active_projects(self): - only_active = {} - for project_id in self.tw_variables: - if self.active_user_ips(project_id) != []: - only_active[project_id] = self.tw_variables[project_id] - return only_active - - def active_user_names(self, project_id): - return [self.tw_clients[user]["username"] for user in self.active_user_ips(project_id)] - - def active_user_ips(self, project_id): - return list(filter(lambda user : str(self.tw_clients[user]["project_id"]) == str(project_id), self.tw_clients)) - - def get_global_vars(self): - return self.tw_variables - - def get_project_vars(self, project_id): - project_id = str(project_id) - if project_id in self.tw_variables: - return self.tw_variables[project_id] - else: return {} - - def get_var(self, project_id, var_name): - project_id = str(project_id) - var_name = var_name.replace("☁ ", "") - if project_id in self.tw_variables: - if var_name in self.tw_variables[project_id]: - return self.tw_variables[project_id][var_name] - else: return None - else: return None - - def set_global_vars(self, data): - for project_id in data: - self.set_project_vars(project_id, data[project_id]) - - def set_project_vars(self, project_id, data, *, user="@server"): - project_id = str(project_id) - self.tw_variables[project_id] = data - for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: - client.sendMessage("\n".join([ - json.dumps({ - "method" : "set", "project_id" : project_id, "name" : "☁ "+varname, - "value" : data[varname], "server" : "scratchattach/2.0.0", "timestamp" : time.time()*1000, "user" : user - }) for varname in data]) - ) + if blocked_ips is None: + blocked_ips = [] - def set_var(self, project_id, var_name, value, *, user="@server", skip_forward=None): - var_name = var_name.replace("☁ ", "") - project_id = str(project_id) - if project_id not in self.tw_variables: - self.tw_variables[project_id] = {} - self.tw_variables[project_id][var_name] = value - - if self.sync_players is True: - for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: - if client == skip_forward: - continue - client.sendMessage( - json.dumps({ - "method" : "set", "project_id" : project_id, "name" : "☁ "+var_name, - "value" : value, "timestamp" : time.time()*1000, "user" : user - }) - ) - - def _check_value(self, value): - # Checks if a received cloud value satisfies the server's constraints - if self.length_limit is not None: - if len(str(value)) > self.length_limit: - return False - if self.allow_non_numeric is False: - x = value.replace(".", "") - x = x.replace("-", "") - if not (x.isnumeric() or x == ""): - return False - return True - - def _updater(self): - try: - # Function called when .start() is executed (.start is inherited from BaseEventHandler) - print(f"Serving websocket server: ws://{hostname}:{port}") - self.serveforever() - except Exception as e: - raise exceptions.WebsocketServerError(str(e)) - - def pause(self): - self.running = False - - def resume(self): - self.running = True - - def stop(self): - self.running = False - self.close() - - return TwCloudServer(hostname, port=port, websocketclass=TwCloudSocket) + return TwCloudServer( + hostname, + port=port, + websocketclass=TwCloudSocket, + length_limit=length_limit, + allow_non_numeric=allow_non_numeric, + whitelisted_projects=whitelisted_projects, + allow_nonscratch_names=allow_nonscratch_names, + blocked_ips=blocked_ips, + sync_players=sync_players, + log_var_sets=log_var_sets, + ) diff --git a/scratchattach/eventhandlers/filterbot.py b/scratchattach/eventhandlers/filterbot.py index b91fccd9..76a8586f 100644 --- a/scratchattach/eventhandlers/filterbot.py +++ b/scratchattach/eventhandlers/filterbot.py @@ -1,12 +1,27 @@ """FilterBot class""" + from __future__ import annotations + +import requests as _requests + from .message_events import MessageEvents import time from collections import deque +from scratchattach.site import activity + class HardFilter: - - def __init__(self, filter_name="UntitledFilter", *, equals=None, contains=None, author_name=None, project_id=None, profile=None, case_sensitive=False): + def __init__( + self, + filter_name="UntitledFilter", + *, + equals=None, + contains=None, + author_name=None, + project_id=None, + profile=None, + case_sensitive=False, + ): self.equals = equals self.contains = contains self.author_name = author_name @@ -14,7 +29,7 @@ def __init__(self, filter_name="UntitledFilter", *, equals=None, contains=None, self.profile = profile self.case_sensitive = case_sensitive self.filter_name = filter_name - + def apply(self, content, author_name, source_id): text_to_check = content if self.case_sensitive else content.lower() if self.equals is not None: @@ -27,19 +42,59 @@ def apply(self, content, author_name, source_id): return True if self.author_name is not None and self.author_name == author_name: return True - if (self.project_id is not None and self.project_id == source_id) or \ - (self.profile is not None and self.profile == source_id): + if (self.project_id is not None and self.project_id == source_id) or ( + self.profile is not None and self.profile == source_id + ): return True return False + class SoftFilter(HardFilter): - def __init__(self, score:float, filter_name="UntitledFilter", *, equals=None, contains=None, author_name=None, project_id=None, profile=None, case_sensitive=False): + def __init__( + self, + score: float, + filter_name="UntitledFilter", + *, + equals=None, + contains=None, + author_name=None, + project_id=None, + profile=None, + case_sensitive=False, + ): self.score = score - super().__init__(filter_name, equals=equals, contains=contains, author_name=author_name, project_id=project_id, profile=profile, case_sensitive=case_sensitive) + super().__init__( + filter_name, + equals=equals, + contains=contains, + author_name=author_name, + project_id=project_id, + profile=profile, + case_sensitive=case_sensitive, + ) + class SpamFilter(HardFilter): - def __init__(self, filter_name="UntitledFilter", *, equals=None, contains=None, author_name=None, project_id=None, profile=None, case_sensitive=False): - super().__init__(filter_name, equals=equals, contains=contains, author_name=author_name, project_id=project_id, profile=profile, case_sensitive=case_sensitive) + def __init__( + self, + filter_name="UntitledFilter", + *, + equals=None, + contains=None, + author_name=None, + project_id=None, + profile=None, + case_sensitive=False, + ): + super().__init__( + filter_name, + equals=equals, + contains=contains, + author_name=author_name, + project_id=project_id, + profile=profile, + case_sensitive=case_sensitive, + ) self.memory = deque() self.retention_period = 300 @@ -47,7 +102,7 @@ def apply(self, content, author_name, source_id): if not super().apply(content, author_name, source_id): return False current_time = time.time() - + # Prune old entries from memory while self.memory and self.memory[-1]["time"] < current_time - self.retention_period: self.memory.pop() @@ -62,8 +117,8 @@ def apply(self, content, author_name, source_id): self.memory.appendleft({"content": content, "time": current_time}) return False -class Filterbot(MessageEvents): +class Filterbot(MessageEvents): # The Filterbot class is built upon MessageEvents, similar to how CloudEvents is built upon CloudEvents def __init__(self, user, *, log_deletions=True): @@ -105,59 +160,75 @@ def add_genalpha_nonsense_filter(self): self.add_filter(HardFilter("[genalpha_nonsene_filter) 'rizzler'", contains="rizzler")) self.add_filter(HardFilter("(genalpha_nonsene_filter) 'fanum tax'", contains="fanum tax")) - def on_message(self, message): - if message.type != "addcomment": - return - source_id = None + def _apply_filters(self, message: activity.Activity, source_id: int | str | None) -> tuple[bool, str]: + """ + Applies the filterbot filters and returns whether the message violates the filters, and why + returns tuple of whether to delete, and the reason + """ + assert message.type == activity.ActivityTypes.addcomment + content = message.comment_fragment - if message.comment_type == 0: # project comment - source_id = message.comment_obj_id - if self.user._session.connect_project(message.comment_obj_id).author_name != self.user.username: - return # no permission to delete comments that aren't on our own project - elif message.comment_type == 1: # profile comment - source_id = message.comment_obj_title - if source_id != self.user.username: - return # no permission to delete messages that are not on our profile - elif message.comment_type == 2: # studio comment - return # studio comments aren't handled - else: - return - delete = False - reason = "" # Apply hard filters for hard_filter in self.hard_filters: if hard_filter.apply(content, message.actor_username, source_id): - delete = True - reason = f"hard filter: {hard_filter.filter_name}" - break + return True, f"hard filter: {hard_filter.filter_name}" # Apply spam filters - if not delete: - for spam_filter in self.spam_filters: - if spam_filter.apply(content, message.actor_username, source_id): - delete = True - reason = f"spam filter: {spam_filter.filter_name}" - break + for spam_filter in self.spam_filters: + if spam_filter.apply(content, message.actor_username, source_id): + return True, f"spam filter: {spam_filter.filter_name}" # Apply soft filters - if not delete: - score = 0 - violated_filters = [] - for soft_filter in self.soft_filters: - if soft_filter.apply(content, message.actor_username, source_id): - score += soft_filter.score - violated_filters.append(soft_filter.filter_name) - if score >= 1: - delete = True - reason = f"too many soft filters: {violated_filters}" + score = 0 + violated_filters = [] + for soft_filter in self.soft_filters: + if soft_filter.apply(content, message.actor_username, source_id): + score += soft_filter.score + violated_filters.append(soft_filter.filter_name) + + if score >= 1: + return True, f"too many soft filters: {violated_filters}" + + return False, "" + + def _determine_source_and_deletable(self, message: activity.Activity) -> tuple[int | str | None, bool]: + if message.comment_type == 0: # project comment + project_id = message.comment_obj_id + if self.user._session.connect_project(message.comment_obj_id).author_name == self.user.username: + return project_id, True # only permission to delete comments that are on our own project + + elif message.comment_type == 1: # profile comment + if message.comment_obj_title == self.user.username: + return message.comment_obj_title, True # no permission to delete messages that are not on our profile + # elif message.comment_type == 2: # studio comment + # studio comments aren't handled + return None, False + + def _print_if_logging(self, *args, **kwargs): + if self.log_deletions: + print(*args, **kwargs) + + def on_message(self, message: activity.Activity): + if message.type != activity.ActivityTypes.addcomment: + return + + source_id, deletable = self._determine_source_and_deletable(message) + if not deletable: + return + + content = message.comment_fragment + delete, reason = self._apply_filters(message, source_id) + if delete: - if self.log_deletions: - print(f"DETECTED: #{message.comment_id} violates {reason}") + self._print_if_logging(f"DETECTED: #{message.comment_id} violates {reason}") try: - resp = message.target().delete() - if self.log_deletions: - print(f"DELETED: #{message.comment_id} by {message.actor_username!r}: '{content}' with message {resp.content!r} & headers {resp.headers!r}") + if target := message.target_comment(): + resp = target.delete() + assert isinstance(resp, _requests.Response) + self._print_if_logging( + f"DELETED: #{message.comment_id} by {message.actor_username!r}: '{content}' with message {resp.content!r} & headers {resp.headers!r}" + ) except Exception as e: if self.log_deletions: print(f"DELETION FAILED: #{message.comment_id} by {message.actor_username!r}: '{content}'; exception: {e}") diff --git a/scratchattach/eventhandlers/message_events.py b/scratchattach/eventhandlers/message_events.py index 1ffc794e..c1360000 100644 --- a/scratchattach/eventhandlers/message_events.py +++ b/scratchattach/eventhandlers/message_events.py @@ -1,14 +1,17 @@ """MessageEvents class""" + from __future__ import annotations from scratchattach.site import user from ._base import BaseEventHandler import time + class MessageEvents(BaseEventHandler): """ Class that calls events when you receive messages on your Scratch account. Data fetched from Scratch's API. """ + def __init__(self, user, *, update_interval=2): super().__init__() self.user = user @@ -20,7 +23,7 @@ def _updater(self): A process that listens for cloud activity and executes events on cloud activity """ self.current_message_count = int(self.user.message_count()) - + self.call_event("on_ready") while True: @@ -32,11 +35,11 @@ def _updater(self): if message_count != 0: if message_count < self.current_message_count: self.current_message_count = 0 - if self.user._session is not None: # authentication check - if self.user._session.username == self.user.username: # authorization check - new_messages = self.user._session.messages(limit=message_count-self.current_message_count) + if self.user._session is not None: # authentication check + if self.user._session.username == self.user.username: # authorization check + new_messages = self.user._session.messages(limit=message_count - self.current_message_count) for message in new_messages[::-1]: self.call_event("on_message", [message]) self.current_message_count = int(message_count) time.sleep(self.update_interval) - \ No newline at end of file + diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index 0dec8d94..8d915e38 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 from dataclasses import dataclass, field @@ -18,6 +19,7 @@ # --- Front page --- + class FeaturedDataRaw(TypedDict): community_newest_projects: list[dict[str, str | int]] community_most_remixed_projects: list[dict[str, str | int]] @@ -27,6 +29,7 @@ class FeaturedDataRaw(TypedDict): community_most_loved_projects: list[dict[str, str | int]] community_featured_projects: list[dict[str, str | int]] + class FeaturedData(TypedDict): community_newest_projects: list[project.Project] community_most_remixed_projects: list[project.Project] @@ -36,9 +39,11 @@ class FeaturedData(TypedDict): community_most_loved_projects: list[project.Project] community_featured_projects: list[project.Project] + def get_news(*, limit=10, offset=0): return commons.api_iterative("https://api.scratch.mit.edu/news", limit=limit, offset=offset) + def get_featured_data(sess: Optional[session.Session] = None) -> FeaturedData: data: FeaturedDataRaw = requests.get("https://api.scratch.mit.edu/proxy/featured").json() @@ -49,7 +54,7 @@ def get_featured_data(sess: Optional[session.Session] = None) -> FeaturedData: "curator_top_projects": parse_object_list(data["curator_top_projects"], project.Project, sess), "community_featured_studios": parse_object_list(data["community_featured_studios"], studio.Studio, sess), "community_most_loved_projects": parse_object_list(data["community_most_loved_projects"], project.Project, sess), - "community_featured_projects": parse_object_list(data["community_featured_projects"], project.Project, sess) + "community_featured_projects": parse_object_list(data["community_featured_projects"], project.Project, sess), } @@ -83,6 +88,7 @@ def design_studio_projects(): # --- Statistics --- + class TotalSiteStats(TypedDict): PROJECT_COUNT: int USER_COUNT: int @@ -104,25 +110,29 @@ class MonthlySiteTraffic(TypedDict): users: str sessions: str + class CloudStatusRedis(TypedDict): connected: bool ready: bool + @dataclass class CloudStatus: is_online: bool _raw: Any = field(repr=False, default=None) - + _: dataclasses.KW_ONLY uptime: Optional[float] = None load: Optional[list[float]] = None redis: Optional[CloudStatusRedis] = None + def monthly_site_traffic() -> MonthlySiteTraffic: data = requests.get("https://scratch.mit.edu/statistics/data/monthly-ga/").json() data.pop("_TS") return data + def get_cloud_status() -> CloudStatus: with requests.no_error_handling(): try: @@ -131,301 +141,302 @@ def get_cloud_status() -> CloudStatus: return CloudStatus(False, resp.content) try: data = resp.json() - return CloudStatus(True, resp.content, - uptime=data["uptime"], - load=data["load"], - redis=data["redis"]) + return CloudStatus(True, resp.content, uptime=data["uptime"], load=data["load"], redis=data["redis"]) except json.JSONDecodeError: return CloudStatus(True, resp.content) except exceptions.FetchError: return CloudStatus(False) -type CountryCounts = TypedDict("CountryCounts", { - '0': int, # not sure what 0 is. maybe it's the 'other' category - 'AT': int, - 'Afghanistan': int, - 'Aland Islands': int, - 'Albania': int, - 'Algeria': int, - 'American Samoa': int, - 'Andorra': int, - 'Angola': int, - 'Anguilla': int, - 'Antigua and Barbuda': int, - 'Argentina': int, - 'Armenia': int, - 'Aruba': int, - 'Australia': int, - 'Austria': int, - 'Azerbaijan': int, - 'Bahamas': int, - 'Bahrain': int, - 'Bangladesh': int, - 'Barbados': int, - 'Belarus': int, - 'Belgium': int, - 'Belize': int, - 'Benin': int, - 'Bermuda': int, - 'Bhutan': int, - 'Bolivia': int, - 'Bonaire, Sint Eustatius and Saba': int, - 'Bosnia and Herzegovina': int, - 'Botswana': int, - 'Bouvet Island': int, - 'Brazil': int, - 'British Indian Ocean Territory': int, - 'Brunei': int, - 'Brunei Darussalam': int, - 'Bulgaria': int, - 'Burkina Faso': int, - 'Burundi': int, - 'CA': int, - 'Cambodia': int, - 'Cameroon': int, - 'Canada': int, - 'Cape Verde': int, - 'Cayman Islands': int, - 'Central African Republic': int, - 'Chad': int, - 'Chile': int, - 'China': int, - 'Christmas Island': int, - 'Cocos (Keeling) Islands': int, - 'Colombia': int, - 'Comoros': int, - 'Congo': int, - 'Congo, Dem. Rep. of The': int, - 'Congo, The Democratic Republic of The': int, - 'Cook Islands': int, - 'Costa Rica': int, - "Cote D'ivoire": int, - 'Croatia': int, - 'Cuba': int, - 'Curacao': int, - 'Cyprus': int, - 'Czech Republic': int, - 'Denmark': int, - 'Djibouti': int, - 'Dominica': int, - 'Dominican Republic': int, - 'Ecuador': int, - 'Egypt': int, - 'El Salvador': int, - 'England': int, - 'Equatorial Guinea': int, - 'Eritrea': int, - 'Estonia': int, - 'Ethiopia': int, - 'Falkland Islands (Malvinas)': int, - 'Faroe Islands': int, - 'Fiji': int, - 'Finland': int, - 'France': int, - 'French Guiana': int, - 'French Polynesia': int, - 'French Southern Territories': int, - 'GB': int, - 'GG': int, - 'Gabon': int, - 'Gambia': int, - 'Georgia': int, - 'Germany': int, - 'Ghana': int, - 'Gibraltar': int, - 'Greece': int, - 'Greenland': int, - 'Grenada': int, - 'Guadeloupe': int, - 'Guam': int, - 'Guatemala': int, - 'Guernsey': int, - 'Guinea': int, - 'Guinea-Bissau': int, - 'Guyana': int, - 'Haiti': int, - 'Heard Island and Mcdonald Islands': int, - 'Holy See (Vatican City State)': int, - 'Honduras': int, - 'Hong Kong': int, - 'Hungary': int, - 'IT': int, - 'Iceland': int, - 'India': int, - 'Indonesia': int, - 'Iran': int, - 'Iran, Islamic Republic of': int, - 'Iraq': int, - 'Ireland': int, - 'Isle of Man': int, - 'Israel': int, - 'Italy': int, - 'Jamaica': int, - 'Japan': int, - 'Jersey': int, - 'Jordan': int, - 'Kazakhstan': int, - 'Kenya': int, - 'Kiribati': int, - "Korea, Dem. People's Rep.": int, - "Korea, Democratic People's Republic of": int, - 'Korea, Republic of': int, - 'Kosovo': int, - 'Kuwait': int, - 'Kyrgyzstan': int, - 'Laos': int, - 'Latvia': int, - 'Lebanon': int, - 'Lesotho': int, - 'Liberia': int, - 'Libya': int, - 'Libyan Arab Jamahiriya': int, - 'Liechtenstein': int, - 'Lithuania': int, - 'Location not given': int, - 'Luxembourg': int, - 'Macao': int, - 'Macedonia': int, - 'Macedonia, The Former Yugoslav Republic of': int, - 'Madagascar': int, - 'Malawi': int, - 'Malaysia': int, - 'Maldives': int, - 'Mali': int, - 'Malta': int, - 'Marshall Islands': int, - 'Martinique': int, - 'Mauritania': int, - 'Mauritius': int, - 'Mayotte': int, - 'Mexico': int, - 'Micronesia, Federated States of': int, - 'Moldova': int, - 'Moldova, Republic of': int, - 'Monaco': int, - 'Mongolia': int, - 'Montenegro': int, - 'Montserrat': int, - 'Morocco': int, - 'Mozambique': int, - 'Myanmar': int, - 'NO': int, - 'Namibia': int, - 'Nauru': int, - 'Nepal': int, - 'Netherlands': int, - 'Netherlands Antilles': int, - 'New Caledonia': int, - 'New Zealand': int, - 'Nicaragua': int, - 'Niger': int, - 'Nigeria': int, - 'Niue': int, - 'Norfolk Island': int, - 'North Korea': int, - 'Northern Mariana Islands': int, - 'Norway': int, - 'Oman': int, - 'Pakistan': int, - 'Palau': int, - 'Palestine': int, - 'Palestine, State of': int, - 'Palestinian Territory, Occupied': int, - 'Panama': int, - 'Papua New Guinea': int, - 'Paraguay': int, - 'Peru': int, - 'Philippines': int, - 'Pitcairn': int, - 'Poland': int, - 'Portugal': int, - 'Puerto Rico': int, - 'Qatar': int, - 'Reunion': int, - 'Romania': int, - 'Russia': int, - 'Russian Federation': int, - 'Rwanda': int, - 'ST': int, - 'Saint Barthelemy': int, - 'Saint Helena': int, - 'Saint Kitts and Nevis': int, - 'Saint Lucia': int, - 'Saint Martin': int, - 'Saint Pierre and Miquelon': int, - 'Saint Vincent and The Grenadines': int, - 'Samoa': int, - 'San Marino': int, - 'Sao Tome and Principe': int, - 'Saudi Arabia': int, - 'Senegal': int, - 'Serbia': int, - 'Serbia and Montenegro': int, - 'Seychelles': int, - 'Sierra Leone': int, - 'Singapore': int, - 'Sint Maarten': int, - 'Slovakia': int, - 'Slovenia': int, - 'Solomon Islands': int, - 'Somalia': int, - 'Somewhere': int, - 'South Africa': int, - 'South Georgia and the South Sandwich Islands': int, - 'South Korea': int, - 'South Sudan': int, - 'Spain': int, - 'Sri Lanka': int, - 'St. Vincent': int, - 'Sudan': int, - 'Suriname': int, - 'Svalbard and Jan Mayen': int, - 'Swaziland': int, - 'Sweden': int, - 'Switzerland': int, - 'Syria': int, - 'Syrian Arab Republic': int, - 'TV': int, - 'Taiwan': int, - 'Taiwan, Province of China': int, - 'Tajikistan': int, - 'Tanzania': int, - 'Tanzania, United Republic of': int, - 'Thailand': int, - 'Timor-leste': int, - 'Togo': int, - 'Tokelau': int, - 'Tonga': int, - 'Trinidad and Tobago': int, - 'Tunisia': int, - 'Turkey': int, - 'Turkmenistan': int, - 'Turks and Caicos Islands': int, - 'Tuvalu': int, - 'US': int, - 'US Minor': int, - 'Uganda': int, - 'Ukraine': int, - 'United Arab Emirates': int, - 'United Kingdom': int, - 'United States': int, - 'United States Minor Outlying Islands': int, - 'Uruguay': int, - 'Uzbekistan': int, - 'Vanuatu': int, - 'Vatican City': int, - 'Venezuela': int, - 'Viet Nam': int, - 'Vietnam': int, - 'Virgin Islands, British': int, - 'Virgin Islands, U.S.': int, - 'Wallis and Futuna': int, - 'Western Sahara': int, - 'Yemen': int, - 'Zambia': int, - 'Zimbabwe': int -}) + +type CountryCounts = TypedDict( + "CountryCounts", + { + "0": int, # not sure what 0 is. maybe it's the 'other' category + "AT": int, + "Afghanistan": int, + "Aland Islands": int, + "Albania": int, + "Algeria": int, + "American Samoa": int, + "Andorra": int, + "Angola": int, + "Anguilla": int, + "Antigua and Barbuda": int, + "Argentina": int, + "Armenia": int, + "Aruba": int, + "Australia": int, + "Austria": int, + "Azerbaijan": int, + "Bahamas": int, + "Bahrain": int, + "Bangladesh": int, + "Barbados": int, + "Belarus": int, + "Belgium": int, + "Belize": int, + "Benin": int, + "Bermuda": int, + "Bhutan": int, + "Bolivia": int, + "Bonaire, Sint Eustatius and Saba": int, + "Bosnia and Herzegovina": int, + "Botswana": int, + "Bouvet Island": int, + "Brazil": int, + "British Indian Ocean Territory": int, + "Brunei": int, + "Brunei Darussalam": int, + "Bulgaria": int, + "Burkina Faso": int, + "Burundi": int, + "CA": int, + "Cambodia": int, + "Cameroon": int, + "Canada": int, + "Cape Verde": int, + "Cayman Islands": int, + "Central African Republic": int, + "Chad": int, + "Chile": int, + "China": int, + "Christmas Island": int, + "Cocos (Keeling) Islands": int, + "Colombia": int, + "Comoros": int, + "Congo": int, + "Congo, Dem. Rep. of The": int, + "Congo, The Democratic Republic of The": int, + "Cook Islands": int, + "Costa Rica": int, + "Cote D'ivoire": int, + "Croatia": int, + "Cuba": int, + "Curacao": int, + "Cyprus": int, + "Czech Republic": int, + "Denmark": int, + "Djibouti": int, + "Dominica": int, + "Dominican Republic": int, + "Ecuador": int, + "Egypt": int, + "El Salvador": int, + "England": int, + "Equatorial Guinea": int, + "Eritrea": int, + "Estonia": int, + "Ethiopia": int, + "Falkland Islands (Malvinas)": int, + "Faroe Islands": int, + "Fiji": int, + "Finland": int, + "France": int, + "French Guiana": int, + "French Polynesia": int, + "French Southern Territories": int, + "GB": int, + "GG": int, + "Gabon": int, + "Gambia": int, + "Georgia": int, + "Germany": int, + "Ghana": int, + "Gibraltar": int, + "Greece": int, + "Greenland": int, + "Grenada": int, + "Guadeloupe": int, + "Guam": int, + "Guatemala": int, + "Guernsey": int, + "Guinea": int, + "Guinea-Bissau": int, + "Guyana": int, + "Haiti": int, + "Heard Island and Mcdonald Islands": int, + "Holy See (Vatican City State)": int, + "Honduras": int, + "Hong Kong": int, + "Hungary": int, + "IT": int, + "Iceland": int, + "India": int, + "Indonesia": int, + "Iran": int, + "Iran, Islamic Republic of": int, + "Iraq": int, + "Ireland": int, + "Isle of Man": int, + "Israel": int, + "Italy": int, + "Jamaica": int, + "Japan": int, + "Jersey": int, + "Jordan": int, + "Kazakhstan": int, + "Kenya": int, + "Kiribati": int, + "Korea, Dem. People's Rep.": int, + "Korea, Democratic People's Republic of": int, + "Korea, Republic of": int, + "Kosovo": int, + "Kuwait": int, + "Kyrgyzstan": int, + "Laos": int, + "Latvia": int, + "Lebanon": int, + "Lesotho": int, + "Liberia": int, + "Libya": int, + "Libyan Arab Jamahiriya": int, + "Liechtenstein": int, + "Lithuania": int, + "Location not given": int, + "Luxembourg": int, + "Macao": int, + "Macedonia": int, + "Macedonia, The Former Yugoslav Republic of": int, + "Madagascar": int, + "Malawi": int, + "Malaysia": int, + "Maldives": int, + "Mali": int, + "Malta": int, + "Marshall Islands": int, + "Martinique": int, + "Mauritania": int, + "Mauritius": int, + "Mayotte": int, + "Mexico": int, + "Micronesia, Federated States of": int, + "Moldova": int, + "Moldova, Republic of": int, + "Monaco": int, + "Mongolia": int, + "Montenegro": int, + "Montserrat": int, + "Morocco": int, + "Mozambique": int, + "Myanmar": int, + "NO": int, + "Namibia": int, + "Nauru": int, + "Nepal": int, + "Netherlands": int, + "Netherlands Antilles": int, + "New Caledonia": int, + "New Zealand": int, + "Nicaragua": int, + "Niger": int, + "Nigeria": int, + "Niue": int, + "Norfolk Island": int, + "North Korea": int, + "Northern Mariana Islands": int, + "Norway": int, + "Oman": int, + "Pakistan": int, + "Palau": int, + "Palestine": int, + "Palestine, State of": int, + "Palestinian Territory, Occupied": int, + "Panama": int, + "Papua New Guinea": int, + "Paraguay": int, + "Peru": int, + "Philippines": int, + "Pitcairn": int, + "Poland": int, + "Portugal": int, + "Puerto Rico": int, + "Qatar": int, + "Reunion": int, + "Romania": int, + "Russia": int, + "Russian Federation": int, + "Rwanda": int, + "ST": int, + "Saint Barthelemy": int, + "Saint Helena": int, + "Saint Kitts and Nevis": int, + "Saint Lucia": int, + "Saint Martin": int, + "Saint Pierre and Miquelon": int, + "Saint Vincent and The Grenadines": int, + "Samoa": int, + "San Marino": int, + "Sao Tome and Principe": int, + "Saudi Arabia": int, + "Senegal": int, + "Serbia": int, + "Serbia and Montenegro": int, + "Seychelles": int, + "Sierra Leone": int, + "Singapore": int, + "Sint Maarten": int, + "Slovakia": int, + "Slovenia": int, + "Solomon Islands": int, + "Somalia": int, + "Somewhere": int, + "South Africa": int, + "South Georgia and the South Sandwich Islands": int, + "South Korea": int, + "South Sudan": int, + "Spain": int, + "Sri Lanka": int, + "St. Vincent": int, + "Sudan": int, + "Suriname": int, + "Svalbard and Jan Mayen": int, + "Swaziland": int, + "Sweden": int, + "Switzerland": int, + "Syria": int, + "Syrian Arab Republic": int, + "TV": int, + "Taiwan": int, + "Taiwan, Province of China": int, + "Tajikistan": int, + "Tanzania": int, + "Tanzania, United Republic of": int, + "Thailand": int, + "Timor-leste": int, + "Togo": int, + "Tokelau": int, + "Tonga": int, + "Trinidad and Tobago": int, + "Tunisia": int, + "Turkey": int, + "Turkmenistan": int, + "Turks and Caicos Islands": int, + "Tuvalu": int, + "US": int, + "US Minor": int, + "Uganda": int, + "Ukraine": int, + "United Arab Emirates": int, + "United Kingdom": int, + "United States": int, + "United States Minor Outlying Islands": int, + "Uruguay": int, + "Uzbekistan": int, + "Vanuatu": int, + "Vatican City": int, + "Venezuela": int, + "Viet Nam": int, + "Vietnam": int, + "Virgin Islands, British": int, + "Virgin Islands, U.S.": int, + "Wallis and Futuna": int, + "Western Sahara": int, + "Yemen": int, + "Zambia": int, + "Zimbabwe": int, + }, +) def country_counts() -> CountryCounts: @@ -458,6 +469,7 @@ def monthly_activity_trends(): # --- CSRF Token Generation API --- + def get_csrf_token(): """ Generates a scratchcsrftoken using Scratch's API. @@ -465,24 +477,32 @@ def get_csrf_token(): Returns: str: The generated scratchcsrftoken """ - return requests.get( - "https://scratch.mit.edu/csrf_token/" - ).headers["set-cookie"].split(";")[3][len(" Path=/, scratchcsrftoken="):] + return ( + requests.get("https://scratch.mit.edu/csrf_token/") + .headers["set-cookie"] + .split(";")[3][len(" Path=/, scratchcsrftoken=") :] + ) + # --- Accounts --- # + def check_email(email: str) -> bool: """ Returns whether an email is considered valid for registering a scratch account or not """ - data = requests.get(f"https://scratch.mit.edu/accounts/check_email/", params={ - "email": email, - }).json()[0] + data = requests.get( + f"https://scratch.mit.edu/accounts/check_email/", + params={ + "email": email, + }, + ).json()[0] return data["msg"] == "valid email" # either "valid email" or "Enter a valid email address." or "This field is required." # --- Various other api.scratch.mit.edu API endpoints --- + def get_health(): return requests.get("https://api.scratch.mit.edu/health").json() @@ -496,12 +516,12 @@ def check_username(username): 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"] @@ -520,20 +540,25 @@ def get_resource_urls(): def scratch_team_members() -> dict: # Unfortunately, the only place to find this is a js file, not a json file, which is annoying text = requests.get("https://scratch.mit.edu/js/credits.bundle.js").text - text = "[{\"userName\"" + text.split("JSON.parse('[{\"userName\"")[1] - text = text.split("\"}]')")[0] + "\"}]" + text = '[{"userName"' + text.split('JSON.parse(\'[{"userName"')[1] + text = text.split("\"}]')")[0] + '"}]' return json.loads(text) def send_password_reset_email(username: Optional[str] = None, email: Optional[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"): + 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 | Language, text: str = "hello"): if isinstance(language, str): lang = Languages.find_by_attrs(language.lower(), ["code", "tts_locale", "name"], str.lower) elif isinstance(language, Languages): @@ -548,7 +573,8 @@ def translate(language: str | Languages, text: str = "hello"): 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() + f"https://translate-service.scratch.mit.edu/translate?language={lang.code}&text={text}" + ).json() if "result" in response_json: return response_json["result"] @@ -556,7 +582,9 @@ 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", voice_name: str = "female", language: str = "en-US"): +def text2speech( + text: str = "hello", voice_name: str | TTSVoices | TTSVoice = "female", language: str | Language | Languages = "en-US" +): """ Sends a request to Scratch's TTS synthesis service. Returns: @@ -575,10 +603,7 @@ def text2speech(text: str = "hello", voice_name: str = "female", language: str = # 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 " + text = " ".join(("meow" for word in text.split(" ") if word.strip())) if isinstance(language, str): lang = Languages.find_by_attrs(language.lower(), ["code", "tts_locale", "name"], str.lower) @@ -593,6 +618,9 @@ def text2speech(text: str = "hello", voice_name: str = "female", language: str = if lang.tts_locale is None: raise InvalidLanguage(f"Language '{language}' is not a valid TTS language") - response = requests.get(f"https://synthesis-service.scratch.mit.edu/synth" - f"?locale={lang.tts_locale}&gender={voice.gender}&text={text}") - return response.content, voice.playback_rate + with requests.no_error_handling(): + resp = requests.get( + f"https://synthesis-service.scratch.mit.edu/synth", + params={"locale": lang.tts_locale, "gender": voice.gender, "text": text}, + ) + return resp.content, voice.playback_rate diff --git a/scratchattach/site/activity.py b/scratchattach/site/activity.py index 5ea0bc03..57fc998f 100644 --- a/scratchattach/site/activity.py +++ b/scratchattach/site/activity.py @@ -1,4 +1,5 @@ """Activity and CloudActivity class""" + from __future__ import annotations import html @@ -10,10 +11,11 @@ from bs4 import Tag -from . import user, project, studio, session, forum +from . import user, project, studio, session, forum, comment from ._base import BaseSiteComponent from scratchattach.utils import exceptions + class ActivityTypes(Enum): loveproject = "loveproject" favoriteproject = "favoriteproject" @@ -36,11 +38,13 @@ class ActivityTypes(Enum): addprojecttostudio = "addprojecttostudio" performaction = "performaction" + @dataclass class Activity(BaseSiteComponent): """ Represents a Scratch activity (message or other user page activity) """ + _session: Optional[session.Session] = None raw: Any = None @@ -64,7 +68,7 @@ class Activity(BaseSiteComponent): parent_id: Optional[int] = None comment_type: Optional[int] = None - comment_obj_id = None + comment_obj_id: Optional[int] = None comment_obj_title: Optional[str] = None comment_id: Optional[int] = None comment_fragment: Optional[str] = None @@ -80,7 +84,28 @@ def __repr__(self): return f"Activity({repr(self.raw)})" def __str__(self): - return '-A ' + ' '.join(self.parts) + return "-A " + " ".join(self.parts) + + def _parts_simple(self, verb: str, obj: str): + return [str(self.actor_username), verb, obj] + + def _parts_comment(self) -> list[str]: + ret = [str(self.actor_username), "commented on"] + + if self.comment_type not in (0, 1, 2): + raise ValueError(f"Unknown comment type: {self.comment_type}") + ret.append( + { + 0: f"-P {self.comment_obj_title!r} ({self.comment_obj_id}", + 1: f"-U {self.comment_obj_title}", + 2: f"-S {self.comment_obj_title!r} ({self.comment_obj_id}", + }[self.comment_type] + ) + ret[-1] += f"#{self.comment_id})" + + ret.append(str(html.unescape(str(self.comment_fragment)))) + + return ret @property def parts(self): @@ -88,84 +113,71 @@ def parts(self): Return format: [actor username] + N * [action, object] :return: A list of parts of the message. Join the parts to get a readable version, which is done with str(activity) """ - match self.type: - case ActivityTypes.loveproject: - return [f"{self.actor_username}", "loved", f"-P {self.title!r} ({self.project_id})"] - case ActivityTypes.favoriteproject: - return [f"{self.actor_username}", "favorited", f"-P {self.project_title!r} ({self.project_id})"] - case ActivityTypes.becomecurator: - return [f"{self.actor_username}", "now curating", f"-S {self.title!r} ({self.gallery_id})"] - case ActivityTypes.followuser: - return [f"{self.actor_username}", "followed", f"-U {self.followed_username}"] - case ActivityTypes.followstudio: - return [f"{self.actor_username}", "followed", f"-S {self.title!r} ({self.gallery_id})"] - case ActivityTypes.shareproject: - return [f"{self.actor_username}", "reshared" if self.is_reshare else "shared", - f"-P {self.title!r} ({self.project_id})"] - case ActivityTypes.remixproject: - return [f"{self.actor_username}", "remixed", - f"-P {self.parent_title!r} ({self.parent_id}) as -P {self.title!r} ({self.project_id})"] - case ActivityTypes.becomeownerstudio: - return [f"{self.actor_username}", "became owner of", f"-S {self.gallery_title!r} ({self.gallery_id})"] + SIMPLE_SOLNS = { + ActivityTypes.loveproject: ("loved", f"-P {self.title!r} ({self.project_id})"), + ActivityTypes.favoriteproject: ("favorited", f"-P {self.project_title!r} ({self.project_id})"), + ActivityTypes.becomecurator: ("now curating", f"-S {self.title!r} ({self.gallery_id})"), + ActivityTypes.followuser: ("followed", f"-U {self.followed_username}"), + ActivityTypes.followstudio: ("followed", f"-S {self.title!r} ({self.gallery_id})"), + ActivityTypes.shareproject: ( + "reshared" if self.is_reshare else "shared", + f"-P {self.title!r} ({self.project_id})", + ), + ActivityTypes.remixproject: ( + "remixed", + f"-P {self.parent_title!r} ({self.parent_id}) as -P {self.title!r} ({self.project_id})", + ), + ActivityTypes.becomeownerstudio: ("became owner of", f"-S {self.gallery_title!r} ({self.gallery_id})"), + ActivityTypes.curatorinvite: ("invited you to curate", f"-S {self.title!r} ({self.gallery_id})"), + ActivityTypes.forumpost: ("posted in", f"-F {self.topic_title} ({self.topic_id})"), + ActivityTypes.updatestudio: ("updated", f"-S {self.gallery_title} ({self.gallery_id})"), + ActivityTypes.createstudio: ("created", f"-S {self.gallery_title} ({self.gallery_id})"), + None: (), # to satisfy type checker; () is falsy + } + if args := SIMPLE_SOLNS.get(self.type): + return self._parts_simple(*args) + match self.type: case ActivityTypes.addcomment: - ret = [self.actor_username, "commented on"] - - match self.comment_type: - case 0: - # project - ret.append(f"-P {self.comment_obj_title!r} ({self.comment_obj_id}") - case 1: - # user - ret.append(f"-U {self.comment_obj_title}") - - case 2: - # studio - ret.append(f"-S {self.comment_obj_title!r} ({self.comment_obj_id}") - - case _: - raise ValueError(f"Unknown comment type: {self.comment_type}") - - ret[-1] += f"#{self.comment_id})" - - ret.append(f"{html.unescape(self.comment_fragment)}") - - return ret - - case ActivityTypes.curatorinvite: - return [f"{self.actor_username}", "invited you to curate", f"-S {self.title!r} ({self.gallery_id})"] + return self._parts_comment() case ActivityTypes.userjoin: # This is also the first message you get - 'Welcome to Scratch' - return [f"{self.actor_username}", "joined Scratch"] + return [str(self.actor_username), "joined Scratch"] case ActivityTypes.studioactivity: # the actor username should be systemuser - return [f"{self.actor_username}", 'Studio activity', '', f"-S {self.title!r} ({self.gallery_id})"] - - case ActivityTypes.forumpost: - return [f"{self.actor_username}", "posted in", f"-F {self.topic_title} ({self.topic_id})"] - - case ActivityTypes.updatestudio: - return [f"{self.actor_username}", "updated", f"-S {self.gallery_title} ({self.gallery_id})"] - - case ActivityTypes.createstudio: - return [f"{self.actor_username}", "created", f"-S {self.gallery_title} ({self.gallery_id})"] + return [str(self.actor_username), "Studio activity", "", f"-S {self.title!r} ({self.gallery_id})"] case ActivityTypes.promotetomanager: - return [f"{self.actor_username}", "promoted", f"-U {self.recipient_username}", "in", - f"-S {self.gallery_title} ({self.gallery_id})"] + return [ + str(self.actor_username), + "promoted", + f"-U {self.recipient_username}", + "in", + f"-S {self.gallery_title} ({self.gallery_id})", + ] case ActivityTypes.updateprofile: - return [f"{self.actor_username}", "updated their profile.", f"Changed fields: {self.changed_fields}"] + return [str(self.actor_username), "updated their profile.", f"Changed fields: {self.changed_fields}"] case ActivityTypes.removeprojectfromstudio: - return [f"{self.actor_username}", "removed", f"-P {self.project_title} ({self.project_id})", "from", - f"-S {self.gallery_title} ({self.gallery_id})"] + return [ + f"{self.actor_username}", + "removed", + f"-P {self.project_title} ({self.project_id})", + "from", + f"-S {self.gallery_title} ({self.gallery_id})", + ] case ActivityTypes.addprojecttostudio: - return [f"{self.actor_username}", "added", f"-P {self.project_title} ({self.project_id})", "to", - f"-S {self.gallery_title} ({self.gallery_id})"] + return [ + f"{self.actor_username}", + "added", + f"-P {self.project_title} ({self.project_id})", + "to", + f"-S {self.gallery_title} ({self.gallery_id})", + ] case ActivityTypes.performaction: return [f"{self.actor_username}", "performed an action"] @@ -174,7 +186,8 @@ def parts(self): raise NotImplementedError( f"Activity type {self.type!r} is not implemented!\n" f"{self.raw=}\n" - f"Raise an issue on github: https://github.com/TimMcCool/scratchattach/issues") + f"Raise an issue on github: https://github.com/TimMcCool/scratchattach/issues" + ) def update(self): print("Warning: Activity objects can't be updated") @@ -218,7 +231,10 @@ def _update_from_dict(self, data): self.time = data.get("time", self.time) _type = data.get("type", self.type) - if _type: + if _type == "becomehoststudio": + self.type = ActivityTypes.becomeownerstudio + elif _type: + # TODO: do not rely on indexing the enum! I think this is bad practice self.type = ActivityTypes[_type] return True @@ -229,125 +245,95 @@ def _update_from_json(self, data: dict): """ activity_type = data["type"] - _time = data["datetime_created"] if "datetime_created" in data else None + _time = data.get("datetime_created") if "actor" in data: - username = data["actor"]["username"] - elif "actor_username" in data: - username = data["actor_username"] + self.username = data["actor"]["username"] else: - username = None + self.username = data.get("actor_username") + self.recipient_username = None if recipient := data.get("recipient"): - recipient_username = recipient["username"] - elif recipient_username := data.get("recipient_username"): - pass + self.recipient_username = recipient["username"] + elif ru := data.get("recipient_username"): + self.recipient_username = ru elif project_creator := data.get("project_creator"): - recipient_username = project_creator["username"] - else: - recipient_username = None + self.recipient_username = project_creator["username"] - default_case = False # Even if `activity_type` is an invalid value; it will default to 'user performed an action' - self.actor_username = username - self.username = username + self.actor_username = self.username self.raw = data self.datetime_created = _time - if activity_type == 0: - self.type = ActivityTypes.followuser - self.followed_username = data["followed_username"] - - elif activity_type == 1: - self.type = ActivityTypes.followstudio - self.gallery_id = data["gallery"] - - elif activity_type == 2: - self.type = ActivityTypes.loveproject - self.project_id = data["project"] - self.recipient_username = recipient_username - - elif activity_type == 3: - self.type = ActivityTypes.favoriteproject - self.project_id = data["project"] - self.recipient_username = recipient_username - elif activity_type == 7: - self.type = ActivityTypes.addprojecttostudio - self.project_id = data["project"] - self.gallery_id = data["gallery"] - self.recipient_username = recipient_username + # NOTE: some type values are treated the same here + # this is by design. the scratch HTML does this using a switch statement + self.type = { + 0: ActivityTypes.followuser, + 1: ActivityTypes.followstudio, + 2: ActivityTypes.loveproject, + 3: ActivityTypes.favoriteproject, + 7: ActivityTypes.addprojecttostudio, + 8: ActivityTypes.shareproject, + 9: ActivityTypes.shareproject, + 10: ActivityTypes.shareproject, + 11: ActivityTypes.remixproject, + # type 12 does not exist in the HTML. That's why it was removed, not merged with type 13. + 13: ActivityTypes.createstudio, + 15: ActivityTypes.updatestudio, + 16: ActivityTypes.removeprojectfromstudio, + 17: ActivityTypes.removeprojectfromstudio, + 18: ActivityTypes.removeprojectfromstudio, + 19: ActivityTypes.removeprojectfromstudio, + 20: ActivityTypes.promotetomanager, + 21: ActivityTypes.promotetomanager, + 22: ActivityTypes.promotetomanager, + 23: ActivityTypes.updateprofile, + 24: ActivityTypes.updateprofile, + 25: ActivityTypes.updateprofile, + 26: ActivityTypes.addcomment, + 27: ActivityTypes.addcomment, + None: ActivityTypes.performaction, # this one is just to satisfy type checkers + }.get(activity_type, ActivityTypes.performaction) - elif activity_type in (8, 9, 10): - self.type = ActivityTypes.shareproject - self.is_reshare = data["is_reshare"] - self.project_id = data["project"] - self.recipient_username = recipient_username - - elif activity_type == 11: - self.type = ActivityTypes.remixproject - self.parent_id = data["parent"] - warnings.warn(f"This may be incorrectly implemented.\n" - f"Raw data: {data}\n" - f"Please raise an issue on gh: https://github.com/TimMcCool/scratchattach/issues") - self.recipient_username = recipient_username - - # type 12 does not exist in the HTML. That's why it was removed, not merged with type 13. - - elif activity_type == 13: - self.type = ActivityTypes.createstudio - self.gallery_id = data["gallery"] - - elif activity_type == 15: - self.type = ActivityTypes.updatestudio - self.gallery_id = data["gallery"] - - elif activity_type in (16, 17, 18, 19): - self.type = ActivityTypes.removeprojectfromstudio - self.gallery_id = data["gallery"] - self.project_id = data["project"] - - elif activity_type in (20, 21, 22): - self.type = ActivityTypes.promotetomanager - self.recipient_username = recipient_username - self.gallery_id = data["gallery"] - - elif activity_type in (23, 24, 25): - self.type = ActivityTypes.updateprofile + self.followed_username = data.get("followed_username", self.followed_username) + self.gallery_id = data.get("gallery", self.gallery_id) + self.project_id = data.get("project", self.project_id) + self.is_reshare = data.get("is_reshare", self.is_reshare) + self.comment_fragment = data.get("comment_fragment", self.comment_fragment) + self.comment_type = data.get("comment_type", self.comment_type) + self.comment_obj_id = data.get("comment_obj_id", self.comment_obj_id) + self.comment_obj_title = data.get("comment_obj_title", self.comment_obj_title) + self.comment_id = data.get("comment_id", self.comment_id) + self.parent_id = data.get("parent", self.parent_id) + if self.parent_id: + # activity_type 11 + warnings.warn( + f"This may be incorrectly implemented.\n" + f"Raw data: {data}\n" + f"Please raise an issue on gh: https://github.com/TimMcCool/scratchattach/issues" + ) + if self.type == ActivityTypes.updateprofile: self.changed_fields = data.get("changed_fields", {}) - elif activity_type in (26, 27): - # Comment in either project, user, or studio - self.type = ActivityTypes.addcomment - self.comment_fragment = data["comment_fragment"] - self.comment_type = data["comment_type"] - self.comment_obj_id = data["comment_obj_id"] - self.comment_obj_title = data["comment_obj_title"] - self.comment_id = data["comment_id"] - - else: - # This is coded in the scratch HTML, haven't found an example of it though - self.type = ActivityTypes.performaction - - def _update_from_html(self, data: Tag): self.raw = data - _time = data.find('div').find('span').find_next().find_next().text.strip() + _time = data.find("div").find("span").find_next().find_next().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.datetime_created = _time - self.actor_username = data.find('div').find('span').text + self.actor_username = data.find("div").find("span").text - self.target_name = data.find('div').find('span').find_next().text - self.target_link = data.find('div').find('span').find_next()["href"] + self.target_name = data.find("div").find("span").find_next().text + self.target_link = data.find("div").find("span").find_next()["href"] # note that target_id can also be a username, so it isn't exclusively an int - self.target_id = data.find('div').find('span').find_next()["href"].split("/")[-2] + self.target_id = data.find("div").find("span").find_next()["href"].split("/")[-2] - _type = data.find('div').find_all('span')[0].next_sibling.strip() + _type = data.find("div").find_all("span")[0].next_sibling.strip() if _type == "loved": self.type = ActivityTypes.loveproject @@ -374,53 +360,97 @@ def actor(self): """ return self._make_linked_object("username", self.actor_username, user.User, exceptions.UserNotFound) + def target_project(self) -> Optional[project.Project]: + if self.target_id: + return self._make_linked_object("id", self.target_id, project.Project, exceptions.ProjectNotFound) + if self.project_id: + return self._make_linked_object("id", self.project_id, project.Project, exceptions.ProjectNotFound) + return None + + def target_studio(self) -> Optional[studio.Studio]: + if self.target_id: + return self._make_linked_object("id", self.target_id, studio.Studio, exceptions.StudioNotFound) + if self.gallery_id: + return self._make_linked_object("id", self.gallery_id, studio.Studio, exceptions.StudioNotFound) + return None + + def target_user(self) -> Optional[user.User]: + if self.username: + return self._make_linked_object("username", self.username, user.User, exceptions.UserNotFound) + if self.target_name: + return self._make_linked_object("username", self.target_name, user.User, exceptions.UserNotFound) + if self.followed_username: + return self._make_linked_object("username", self.followed_username, user.User, exceptions.UserNotFound) + if self.recipient_username: + return self._make_linked_object("username", self.recipient_username, user.User, exceptions.UserNotFound) + return None + + def target_comment(self) -> Optional[comment.Comment]: + # TODO: make use of self.target_project/target_user/target_studio here. + # Also why is there no use of studio here??? This needs to be tested + if self.comment_type == 0: + if self.comment_obj_id is None: + return None + + # we need author name, but it has not been saved in this object + if self._session is not None: + _proj = self._session.connect_project(self.comment_obj_id) + else: + _proj = project.Project(id=self.comment_obj_id) + + return _proj.comment_by_id(self.comment_id) + + elif self.comment_type == 1: + return user.User(username=self.comment_obj_title, _session=self._session).comment_by_id(self.comment_id) + elif self.comment_type == 2: + return user.User(id=self.comment_obj_id, _session=self._session).comment_by_id(self.comment_id) + else: + return None + 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. """ - _type = self.type.value + if self.type is None: + return None - if "project" in _type: # target is a project - if self.target_id: - return self._make_linked_object("id", self.target_id, project.Project, exceptions.ProjectNotFound) - if self.project_id: - return self._make_linked_object("id", self.project_id, project.Project, exceptions.ProjectNotFound) - - if _type == "becomecurator" or _type == "followstudio": # target is a studio - if self.target_id: - return self._make_linked_object("id", self.target_id, studio.Studio, exceptions.StudioNotFound) - if self.gallery_id: - return self._make_linked_object("id", self.gallery_id, studio.Studio, exceptions.StudioNotFound) + _type = self.type.value + if self.type in ( + ActivityTypes.addprojecttostudio, + ActivityTypes.favoriteproject, + ActivityTypes.loveproject, + ActivityTypes.remixproject, + ActivityTypes.removeprojectfromstudio, + ActivityTypes.shareproject, + ): # target is a project + return self.target_project() + + if self.type in (ActivityTypes.becomecurator, ActivityTypes.followstudio): # target is a studio + if ret := self.target_studio(): + return ret # NOTE: the "becomecurator" type is ambigous - if it is inside the studio activity tab, the target is the user who joined - if self.username: - return self._make_linked_object("username", self.username, user.User, exceptions.UserNotFound) - - if _type == "followuser" or "curator" in _type: # target is a user - if self.target_name: - return self._make_linked_object("username", self.target_name, user.User, exceptions.UserNotFound) - if self.followed_username: - return self._make_linked_object("username", self.followed_username, user.User, exceptions.UserNotFound) - - if self.recipient_username: # 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 _type == "addcomment": # target is a comment - if self.comment_type == 0: - # we need author name, but it has not been saved in this object - _proj = self._session.connect_project(self.comment_obj_id) - _c = _proj.comment_by_id(self.comment_id) - - elif self.comment_type == 1: - _c = user.User(username=self.comment_obj_title, _session=self._session).comment_by_id(self.comment_id) - elif 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 self.target_user() + + if ( + self.type + in ( + ActivityTypes.followuser, + ActivityTypes.curatorinvite, + ) + or self.recipient_username + ): # target is a user + # NOTE: the recipient_username field always indicates the target is a user + return self.target_user() + + if self.type == ActivityTypes.addcomment: # target is a comment + if ret := self.target_comment(): + return ret - return _c + raise ValueError(f"Either {self.comment_type} is an invalid comment type, or the linked target could not be found") if _type == "forumpost": + # FIXME: why is the id here constant?!?? return forum.ForumTopic(id=603418, _session=self._session, title=self.title) return None diff --git a/scratchattach/site/project.py b/scratchattach/site/project.py index 19f42b56..e7d5e4ed 100644 --- a/scratchattach/site/project.py +++ b/scratchattach/site/project.py @@ -94,83 +94,41 @@ def __post_init__(self) -> None: self._json_headers["Content-Type"] = "application/json" def _update_from_dict(self, data: ProjectDict): - try: - self.id = int(data["id"]) - except KeyError: - pass - try: - self.url = f"https://scratch.mit.edu/projects/{self.id}" - except KeyError: - pass - try: - self.author_name = data["author"]["username"] - except KeyError: - pass - try: - self.author_name = data["username"] # type: ignore[typeddict-item] - except KeyError: - pass - try: - self.comments_allowed = data["comments_allowed"] - except KeyError: - pass - try: - self.instructions = data["instructions"] - except KeyError: - pass - try: - self.notes = data["description"] - except KeyError: - pass - try: - self.created = data["history"]["created"] - except KeyError: - pass - try: - self.last_modified = data["history"]["modified"] - except KeyError: - pass - try: - self.share_date = data["history"]["shared"] - except KeyError: - pass - try: - self.thumbnail_url = data["image"] - except KeyError: - pass - try: - self.remix_parent = data["remix"]["parent"] - self.remix_root = data["remix"]["root"] - except KeyError: - self.remix_parent = None - self.remix_root = None - try: - self.favorites = data["stats"]["favorites"] - except KeyError: - pass - try: - self.loves = data["stats"]["loves"] - except KeyError: - pass - try: - self.remix_count = data["stats"]["remixes"] - except KeyError: - pass - try: - self.views = data["stats"]["views"] - except KeyError: - pass - try: - self.title = data["title"] - except KeyError: - pass - try: - self.project_token = data["project_token"] - except KeyError: - self.project_token = None - if "code" in data: # Project is unshared -> return false - return False - return True + self.id = int(data.get("id", self.id)) + self.url = f"https://scratch.mit.edu/projects/{self.id}" + if author := data.get("author"): + self.author_name = author.get("username", self.author_name) + self.author_name = data.get("username", self.author_name) + self.comments_allowed = data.get("comments_allowed", self.comments_allowed) + self.instructions = data.get("instructions", self.instructions) + self.notes = data.get("description", self.notes) + + if history := data.get("history"): + self.created = history.get("created", self.created) + self.last_modified = history.get("modified", self.last_modified) + self.share_date = history.get("shared", self.share_date) + + self.thumbnail_url = data.get("image", self.thumbnail_url) + + # NOTE: if we have no value, then we set it to None instead of empty string. + # TODO: consider changing this behavior + remix_data = data.get("remix", {}) + self.remix_parent = remix_data.get("parent") + self.remix_root = remix_data.get("root") + + if stats := data.get("stats"): + self.favorites = stats.get("favorites", self.favorites) + self.loves = stats.get("loves", self.loves) + self.remix_count = stats.get("remixes", self.remix_count) + self.views = stats.get("views", self.views) + + self.title = data.get("title", self.title) + self.project_token = data.get("project_token", None) + + # the typed dict here isn't perfect: + # code as in {"code": "not found"} + # if the project is unshared, then we get that error code + return "code" not in data def __rich__(self): from rich.panel import Panel diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index 8855ef7c..ebb77040 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -191,7 +191,10 @@ def __rich__(self): from rich.markup import escape featured_data = self.featured_data() or {} - ocular_data = self.ocular_status() + + ocular_data = {} + # FIXME: ocular is down right now, so this is disabled + # ocular_data = self.ocular_status() ocular = "No ocular status" if status := ocular_data.get("status"): diff --git a/tests/test_studio.py b/tests/test_studio.py index 8dd993f8..85e696b4 100644 --- a/tests/test_studio.py +++ b/tests/test_studio.py @@ -55,7 +55,7 @@ def test_studio(): assert host.name == "ScratchAttachV2" # set fields, desc, title, open projects, close projects, turn on/off/toggle commenting - assert studio.activity()[0].type == sa.ActivityTypes.addprojecttostudio + assert studio.activity()[2].type == sa.ActivityTypes.addprojecttostudio # accept invite/your role # If we run out of 'add everything!' studios, clearly something has gone wrong. diff --git a/tests/util/__main__.py b/tests/util/__main__.py index 4390e557..ebba58bf 100644 --- a/tests/util/__main__.py +++ b/tests/util/__main__.py @@ -16,27 +16,29 @@ def gen_keystr(): return secrets.token_urlsafe(32) -def main(): - class Args(argparse.Namespace): - command: str - content: str +class Args(argparse.Namespace): + command: str + content: str + +def main(): parser = argparse.ArgumentParser() + # using if statements here for indentation to help organise subcommands/arguments if command := parser.add_subparsers(dest="command"): if encrypt := command.add_parser("e", help="Encrypt content"): encrypt.add_argument("content", nargs="?") if decrypt := command.add_parser("d", help="Decrypt content"): decrypt.add_argument("content", nargs="?") - if keygen := command.add_parser("keygen", help="Generate a key. You could set this to $FERNET_KEY if you want"): - ... - if vercelauth := command.add_parser("vercel", help="Output the vercel auth data."): - ... - if addmask := command.add_parser("addmask", help="Mask all secrets."): - ... - args = parser.parse_args(namespace=Args()) + command.add_parser("keygen", help="Generate a key. You could set this to $FERNET_KEY if you want") + command.add_parser("vercel", help="Output the vercel auth data.") + command.add_parser("addmask", help="Mask all secrets.") + + cmd(parser.parse_args(namespace=Args())) + +def cmd(args: Args): match args.command: case "e": if not args.content: @@ -50,14 +52,13 @@ class Args(argparse.Namespace): case "keygen": print(gen_keystr()) - + case "vercel": - print( - "\n".join(vercel_auth()) - ) + print("\n".join(vercel_auth())) case "addmask": mask_all() + if __name__ == "__main__": main() diff --git a/uv.lock b/uv.lock index b746bbe1..38ac85f0 100644 --- a/uv.lock +++ b/uv.lock @@ -932,6 +932,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/72/7264494bc0944db1166b73c88f19d9ddfc584dbbc77c210cd0f52f59c511/rich_pixels-3.0.1-py3-none-any.whl", hash = "sha256:e82c5aa0d00885609675494f16e1ef814c68fa795634f1d6917cae9159b755e1", size = 6004, upload-time = "2024-03-30T09:37:51.169Z" }, ] +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + [[package]] name = "scratchattach" version = "3.0.0b3" @@ -960,6 +985,7 @@ dev = [ { name = "cryptography" }, { name = "pytest" }, { name = "python-dotenv" }, + { name = "ruff" }, ] [package.metadata] @@ -982,6 +1008,7 @@ dev = [ { name = "cryptography", specifier = ">=46.0.3" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "ruff", specifier = ">=0.15.12" }, ] [[package]]