From 1b2ead2e10c6ddde75ae4dd472be5449a36c2404 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 4 Oct 2025 15:06:44 +0100 Subject: [PATCH 01/42] feat: create CLI --- scratchattach/__main__.py | 15 +++++++++++++++ scratchattach/cli/__about__.py | 1 + scratchattach/cli/__init__.py | 2 ++ scratchattach/cli/namespace.py | 5 +++++ setup.py | 5 +++++ 5 files changed, 28 insertions(+) create mode 100644 scratchattach/__main__.py create mode 100644 scratchattach/cli/__about__.py create mode 100644 scratchattach/cli/__init__.py create mode 100644 scratchattach/cli/namespace.py diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py new file mode 100644 index 00000000..4ba02185 --- /dev/null +++ b/scratchattach/__main__.py @@ -0,0 +1,15 @@ +import argparse + +from scratchattach import cli + +def main(): + parser = argparse.ArgumentParser( + prog="scratch", + description="Scratchattach CLI", + epilog=f"Running scratchattach CLI version {cli.VERSION}", + ) + + args = parser.parse_args(namespace=cli.ArgSpace()) + +if __name__ == '__main__': + main() diff --git a/scratchattach/cli/__about__.py b/scratchattach/cli/__about__.py new file mode 100644 index 00000000..bdeca9df --- /dev/null +++ b/scratchattach/cli/__about__.py @@ -0,0 +1 @@ +VERSION = "0.0.0" \ No newline at end of file diff --git a/scratchattach/cli/__init__.py b/scratchattach/cli/__init__.py new file mode 100644 index 00000000..f9d6a5db --- /dev/null +++ b/scratchattach/cli/__init__.py @@ -0,0 +1,2 @@ +from scratchattach.cli.__about__ import VERSION +from scratchattach.cli.namespace import ArgSpace diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py new file mode 100644 index 00000000..1488dec9 --- /dev/null +++ b/scratchattach/cli/namespace.py @@ -0,0 +1,5 @@ +import argparse + + +class ArgSpace(argparse.Namespace): + ... diff --git a/setup.py b/setup.py index 512d2dff..71e8c086 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,11 @@ packages=find_packages(), python_requires='>=3.12', install_requires=requirements, + entry_points={ + 'console_scripts': [ + "scratch=scratchattach.__main__:main" + ] + }, extras_require={ "lark": ["lark"] }, From 6ae371dd6c12765cba9276150fba5f9e8d5f4450 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 4 Oct 2025 15:43:24 +0100 Subject: [PATCH 02/42] feat: sqlite db --- scratchattach/cli/db.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 scratchattach/cli/db.py diff --git a/scratchattach/cli/db.py b/scratchattach/cli/db.py new file mode 100644 index 00000000..1b1bd42f --- /dev/null +++ b/scratchattach/cli/db.py @@ -0,0 +1,24 @@ +""" +Basic connections to the scratch.sqlite file +""" +import sqlite3 +import sys +import os + +from pathlib import Path + +def _gen_appdata_folder() -> Path: + name = "scratchattach" + match sys.platform: + case "win32": + return Path(os.getenv('APPDATA')) / name + case "linux": + return Path.home() / f".{name}" + case plat: + raise NotImplementedError(f"No 'appdata' folder implemented for {plat}") + +_path = _gen_appdata_folder() +_path.mkdir(parents=True, exist_ok=True) + +conn = sqlite3.connect(_path / "cli.sqlite") +cursor = conn.cursor() From a248b7e48ffe3424eb1fc4dc13a157c661860840 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 4 Oct 2025 16:13:27 +0100 Subject: [PATCH 03/42] feat: login does not save sesssion yet --- scratchattach/__init__.py | 2 ++ scratchattach/__main__.py | 18 ++++++++++++++++ scratchattach/cli/__init__.py | 4 ++++ scratchattach/cli/cmd/__init__.py | 1 + scratchattach/cli/cmd/login.py | 24 ++++++++++++++++++++++ scratchattach/cli/context.py | 34 +++++++++++++++++++++++++++++++ scratchattach/cli/namespace.py | 4 +++- 7 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 scratchattach/cli/cmd/__init__.py create mode 100644 scratchattach/cli/cmd/login.py create mode 100644 scratchattach/cli/context.py diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index 63a9c59f..edfdf5a9 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -34,3 +34,5 @@ from .site.browser_cookies import Browser, ANY, FIREFOX, CHROME, CHROMIUM, VIVALDI, EDGE, EDGE_DEV, SAFARI from . import editor + +# note: do NOT import scratch CLI here \ No newline at end of file diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index 4ba02185..52e00753 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -1,6 +1,11 @@ +""" +Scratchattach CLI. Most source code is in the `cli` directory +""" + import argparse from scratchattach import cli +from scratchattach.cli import db, cmd def main(): parser = argparse.ArgumentParser( @@ -9,7 +14,20 @@ def main(): epilog=f"Running scratchattach CLI version {cli.VERSION}", ) + # Using walrus operator & ifs for artificial indentation + if commands := parser.add_subparsers(dest="command"): + 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") + args = parser.parse_args(namespace=cli.ArgSpace()) + cli.ctx.args = args + cli.ctx.parser = parser + + match args.command: + case "login": + cmd.login() + case None: + parser.print_help() if __name__ == '__main__': main() diff --git a/scratchattach/cli/__init__.py b/scratchattach/cli/__init__.py index f9d6a5db..68f9417a 100644 --- a/scratchattach/cli/__init__.py +++ b/scratchattach/cli/__init__.py @@ -1,2 +1,6 @@ +""" +Only for use within __main__.py +""" from scratchattach.cli.__about__ import VERSION from scratchattach.cli.namespace import ArgSpace +from scratchattach.cli.context import ctx diff --git a/scratchattach/cli/cmd/__init__.py b/scratchattach/cli/cmd/__init__.py new file mode 100644 index 00000000..ee9b18ba --- /dev/null +++ b/scratchattach/cli/cmd/__init__.py @@ -0,0 +1 @@ +from .login import login \ No newline at end of file diff --git a/scratchattach/cli/cmd/login.py b/scratchattach/cli/cmd/login.py new file mode 100644 index 00000000..8da936c7 --- /dev/null +++ b/scratchattach/cli/cmd/login.py @@ -0,0 +1,24 @@ +from scratchattach.cli.context import ctx +from scratchattach.cli import db + +from getpass import getpass + +import scratchattach as sa +import warnings + +warnings.filterwarnings("ignore", category=sa.LoginDataWarning) + +def login_by_sessid(): + raise NotImplementedError + +def login(): + print(ctx.args) + + if ctx.args.sessid: + login_by_sessid() + return + + username = input("Username: ") + password = getpass() + + session = sa.login(username, password) diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py new file mode 100644 index 00000000..12e20685 --- /dev/null +++ b/scratchattach/cli/context.py @@ -0,0 +1,34 @@ +""" +Handles data like current session for 'sessionable' commands. +""" +import argparse +from dataclasses import dataclass, field +from typing_extensions import Optional + +from scratchattach.cli.namespace import ArgSpace +import scratchattach as sa + +@dataclass +class _Ctx: + args: ArgSpace = field(default_factory=ArgSpace) + parser: argparse.ArgumentParser = field(default_factory=argparse.ArgumentParser) + sessions: list[sa.Session] = field(default_factory=list) + _session: Optional[sa.Session] = None + + # TODO: implement this + def sessionable(self, func): + """ + Decorate a command that will be run for every session in the group. + """ + def wrapper(*args, **kwargs): + for session in self.sessions: + self._session = session + func(*args, **kwargs) + + return wrapper + + @property + def session(self): + return self._session + +ctx = _Ctx() diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index 1488dec9..c1b229cf 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -1,5 +1,7 @@ import argparse +from typing_extensions import Optional, Literal class ArgSpace(argparse.Namespace): - ... + command: Optional[Literal['login']] + sessid: bool | str From 28b58767b09c61330574ffec1cbb94d29ccd4135 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 4 Oct 2025 16:18:10 +0100 Subject: [PATCH 04/42] feat: init session table --- scratchattach/cli/db.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scratchattach/cli/db.py b/scratchattach/cli/db.py index 1b1bd42f..dc557fdc 100644 --- a/scratchattach/cli/db.py +++ b/scratchattach/cli/db.py @@ -22,3 +22,14 @@ def _gen_appdata_folder() -> Path: conn = sqlite3.connect(_path / "cli.sqlite") cursor = conn.cursor() + +# Init any tables +conn.execute("BEGIN") +cursor.execute(""" + CREATE TABLE IF NOT EXISTS SESSIONS ( + ID TEXT NOT NULL, + USERNAME TEXT NOT NULL PRIMARY KEY, + PASSWORD TEXT NOT NULL -- SessID is included, so is there harm in this? + ) +""") +conn.commit() From d4ba212db95d1c203ed7d2a2612843b4b2afbbb3 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 4 Oct 2025 16:24:16 +0100 Subject: [PATCH 05/42] feat: save login data to table --- scratchattach/cli/cmd/login.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scratchattach/cli/cmd/login.py b/scratchattach/cli/cmd/login.py index 8da936c7..2829c225 100644 --- a/scratchattach/cli/cmd/login.py +++ b/scratchattach/cli/cmd/login.py @@ -22,3 +22,9 @@ def login(): password = getpass() session = sa.login(username, password) + db.conn.execute("BEGIN") + db.cursor.execute( + "INSERT INTO SESSIONS (ID, USERNAME, PASSWORD) " + "VALUES (?, ?, ?)", (session.id, session.username, password) + ) + db.conn.commit() From b1196abac0a6cd2300d6183cafa7582b4ecab826 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 4 Oct 2025 16:39:24 +0100 Subject: [PATCH 06/42] fix: allow updating credentials --- scratchattach/cli/cmd/login.py | 2 +- scratchattach/cli/db.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scratchattach/cli/cmd/login.py b/scratchattach/cli/cmd/login.py index 2829c225..f3680d09 100644 --- a/scratchattach/cli/cmd/login.py +++ b/scratchattach/cli/cmd/login.py @@ -24,7 +24,7 @@ def login(): session = sa.login(username, password) db.conn.execute("BEGIN") db.cursor.execute( - "INSERT INTO SESSIONS (ID, USERNAME, PASSWORD) " + "INSERT OR REPLACE INTO SESSIONS (ID, USERNAME, PASSWORD) " "VALUES (?, ?, ?)", (session.id, session.username, password) ) db.conn.commit() diff --git a/scratchattach/cli/db.py b/scratchattach/cli/db.py index dc557fdc..5d7d9229 100644 --- a/scratchattach/cli/db.py +++ b/scratchattach/cli/db.py @@ -29,7 +29,7 @@ def _gen_appdata_folder() -> Path: CREATE TABLE IF NOT EXISTS SESSIONS ( ID TEXT NOT NULL, USERNAME TEXT NOT NULL PRIMARY KEY, - PASSWORD TEXT NOT NULL -- SessID is included, so is there harm in this? + PASSWORD TEXT NOT NULL -- TODO: consider if this is needed ) """) conn.commit() From 5e14d85f2e586901afce3a7105994e380f37cd10 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 4 Oct 2025 16:58:02 +0100 Subject: [PATCH 07/42] feat: login by sessid also removed password field in db --- scratchattach/cli/cmd/login.py | 21 ++++++++++----------- scratchattach/cli/db.py | 3 +-- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/scratchattach/cli/cmd/login.py b/scratchattach/cli/cmd/login.py index f3680d09..9527dd82 100644 --- a/scratchattach/cli/cmd/login.py +++ b/scratchattach/cli/cmd/login.py @@ -8,23 +8,22 @@ warnings.filterwarnings("ignore", category=sa.LoginDataWarning) -def login_by_sessid(): - raise NotImplementedError def login(): - print(ctx.args) - if ctx.args.sessid: - login_by_sessid() - return + if isinstance(ctx.args.sessid, bool): + ctx.args.sessid = getpass("Session ID: ") + + session = sa.login_by_id(ctx.args.sessid) + else: + username = input("Username: ") + password = getpass() - username = input("Username: ") - password = getpass() + session = sa.login(username, password) - session = sa.login(username, password) db.conn.execute("BEGIN") db.cursor.execute( - "INSERT OR REPLACE INTO SESSIONS (ID, USERNAME, PASSWORD) " - "VALUES (?, ?, ?)", (session.id, session.username, password) + "INSERT OR REPLACE INTO SESSIONS (ID, USERNAME) " + "VALUES (?, ?)", (session.id, session.username) ) db.conn.commit() diff --git a/scratchattach/cli/db.py b/scratchattach/cli/db.py index 5d7d9229..7d4be52b 100644 --- a/scratchattach/cli/db.py +++ b/scratchattach/cli/db.py @@ -28,8 +28,7 @@ def _gen_appdata_folder() -> Path: cursor.execute(""" CREATE TABLE IF NOT EXISTS SESSIONS ( ID TEXT NOT NULL, - USERNAME TEXT NOT NULL PRIMARY KEY, - PASSWORD TEXT NOT NULL -- TODO: consider if this is needed + USERNAME TEXT NOT NULL PRIMARY KEY ) """) conn.commit() From 390392670ac09e97097b7a2fe31f417bdac0003a Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 4 Oct 2025 22:04:43 +0100 Subject: [PATCH 08/42] fix: remove note from init It doesn't seem to cause any issues, although there is no reason to import it --- scratchattach/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index edfdf5a9..63a9c59f 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -34,5 +34,3 @@ from .site.browser_cookies import Browser, ANY, FIREFOX, CHROME, CHROMIUM, VIVALDI, EDGE, EDGE_DEV, SAFARI from . import editor - -# note: do NOT import scratch CLI here \ No newline at end of file From c3e96aa616619b89f561672380d7b57f9308b783 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 4 Oct 2025 22:17:25 +0100 Subject: [PATCH 09/42] feat: Groups tables --- scratchattach/cli/db.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scratchattach/cli/db.py b/scratchattach/cli/db.py index 7d4be52b..b0fac800 100644 --- a/scratchattach/cli/db.py +++ b/scratchattach/cli/db.py @@ -31,4 +31,20 @@ def _gen_appdata_folder() -> Path: USERNAME TEXT NOT NULL PRIMARY KEY ) """) + +cursor.execute(""" + CREATE TABLE IF NOT EXISTS GROUPS ( + NAME TEXT NOT NULL PRIMARY KEY, + DESCRIPTION TEXT + -- If you want to add users to a group, you add to the next table + ) +""") + +cursor.execute(""" + CREATE TABLE IF NOT EXISTS GROUP_USERS ( + GROUP_NAME TEXT NOT NULL, + USERNAME TEXT NOT NULL + ) +""") + conn.commit() From afa4875fdc378e8f655e11f71acd3fb52d0896ef Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 4 Oct 2025 22:53:44 +0100 Subject: [PATCH 10/42] feat: add col --- scratchattach/cli/db.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scratchattach/cli/db.py b/scratchattach/cli/db.py index b0fac800..ad1825cc 100644 --- a/scratchattach/cli/db.py +++ b/scratchattach/cli/db.py @@ -7,6 +7,9 @@ from pathlib import Path +from typing_extensions import LiteralString + + def _gen_appdata_folder() -> Path: name = "scratchattach" match sys.platform: @@ -24,6 +27,15 @@ def _gen_appdata_folder() -> Path: cursor = conn.cursor() # Init any tables +def add_col(table: LiteralString, column: LiteralString, _type: LiteralString): + try: + return cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {_type}") + except sqlite3.OperationalError as e: + if "duplicate column name" not in str(e).lower(): + raise + +# NOTE: IF YOU WANT TO ADD EXTRA KEYS TO A TABLE RETROACTIVELY, USE add_col + conn.execute("BEGIN") cursor.execute(""" CREATE TABLE IF NOT EXISTS SESSIONS ( @@ -43,7 +55,7 @@ def _gen_appdata_folder() -> Path: cursor.execute(""" CREATE TABLE IF NOT EXISTS GROUP_USERS ( GROUP_NAME TEXT NOT NULL, - USERNAME TEXT NOT NULL + USERNAME TEXT NOT NULL ) """) From c8cd57b4fde7e62996eb1a20e88c633e0edd3194 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 4 Oct 2025 23:30:01 +0100 Subject: [PATCH 11/42] feat: print current group --- scratchattach/__main__.py | 4 ++++ scratchattach/cli/cmd/__init__.py | 3 ++- scratchattach/cli/cmd/group.py | 19 +++++++++++++++++++ scratchattach/cli/context.py | 1 + scratchattach/cli/db.py | 25 +++++++++++++------------ scratchattach/cli/namespace.py | 2 +- 6 files changed, 40 insertions(+), 14 deletions(-) create mode 100644 scratchattach/cli/cmd/group.py diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index 52e00753..32a112ef 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -18,6 +18,8 @@ def main(): if commands := parser.add_subparsers(dest="command"): 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") + if group := commands.add_parser("group", help="View current session group"): + ... args = parser.parse_args(namespace=cli.ArgSpace()) cli.ctx.args = args @@ -26,6 +28,8 @@ def main(): match args.command: case "login": cmd.login() + case "group": + cmd.group() case None: parser.print_help() diff --git a/scratchattach/cli/cmd/__init__.py b/scratchattach/cli/cmd/__init__.py index ee9b18ba..edef6df7 100644 --- a/scratchattach/cli/cmd/__init__.py +++ b/scratchattach/cli/cmd/__init__.py @@ -1 +1,2 @@ -from .login import login \ No newline at end of file +from .login import login +from .group import group \ No newline at end of file diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py new file mode 100644 index 00000000..b0722ac7 --- /dev/null +++ b/scratchattach/cli/cmd/group.py @@ -0,0 +1,19 @@ +from scratchattach.cli import db + + +def group(): + db.cursor.execute( + "SELECT NAME, DESCRIPTION FROM GROUPS WHERE NAME IN (SELECT * FROM CURRENT WHERE GROUP_NAME IS NOT NULL)") + result = db.cursor.fetchone() + if result is None: + print("No group selected!!") + return + + name, description = result + + db.cursor.execute("SELECT USERNAME FROM GROUP_USERS WHERE GROUP_NAME = ?", (name,)) + usernames = [name for (name,) in db.cursor.fetchall()] + + print(f"{name}\n" + f"{description}\n" + f"{usernames}") diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index 12e20685..c22d1a9c 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -1,5 +1,6 @@ """ Handles data like current session for 'sessionable' commands. +Also provides wrappers for some SQL info """ import argparse from dataclasses import dataclass, field diff --git a/scratchattach/cli/db.py b/scratchattach/cli/db.py index ad1825cc..fd869881 100644 --- a/scratchattach/cli/db.py +++ b/scratchattach/cli/db.py @@ -29,34 +29,35 @@ def _gen_appdata_folder() -> Path: # Init any tables def add_col(table: LiteralString, column: LiteralString, _type: LiteralString): try: - return cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {_type}") + return cursor.execute("ALTER TABLE ? ADD COLUMN ? ?", (table, column, _type)) except sqlite3.OperationalError as e: if "duplicate column name" not in str(e).lower(): raise # NOTE: IF YOU WANT TO ADD EXTRA KEYS TO A TABLE RETROACTIVELY, USE add_col - +# note: avoide using select * with tuple unpacking/indexing, because if fields are added, things will break. conn.execute("BEGIN") -cursor.execute(""" +cursor.executescript(""" CREATE TABLE IF NOT EXISTS SESSIONS ( ID TEXT NOT NULL, USERNAME TEXT NOT NULL PRIMARY KEY - ) -""") - -cursor.execute(""" + ); + CREATE TABLE IF NOT EXISTS GROUPS ( NAME TEXT NOT NULL PRIMARY KEY, DESCRIPTION TEXT -- If you want to add users to a group, you add to the next table - ) -""") - -cursor.execute(""" + ); + CREATE TABLE IF NOT EXISTS GROUP_USERS ( GROUP_NAME TEXT NOT NULL, USERNAME TEXT NOT NULL - ) + ); + + -- stores info like current group, last project/studio, etc + CREATE TABLE IF NOT EXISTS CURRENT ( + GROUP_NAME TEXT NOT NULL + ); """) conn.commit() diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index c1b229cf..f9e64f38 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -3,5 +3,5 @@ class ArgSpace(argparse.Namespace): - command: Optional[Literal['login']] + command: Optional[Literal['login', 'group']] sessid: bool | str From 9ef60153c3de22981c2f2ef9db408a4434e2b816 Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 12:57:46 +0100 Subject: [PATCH 12/42] feat: rich print group --- requirements.txt | 1 + scratchattach/cli/cmd/group.py | 14 +++++++++++--- scratchattach/cli/context.py | 16 +++++++++++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index a0886982..73967bf9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ SimpleWebSocketServer typing-extensions browser_cookie3 aiohttp +rich \ No newline at end of file diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index b0722ac7..0ffd6c2c 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -1,5 +1,8 @@ from scratchattach.cli import db +from scratchattach.cli.context import console, format_esc +from rich.console import escape +from rich.table import Table def group(): db.cursor.execute( @@ -14,6 +17,11 @@ def group(): db.cursor.execute("SELECT USERNAME FROM GROUP_USERS WHERE GROUP_NAME = ?", (name,)) usernames = [name for (name,) in db.cursor.fetchall()] - print(f"{name}\n" - f"{description}\n" - f"{usernames}") + table = Table(title="Current Group") + table.add_column(escape(name)) + table.add_column('Usernames') + + table.add_row(escape(description), + '\n'.join(f"{i}. {u}" for i, u in enumerate(usernames))) + + console.print(table) diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index c22d1a9c..a4edb067 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -1,9 +1,12 @@ """ Handles data like current session for 'sessionable' commands. -Also provides wrappers for some SQL info +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 + +import rich.console from typing_extensions import Optional from scratchattach.cli.namespace import ArgSpace @@ -33,3 +36,14 @@ def session(self): return self._session ctx = _Ctx() +console = rich.console.Console() + +def format_esc(text: str, *args, **kwargs) -> str: + """ + Format string with args, escaped. + """ + def esc(s): + return rich.console.escape(s) if isinstance(s, str) else s + + kwargs = {k: esc(v) for k, v in kwargs.items()} + return text.format(*map(esc, args), **kwargs) From 7c005d5a55fe60c5a1cbfc4cb4e041101a6e8a49 Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 13:06:48 +0100 Subject: [PATCH 13/42] feat: make group for new user --- scratchattach/cli/cmd/login.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scratchattach/cli/cmd/login.py b/scratchattach/cli/cmd/login.py index 9527dd82..7be3eba8 100644 --- a/scratchattach/cli/cmd/login.py +++ b/scratchattach/cli/cmd/login.py @@ -1,5 +1,6 @@ -from scratchattach.cli.context import ctx +from scratchattach.cli.context import ctx, console from scratchattach.cli import db +from rich.console import escape from getpass import getpass @@ -21,9 +22,21 @@ def login(): session = sa.login(username, password) + # register session db.conn.execute("BEGIN") db.cursor.execute( "INSERT OR REPLACE INTO SESSIONS (ID, USERNAME) " "VALUES (?, ?)", (session.id, session.username) ) db.conn.commit() + + # make new group + db.cursor.execute("SELECT NAME FROM GROUPS WHERE NAME = ?", (session.username,)) + if not db.cursor.fetchone(): + console.print(f"Registering [blue]{escape(session.username)}[/] as group") + db.conn.execute("BEGIN") + db.cursor.execute( + "INSERT INTO GROUPS (NAME, DESCRIPTION) " + "VALUES (?, ?)", (session.username, input(f"Description for {session.username}: ")) + ) + db.conn.commit() From 69b461866635f339e50b8b51251ee8dfe803607c Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 13:16:20 +0100 Subject: [PATCH 14/42] feat: group list --- scratchattach/__main__.py | 6 +++++- scratchattach/cli/cmd/group.py | 23 ++++++++++++++++++++++- scratchattach/cli/namespace.py | 2 ++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index 32a112ef..d0267147 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -7,6 +7,8 @@ from scratchattach import cli from scratchattach.cli import db, cmd + +# noinspection PyUnusedLocal def main(): parser = argparse.ArgumentParser( prog="scratch", @@ -19,7 +21,9 @@ def main(): 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") if group := commands.add_parser("group", help="View current session group"): - ... + if group_commands := group.add_subparsers(dest="group_command"): + if group_list := group_commands.add_parser("list", help="List all session groups"): + ... args = parser.parse_args(namespace=cli.ArgSpace()) cli.ctx.args = args diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index 0ffd6c2c..8283868d 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -1,10 +1,31 @@ from scratchattach.cli import db -from scratchattach.cli.context import console, format_esc +from scratchattach.cli.context import console, format_esc, ctx from rich.console import escape from rich.table import Table +def _list(): + table = Table(title="All groups") + table.add_column("Name") + table.add_column("Description") + table.add_column("Usernames") + + db.cursor.execute("SELECT NAME, DESCRIPTION FROM GROUPS") + for name, description in db.cursor.fetchall(): + 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))) + + console.print(table) + def group(): + match ctx.args.group_command: + case "list": + _list() + return + db.cursor.execute( "SELECT NAME, DESCRIPTION FROM GROUPS WHERE NAME IN (SELECT * FROM CURRENT WHERE GROUP_NAME IS NOT NULL)") result = db.cursor.fetchone() diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index f9e64f38..16a6478b 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -5,3 +5,5 @@ class ArgSpace(argparse.Namespace): command: Optional[Literal['login', 'group']] sessid: bool | str + + group_command: Optional[Literal['list']] From 3477ba0a20e647ab1cd7da4bce3d9c48f9a2d92f Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 13:20:19 +0100 Subject: [PATCH 15/42] feat: enable rich traceback --- scratchattach/__main__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index d0267147..317ea841 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -7,6 +7,10 @@ from scratchattach import cli from scratchattach.cli import db, cmd +import rich.traceback + +rich.traceback.install() + # noinspection PyUnusedLocal def main(): @@ -19,7 +23,8 @@ def main(): # Using walrus operator & ifs for artificial indentation if commands := parser.add_subparsers(dest="command"): 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"): if group_list := group_commands.add_parser("list", help="List all session groups"): @@ -37,5 +42,6 @@ def main(): case None: parser.print_help() + if __name__ == '__main__': main() From aad2b019dd0952b9af8c36ffa652a3ec2e275b3f Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 13:26:45 +0100 Subject: [PATCH 16/42] feat: add user to new automatic group --- scratchattach/cli/cmd/login.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scratchattach/cli/cmd/login.py b/scratchattach/cli/cmd/login.py index 7be3eba8..96cb2227 100644 --- a/scratchattach/cli/cmd/login.py +++ b/scratchattach/cli/cmd/login.py @@ -38,5 +38,9 @@ def login(): db.cursor.execute( "INSERT INTO GROUPS (NAME, DESCRIPTION) " "VALUES (?, ?)", (session.username, input(f"Description for {session.username}: ")) + ).execute( + "INSERT INTO GROUP_USERS (GROUP_NAME, USERNAME) " + "VALUES (?, ?)", (session.username, session.username) ) + db.conn.commit() From 571e9f922230557aeed4e236ee28f14d0fd85ea7 Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 13:40:42 +0100 Subject: [PATCH 17/42] feat: add user to current group --- scratchattach/cli/cmd/group.py | 2 +- scratchattach/cli/cmd/login.py | 15 ++++++++++++++- scratchattach/cli/context.py | 12 ++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index 8283868d..0267ad3b 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -27,7 +27,7 @@ def group(): return db.cursor.execute( - "SELECT NAME, DESCRIPTION FROM GROUPS WHERE NAME IN (SELECT * FROM CURRENT WHERE GROUP_NAME IS NOT NULL)") + "SELECT NAME, DESCRIPTION FROM GROUPS WHERE NAME = ?", (ctx.current_group_name,)) result = db.cursor.fetchone() if result is None: print("No group selected!!") diff --git a/scratchattach/cli/cmd/login.py b/scratchattach/cli/cmd/login.py index 96cb2227..3e5df2ab 100644 --- a/scratchattach/cli/cmd/login.py +++ b/scratchattach/cli/cmd/login.py @@ -22,6 +22,9 @@ def login(): session = sa.login(username, password) + console.rule() + console.print(f"Logged in as [b]{session.username}[/]") + # register session db.conn.execute("BEGIN") db.cursor.execute( @@ -33,7 +36,7 @@ def login(): # make new group db.cursor.execute("SELECT NAME FROM GROUPS WHERE NAME = ?", (session.username,)) if not db.cursor.fetchone(): - console.print(f"Registering [blue]{escape(session.username)}[/] as group") + console.rule(f"Registering [b]{escape(session.username)}[/] as group") db.conn.execute("BEGIN") db.cursor.execute( "INSERT INTO GROUPS (NAME, DESCRIPTION) " @@ -44,3 +47,13 @@ def login(): ) db.conn.commit() + + console.rule() + if input("Add to current session group? (Y/n)").lower() not in ("y", ''): + return + + db.conn.execute("BEGIN") + db.cursor.execute("INSERT INTO GROUP_USERS (GROUP_NAME, USERNAME) " + "VALUES (?, ?)", (ctx.current_group_name, session.username)) + db.conn.commit() + console.print(f"Added to [b]{escape(ctx.current_group_name)}[/]") diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index a4edb067..25e9751c 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -10,8 +10,10 @@ from typing_extensions import Optional from scratchattach.cli.namespace import ArgSpace +from scratchattach.cli import db import scratchattach as sa + @dataclass class _Ctx: args: ArgSpace = field(default_factory=ArgSpace) @@ -24,6 +26,7 @@ def sessionable(self, func): """ Decorate a command that will be run for every session in the group. """ + def wrapper(*args, **kwargs): for session in self.sessions: self._session = session @@ -35,13 +38,22 @@ def wrapper(*args, **kwargs): def session(self): return self._session + @property + def current_group_name(self): + return db.cursor \ + .execute("SELECT * FROM CURRENT WHERE GROUP_NAME IS NOT NULL") \ + .fetchone()[0] + + ctx = _Ctx() console = rich.console.Console() + def format_esc(text: str, *args, **kwargs) -> str: """ Format string with args, escaped. """ + def esc(s): return rich.console.escape(s) if isinstance(s, str) else s From 2f33dcf1eb2b4b5f605d4587094ba931d3c571b7 Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 14:03:17 +0100 Subject: [PATCH 18/42] feat: new group --- scratchattach/__main__.py | 5 +++-- scratchattach/cli/cmd/group.py | 37 +++++++++++++++++++++++++++------- scratchattach/cli/context.py | 23 +++++++++++++++++++++ scratchattach/cli/namespace.py | 3 ++- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index 317ea841..74388a9d 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -27,8 +27,9 @@ def main(): 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"): - if group_list := group_commands.add_parser("list", help="List all session groups"): - ... + group_commands.add_parser("list", help="List all session groups") + if group_new := group_commands.add_parser("new", help="Create a new group"): + group_new.add_argument("group_name") args = parser.parse_args(namespace=cli.ArgSpace()) cli.ctx.args = args diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index 0267ad3b..32652090 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -20,14 +20,27 @@ def _list(): console.print(table) -def group(): - match ctx.args.group_command: - case "list": - _list() - return +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.conn.commit() + accounts = input("Add accounts (split by space): ").split() + for account in accounts: + ctx.db_add_to_group(ctx.args.group_name, account) + + _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 = ?", (ctx.current_group_name,)) + "SELECT NAME, DESCRIPTION FROM GROUPS WHERE NAME = ?", (group_name,)) result = db.cursor.fetchone() if result is None: print("No group selected!!") @@ -38,7 +51,7 @@ def group(): db.cursor.execute("SELECT USERNAME FROM GROUP_USERS WHERE GROUP_NAME = ?", (name,)) usernames = [name for (name,) in db.cursor.fetchall()] - table = Table(title="Current Group") + table = Table(title=escape(group_name)) table.add_column(escape(name)) table.add_column('Usernames') @@ -46,3 +59,13 @@ def group(): '\n'.join(f"{i}. {u}" for i, u in enumerate(usernames))) console.print(table) + + +def group(): + match ctx.args.group_command: + case "list": + _list() + case "new": + new() + case None: + _group(ctx.current_group_name) diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index 25e9751c..22274d03 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -38,12 +38,35 @@ def wrapper(*args, **kwargs): def session(self): return self._session + # helper functions with DB + # if possible, put all db funcs here + @property def current_group_name(self): return db.cursor \ .execute("SELECT * FROM CURRENT WHERE GROUP_NAME IS NOT NULL") \ .fetchone()[0] + @staticmethod + def db_group_exists(name: str) -> bool: + return db.cursor.execute("SELECT NAME FROM GROUPS WHERE NAME = ?", (name,)).fetchone() is not None + + @staticmethod + def db_session_exists(name: str) -> bool: + return db.cursor.execute("SELECT USERNAME FROM SESSIONS WHERE USERNAME = ?", (name,)).fetchone() is not None + + @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()] + + 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.conn.commit() ctx = _Ctx() console = rich.console.Console() diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index 16a6478b..8fbbfb62 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -6,4 +6,5 @@ class ArgSpace(argparse.Namespace): command: Optional[Literal['login', 'group']] sessid: bool | str - group_command: Optional[Literal['list']] + group_command: Optional[Literal['list', 'new']] + group_name: str From 3d94345cddac11e9a246b64e2acac1fade0edc75 Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 14:11:05 +0100 Subject: [PATCH 19/42] feat: switch group --- scratchattach/__main__.py | 2 ++ scratchattach/cli/cmd/group.py | 9 +++++++++ scratchattach/cli/context.py | 7 +++++++ scratchattach/cli/namespace.py | 2 +- 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index 74388a9d..49ce603b 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -30,6 +30,8 @@ def main(): group_commands.add_parser("list", help="List all session groups") if group_new := group_commands.add_parser("new", help="Create a new group"): group_new.add_argument("group_name") + if group_switch := group_commands.add_parser("switch", help="Change the current group"): + group_switch.add_argument("group_name") args = parser.parse_args(namespace=cli.ArgSpace()) cli.ctx.args = args diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index 32652090..fff76521 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -60,6 +60,13 @@ def _group(group_name: str): 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): + raise ValueError(f"Group {escape(ctx.args.group_name)} does not exist") + + ctx.current_group_name = ctx.args.group_name + _group(ctx.current_group_name) def group(): match ctx.args.group_command: @@ -67,5 +74,7 @@ def group(): _list() case "new": new() + case "switch": + switch() case None: _group(ctx.current_group_name) diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index 22274d03..319d4ac2 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -47,6 +47,13 @@ def current_group_name(self): .execute("SELECT * FROM CURRENT WHERE GROUP_NAME IS NOT NULL") \ .fetchone()[0] + @current_group_name.setter + def current_group_name(self, value: str): + db.conn.execute("BEGIN") + db.cursor.execute("DELETE FROM CURRENT WHERE GROUP_NAME IS NOT NULL") + db.cursor.execute("INSERT INTO CURRENT (GROUP_NAME) VALUES (?)", (value,)) + db.conn.commit() + @staticmethod def db_group_exists(name: str) -> bool: return db.cursor.execute("SELECT NAME FROM GROUPS WHERE NAME = ?", (name,)).fetchone() is not None diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index 8fbbfb62..64241017 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -6,5 +6,5 @@ class ArgSpace(argparse.Namespace): command: Optional[Literal['login', 'group']] sessid: bool | str - group_command: Optional[Literal['list', 'new']] + group_command: Optional[Literal['list', 'new', 'switch']] group_name: str From 4961ede1dcbdbbb824f834f25acb1c2b13665199 Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 14:16:08 +0100 Subject: [PATCH 20/42] feat: add to group --- scratchattach/__main__.py | 1 + scratchattach/cli/cmd/group.py | 11 ++++++++--- scratchattach/cli/namespace.py | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index 49ce603b..af51b9ae 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -28,6 +28,7 @@ def main(): 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") + group_commands.add_parser("add", help="Add sessions to group") if group_new := group_commands.add_parser("new", help="Create a new group"): group_new.add_argument("group_name") if group_switch := group_commands.add_parser("switch", help="Change the current group"): diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index fff76521..99fc25f5 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -20,6 +20,11 @@ def _list(): 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 new(): console.rule(f"New group {escape(ctx.args.group_name)}") if ctx.db_group_exists(ctx.args.group_name): @@ -29,9 +34,7 @@ def new(): db.cursor.execute("INSERT INTO GROUPS (NAME, DESCRIPTION) " "VALUES (?, ?)", (ctx.args.group_name, input("Description: "))) db.conn.commit() - accounts = input("Add accounts (split by space): ").split() - for account in accounts: - ctx.db_add_to_group(ctx.args.group_name, account) + add(ctx.args.group_name) _group(ctx.args.group_name) @@ -76,5 +79,7 @@ def group(): new() case "switch": switch() + case "add": + add(ctx.current_group_name) case None: _group(ctx.current_group_name) diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index 64241017..d6e04bec 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -6,5 +6,5 @@ class ArgSpace(argparse.Namespace): command: Optional[Literal['login', 'group']] sessid: bool | str - group_command: Optional[Literal['list', 'new', 'switch']] + group_command: Optional[Literal['list', 'new', 'switch', 'add']] group_name: str From 9ed0ce011e1ba88dac544d9d62eeba61ce9d15bb Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 14:19:36 +0100 Subject: [PATCH 21/42] feat: remove from group --- scratchattach/__main__.py | 1 + scratchattach/cli/cmd/group.py | 7 +++++++ scratchattach/cli/context.py | 7 +++++++ scratchattach/cli/namespace.py | 2 +- 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index af51b9ae..e66e83cb 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -29,6 +29,7 @@ def main(): if group_commands := group.add_subparsers(dest="group_command"): group_commands.add_parser("list", help="List all session groups") group_commands.add_parser("add", help="Add sessions to group") + group_commands.add_parser("remove", help="Remove sessions from a group") if group_new := group_commands.add_parser("new", help="Create a new group"): group_new.add_argument("group_name") if group_switch := group_commands.add_parser("switch", help="Change the current group"): diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index 99fc25f5..b2463ef8 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -25,6 +25,11 @@ def add(group_name: str): 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): @@ -81,5 +86,7 @@ def group(): switch() case "add": add(ctx.current_group_name) + case "remove": + remove(ctx.current_group_name) case None: _group(ctx.current_group_name) diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index 319d4ac2..1ea06e77 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -67,6 +67,13 @@ 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()] + 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.conn.commit() + 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 diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index d6e04bec..1627d647 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -6,5 +6,5 @@ class ArgSpace(argparse.Namespace): command: Optional[Literal['login', 'group']] sessid: bool | str - group_command: Optional[Literal['list', 'new', 'switch', 'add']] + group_command: Optional[Literal['list', 'new', 'switch', 'add', 'remove']] group_name: str From 1992a7b878d8bcadef1331769b55501b2504974c Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 14:22:08 +0100 Subject: [PATCH 22/42] feat: remove format_esc --- scratchattach/cli/cmd/group.py | 2 +- scratchattach/cli/context.py | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index b2463ef8..d095c5e2 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -1,5 +1,5 @@ from scratchattach.cli import db -from scratchattach.cli.context import console, format_esc, ctx +from scratchattach.cli.context import console, ctx from rich.console import escape from rich.table import Table diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index 1ea06e77..3121098f 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -84,15 +84,3 @@ def db_add_to_group(self, group_name: str, username: str): ctx = _Ctx() console = rich.console.Console() - - -def format_esc(text: str, *args, **kwargs) -> str: - """ - Format string with args, escaped. - """ - - def esc(s): - return rich.console.escape(s) if isinstance(s, str) else s - - kwargs = {k: esc(v) for k, v in kwargs.items()} - return text.format(*map(esc, args), **kwargs) From 8929924c80700efef8c02042a0ff759e51be403f Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 15:25:11 +0100 Subject: [PATCH 23/42] feat: view profile --- scratchattach/__main__.py | 3 ++ scratchattach/cli/cmd/__init__.py | 3 +- scratchattach/cli/cmd/profile.py | 6 ++++ scratchattach/cli/context.py | 17 +++++++++-- scratchattach/cli/namespace.py | 2 +- scratchattach/site/user.py | 48 ++++++++++++++++++++++++++++++- 6 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 scratchattach/cli/cmd/profile.py diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index e66e83cb..e1421f18 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -22,6 +22,7 @@ def main(): # Using walrus operator & ifs for artificial indentation if commands := parser.add_subparsers(dest="command"): + commands.add_parser("profile", help="View your profile") 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") @@ -44,6 +45,8 @@ def main(): cmd.login() case "group": cmd.group() + case "profile": + cmd.profile() case None: parser.print_help() diff --git a/scratchattach/cli/cmd/__init__.py b/scratchattach/cli/cmd/__init__.py index edef6df7..7bd0e242 100644 --- a/scratchattach/cli/cmd/__init__.py +++ b/scratchattach/cli/cmd/__init__.py @@ -1,2 +1,3 @@ from .login import login -from .group import group \ No newline at end of file +from .group import group +from .profile import profile diff --git a/scratchattach/cli/cmd/profile.py b/scratchattach/cli/cmd/profile.py new file mode 100644 index 00000000..acb5677f --- /dev/null +++ b/scratchattach/cli/cmd/profile.py @@ -0,0 +1,6 @@ +from scratchattach.cli.context import ctx, console + +@ctx.sessionable +def profile(): + user = ctx.session.connect_linked_user() + console.print(user) diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index 3121098f..12079abb 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -18,7 +18,7 @@ class _Ctx: args: ArgSpace = field(default_factory=ArgSpace) parser: argparse.ArgumentParser = field(default_factory=argparse.ArgumentParser) - sessions: list[sa.Session] = field(default_factory=list) + _username: Optional[str] = None _session: Optional[sa.Session] = None # TODO: implement this @@ -28,14 +28,18 @@ def sessionable(self, func): """ def wrapper(*args, **kwargs): - for session in self.sessions: - self._session = session + for username in self.db_users_in_group(self.current_group_name): + self._username = username + self._session = None func(*args, **kwargs) return wrapper @property def session(self): + if not self._session: + self._session = sa.login_by_id(self.db_get_sessid(self._username)) + return self._session # helper functions with DB @@ -74,6 +78,13 @@ def db_remove_from_group(self, group_name: str, username: str): "WHERE USERNAME = ? AND GROUP_NAME = ?", (username, group_name)) db.conn.commit() + @staticmethod + def db_get_sessid(username: str) -> Optional[str]: + ret = db.cursor.execute("SELECT ID FROM SESSIONS WHERE USERNAME = ?", (username,)).fetchone() + if ret: + ret = ret[0] + return ret + 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 diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index 1627d647..fdf3a50b 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -3,7 +3,7 @@ class ArgSpace(argparse.Namespace): - command: Optional[Literal['login', 'group']] + command: Optional[Literal['login', 'group', 'profile']] sessid: bool | str group_command: Optional[Literal['list', 'new', 'switch', 'add', 'remove']] diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index f1c4aec5..82f29852 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -100,7 +100,7 @@ class User(BaseSiteComponent[typed_dicts.UserDict]): _session: Optional[session.Session] = field(kw_only=True, default=None) def __str__(self): - return str(self.username) + return f"-U {self.username}" @property def status(self) -> str: @@ -152,6 +152,52 @@ def _assert_permission(self): raise exceptions.Unauthorized( "You need to be authenticated as the profile owner to do this.") + @property + def url(self): + return f"https://scratch.mit.edu/users/{self.username}" + + def __rich__(self): + from rich.panel import Panel + from rich.table import Table + from rich import box + from rich.console import escape + from rich.layout import Layout + + ocular_data = self.ocular_status() + ocular = 'No ocular status' + + if status := ocular_data.get("status"): + color_str = '' + color_data = ocular_data.get("color") + if color_data is not None: + color_str = f"[{color_data}] ⬤ [/]" + + ocular = f"[i]{escape(status)}[/]{color_str}" + + _classroom = self.classroom + url = f"[link={self.url}]{escape(self.username)}[/]" + + info = Table(box=box.SIMPLE) + info.add_column(url, overflow="fold") + info.add_column(f"#{self.id}", overflow="fold") + + info.add_row("Joined", escape(self.join_date)) + info.add_row("Country", escape(self.country)) + info.add_row("Messages", str(self.message_count())) + info.add_row("Class", str(_classroom.title if _classroom is not None else 'None')) + + desc = Table("Profile", ocular, box=box.SIMPLE) + desc.add_row("About me", escape(self.about_me)) + desc.add_row("Wiwo", escape(self.wiwo)) + + ret = Layout() + ret.split_row( + Layout(Panel(info, title=url), ratio=2), + Layout(Panel(desc, title="Description"), ratio=5) + ) + + return ret + @property def classroom(self) -> classroom.Classroom | None: """ From 1e4500afd22e31c4d1bda90a131a994d4eaf19fa Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 15:34:24 +0100 Subject: [PATCH 24/42] feat: view user --- scratchattach/__main__.py | 13 +++++++++++++ scratchattach/cli/context.py | 9 ++++++++- scratchattach/cli/namespace.py | 3 +++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index e1421f18..58560974 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -6,6 +6,7 @@ from scratchattach import cli from scratchattach.cli import db, cmd +from scratchattach.cli.context import ctx, console import rich.traceback @@ -36,6 +37,10 @@ def main(): if group_switch := group_commands.add_parser("switch", help="Change the current group"): group_switch.add_argument("group_name") + 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") + args = parser.parse_args(namespace=cli.ArgSpace()) cli.ctx.args = args cli.ctx.parser = parser @@ -48,6 +53,14 @@ def main(): case "profile": cmd.profile() case None: + if args.username: + console.print(ctx.session.connect_user(args.username)) + return + if args.studio_id: + return + if args.project_id: + return + parser.print_help() diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index 12079abb..027a371d 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -35,10 +35,17 @@ def wrapper(*args, **kwargs): return wrapper + @property + def username(self): + if not self._username: + self._username = self.db_users_in_group(self.current_group_name)[0] + + return self._username + @property def session(self): if not self._session: - self._session = sa.login_by_id(self.db_get_sessid(self._username)) + self._session = sa.login_by_id(self.db_get_sessid(self.username)) return self._session diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index fdf3a50b..c8de5b85 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -5,6 +5,9 @@ class ArgSpace(argparse.Namespace): command: Optional[Literal['login', 'group', 'profile']] sessid: bool | str + username: Optional[str] + studio_id: Optional[str] + project_id: Optional[str] group_command: Optional[Literal['list', 'new', 'switch', 'add', 'remove']] group_name: str From 59979b43eb76856ca5efd26b85177eed294a4272 Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 15:35:23 +0100 Subject: [PATCH 25/42] fix: use markup escape, not console escape --- scratchattach/cli/cmd/group.py | 2 +- scratchattach/cli/cmd/login.py | 2 +- scratchattach/site/user.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index d095c5e2..a7bb32aa 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -1,7 +1,7 @@ from scratchattach.cli import db from scratchattach.cli.context import console, ctx -from rich.console import escape +from rich.markup import escape from rich.table import Table def _list(): diff --git a/scratchattach/cli/cmd/login.py b/scratchattach/cli/cmd/login.py index 3e5df2ab..c684594c 100644 --- a/scratchattach/cli/cmd/login.py +++ b/scratchattach/cli/cmd/login.py @@ -1,6 +1,6 @@ from scratchattach.cli.context import ctx, console from scratchattach.cli import db -from rich.console import escape +from rich.markup import escape from getpass import getpass diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index 82f29852..2d20895a 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -160,7 +160,7 @@ def __rich__(self): from rich.panel import Panel from rich.table import Table from rich import box - from rich.console import escape + from rich.markup import escape from rich.layout import Layout ocular_data = self.ocular_status() From 7aa31c68ddc5c93daeae3d9c4e583c45e2f927e8 Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 15:38:11 +0100 Subject: [PATCH 26/42] fix: get classroom for banned users there may be a workaround for this --- scratchattach/site/user.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index 2d20895a..eb793d57 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -210,6 +210,10 @@ def classroom(self) -> classroom.Classroom | None: soup = BeautifulSoup(resp.text, "html.parser") details = soup.find("p", {"class": "profile-details"}) + if details is None: + # No details, e.g. if the user is banned + return None + assert isinstance(details, Tag) class_name, class_id, is_closed = None, None, False From 8cfbcbd35351d3f44a9da17d61a0fbac35cb4105 Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 15:55:46 +0100 Subject: [PATCH 27/42] feat: display featured project --- scratchattach/site/user.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index eb793d57..7705fda9 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -162,7 +162,10 @@ def __rich__(self): from rich import box from rich.markup import escape from rich.layout import Layout + from rich.console import Group + from rich.columns import Columns + featured_data = self.featured_data() or {} ocular_data = self.ocular_status() ocular = 'No ocular status' @@ -189,15 +192,25 @@ def __rich__(self): desc = Table("Profile", ocular, box=box.SIMPLE) desc.add_row("About me", escape(self.about_me)) desc.add_row("Wiwo", escape(self.wiwo)) + desc.add_row(escape(featured_data.get("label", "Featured Project")), + escape(str(self.connect_featured_project()))) - ret = Layout() - ret.split_row( - Layout(Panel(info, title=url), ratio=2), - Layout(Panel(desc, title="Description"), ratio=5) + ret = Columns(( + Group(Panel(info, title=url)), + Group(Panel(desc, title="Description"))), + expand=True, ) return ret + def connect_featured_project(self) -> Optional[project.Project]: + data = self.featured_data() or {} + if pid := data.get("id"): + return self._session.connect_project(int(pid)) + if projs := self.projects(limit=1): + return projs[0] + return None + @property def classroom(self) -> classroom.Classroom | None: """ From 58683cb8f3bef359da1b4914c26712223b84da36 Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 16:04:01 +0100 Subject: [PATCH 28/42] fix: vertical truncation if anyone knows of a way to have horizontal alignment and the same vertical size without truncating, i would like to know --- scratchattach/cli/cmd/profile.py | 1 + scratchattach/site/user.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/scratchattach/cli/cmd/profile.py b/scratchattach/cli/cmd/profile.py index acb5677f..66971737 100644 --- a/scratchattach/cli/cmd/profile.py +++ b/scratchattach/cli/cmd/profile.py @@ -2,5 +2,6 @@ @ctx.sessionable def profile(): + console.rule() user = ctx.session.connect_linked_user() console.print(user) diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index 7705fda9..a6ee7c4d 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -195,11 +195,11 @@ def __rich__(self): desc.add_row(escape(featured_data.get("label", "Featured Project")), escape(str(self.connect_featured_project()))) - ret = Columns(( - Group(Panel(info, title=url)), - Group(Panel(desc, title="Description"))), - expand=True, - ) + ret = Table.grid(expand=True) + + ret.add_column(ratio=1) + ret.add_column(ratio=3) + ret.add_row(Panel(info, title=url), Panel(desc, title="Description")) return ret From 4b2cfc33d3cc2492047089a41b343b66b466d03c Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 19:41:49 +0100 Subject: [PATCH 29/42] feat: -S studio command --- scratchattach/__main__.py | 1 + scratchattach/site/studio.py | 57 ++++++++++++++++++++++++++++++++---- scratchattach/site/user.py | 3 -- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index 58560974..e558467c 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -57,6 +57,7 @@ def main(): console.print(ctx.session.connect_user(args.username)) return if args.studio_id: + console.print(ctx.session.connect_studio(args.studio_id)) return if args.project_id: return diff --git a/scratchattach/site/studio.py b/scratchattach/site/studio.py index a9eaf93f..37027a86 100644 --- a/scratchattach/site/studio.py +++ b/scratchattach/site/studio.py @@ -55,6 +55,7 @@ def __init__(self, **entries): # Set attributes every Project object needs to have: self._session = None self.id = 0 + self.title = None # Update attributes from entries dict: self.__dict__.update(entries) @@ -79,11 +80,11 @@ def _update_from_dict(self, studio): except Exception: pass try: self.description = studio["description"] except Exception: pass - try: self.host_id = studio["host"] + try: self.host_id: int = studio["host"] except Exception: pass - try: self.open_to_all = studio["open_to_all"] + try: self.open_to_all: bool = studio["open_to_all"] except Exception: pass - try: self.comments_allowed = studio["comments_allowed"] + try: self.comments_allowed: bool = studio["comments_allowed"] except Exception: pass try: self.image_url = studio["image"] except Exception: pass @@ -91,14 +92,58 @@ def _update_from_dict(self, studio): except Exception: pass try: self.modified = studio["history"]["modified"] except Exception: pass - try: self.follower_count = studio["stats"]["followers"] + try: self.follower_count: int = studio["stats"]["followers"] except Exception: pass - try: self.manager_count = studio["stats"]["managers"] + try: self.manager_count: int = studio["stats"]["managers"] except Exception: pass - try: self.project_count = studio["stats"]["projects"] + try: self.project_count: int = studio["stats"]["projects"] except Exception: pass return True + def __str__(self): + ret = f"-S {self.id}" + if self.title: + ret += f" ({self.title})" + return ret + + def __rich__(self): + from rich.panel import Panel + from rich.table import Table + from rich import box + from rich.markup import escape + + url = f"[link={self.url}]{escape(self.title)}[/]" + + ret = Table.grid(expand=True) + ret.add_column(ratio=1) + ret.add_column(ratio=3) + + info = Table(box=box.SIMPLE) + info.add_column(url, overflow="fold") + info.add_column(f"#{self.id}", overflow="fold") + info.add_row("Host ID", str(self.host_id)) + info.add_row("Followers", str(self.follower_count)) + info.add_row("Projects", str(self.project_count)) + info.add_row("Managers", str(self.manager_count)) + info.add_row("Comments allowed", str(self.comments_allowed)) + info.add_row("Open", str(self.open_to_all)) + info.add_row("Created", self.created) + info.add_row("Modified", self.modified) + + desc = Table(box=box.SIMPLE) + desc.add_row("Description", escape(self.description)) + + ret.add_row( + Panel(info, title=url), + Panel(desc, title="Description"), + ) + + return ret + + @property + def url(self): + return f"https://scratch.mit.edu/studios/{self.id}" + def follow(self): """ You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_studio` diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index a6ee7c4d..d8c090b1 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -161,9 +161,6 @@ def __rich__(self): from rich.table import Table from rich import box from rich.markup import escape - from rich.layout import Layout - from rich.console import Group - from rich.columns import Columns featured_data = self.featured_data() or {} ocular_data = self.ocular_status() From 6112a4bdf056b06725b586ad1f441003aca74445 Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 19:56:23 +0100 Subject: [PATCH 30/42] feat: -P project command --- scratchattach/__main__.py | 1 + scratchattach/site/project.py | 36 ++++++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index e558467c..1c604699 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -60,6 +60,7 @@ def main(): console.print(ctx.session.connect_studio(args.studio_id)) return if args.project_id: + console.print(ctx.session.connect_project(args.project_id)) return parser.print_help() diff --git a/scratchattach/site/project.py b/scratchattach/site/project.py index af4110bb..6a6cb1a7 100644 --- a/scratchattach/site/project.py +++ b/scratchattach/site/project.py @@ -145,6 +145,40 @@ def _update_from_dict(self, data): return False return True + def __rich__(self): + from rich.panel import Panel + from rich.table import Table + from rich import box + from rich.markup import escape + + url = f"[link={self.url}]{self.title}[/]" + + ret = Table.grid(expand=True) + ret.add_column(ratio=1) + ret.add_column(ratio=3) + + info = Table(box=box.SIMPLE) + info.add_column(url, overflow="fold") + info.add_column(f"#{self.id}", overflow="fold") + + info.add_row("By", self.author_name) + info.add_row("Created", escape(self.created)) + info.add_row("Shared", escape(self.share_date)) + info.add_row("Modified", escape(self.last_modified)) + info.add_row("Comments allowed", escape(str(self.comments_allowed))) + info.add_row("Loves", str(self.loves)) + info.add_row("Faves", str(self.favorites)) + info.add_row("Remixes", str(self.remix_count)) + info.add_row("Views", str(self.views)) + + desc = Table(box=box.SIMPLE) + desc.add_row("Instructions", escape(self.instructions)) + desc.add_row("Notes & Credits", escape(self.notes)) + + ret.add_row(Panel(info, title=url), Panel(desc, title="Description")) + + return ret + @property def embed_url(self): """ @@ -273,7 +307,7 @@ class Project(PartialProject): """ def __str__(self): - return str(self.title) + return f"-P {self.id} ({self.title})" def _assert_permission(self): self._assert_auth() From 5cafc34b0c4ab3c341d4f673c684e51672e7158a Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 20:23:15 +0100 Subject: [PATCH 31/42] feat: print user pfp --- requirements.txt | 3 ++- scratchattach/__main__.py | 4 +++- scratchattach/cli/__init__.py | 20 ++++++++++++++++++++ scratchattach/site/user.py | 5 +++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 73967bf9..5bf0c0cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ SimpleWebSocketServer typing-extensions browser_cookie3 aiohttp -rich \ No newline at end of file +rich +# install rich-pixels if you want CLI image support. This is an optional dependency. diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index 1c604699..a866217c 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -54,7 +54,9 @@ def main(): cmd.profile() case None: if args.username: - console.print(ctx.session.connect_user(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: console.print(ctx.session.connect_studio(args.studio_id)) diff --git a/scratchattach/cli/__init__.py b/scratchattach/cli/__init__.py index 68f9417a..2c9cf540 100644 --- a/scratchattach/cli/__init__.py +++ b/scratchattach/cli/__init__.py @@ -1,6 +1,26 @@ """ Only for use within __main__.py """ +import io + +from rich.console import RenderableType +from typing_extensions import Optional from scratchattach.cli.__about__ import VERSION from scratchattach.cli.namespace import ArgSpace from scratchattach.cli.context import ctx + + +# noinspection PyPackageRequirements +def try_get_img(image: bytes, size: tuple[int, int] | None = None) -> Optional[RenderableType]: + try: + from PIL import Image + from rich_pixels import Pixels + + with Image.open(io.BytesIO(image)) as image: + if size is not None: + image = image.resize(size) + + return Pixels.from_image(image) + + except ImportError: + return "" diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index d8c090b1..759788d1 100644 --- a/scratchattach/site/user.py +++ b/scratchattach/site/user.py @@ -110,6 +110,11 @@ def status(self) -> str: def bio(self) -> str: return self.about_me + @property + def icon(self) -> bytes: + with requests.no_error_handling(): + return requests.get(self.icon_url).content + @property def name(self) -> str: return self.username From 7f8f6696b51a929c62f5458eddfc4f646b150d7a Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 20:27:04 +0100 Subject: [PATCH 32/42] feat: print studio image --- scratchattach/__main__.py | 4 +++- scratchattach/site/studio.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index a866217c..57d92964 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -59,7 +59,9 @@ def main(): console.print(user) return if args.studio_id: - console.print(ctx.session.connect_studio(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: console.print(ctx.session.connect_project(args.project_id)) diff --git a/scratchattach/site/studio.py b/scratchattach/site/studio.py index 37027a86..309b6571 100644 --- a/scratchattach/site/studio.py +++ b/scratchattach/site/studio.py @@ -86,7 +86,7 @@ def _update_from_dict(self, studio): except Exception: pass try: self.comments_allowed: bool = studio["comments_allowed"] except Exception: pass - try: self.image_url = studio["image"] + try: self.image_url = studio["image"] # rename/alias to thumbnail_url? except Exception: pass try: self.created = studio["history"]["created"] except Exception: pass @@ -144,6 +144,11 @@ def __rich__(self): def url(self): return f"https://scratch.mit.edu/studios/{self.id}" + @property + def thumbnail(self) -> bytes: + with requests.no_error_handling(): + return requests.get(self.image_url).content + def follow(self): """ You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_studio` From 9c746a128d0bfaecf86835b2c0cb8d3f8b768cab Mon Sep 17 00:00:00 2001 From: faretek Date: Sun, 5 Oct 2025 20:31:12 +0100 Subject: [PATCH 33/42] feat: print project image --- scratchattach/__main__.py | 4 +++- scratchattach/site/project.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index 57d92964..5d15409f 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -64,7 +64,9 @@ def main(): console.print(studio) return if args.project_id: - console.print(ctx.session.connect_project(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 parser.print_help() diff --git a/scratchattach/site/project.py b/scratchattach/site/project.py index 6a6cb1a7..2dac940c 100644 --- a/scratchattach/site/project.py +++ b/scratchattach/site/project.py @@ -309,6 +309,11 @@ class Project(PartialProject): def __str__(self): return f"-P {self.id} ({self.title})" + @property + def thumbnail(self) -> bytes: + with requests.no_error_handling(): + return requests.get(self.thumbnail_url).content + def _assert_permission(self): self._assert_auth() if self._session._username != self.author_name: From 0dc55ab3bedd43f8c033c0d598a618fb4d36c264 Mon Sep 17 00:00:00 2001 From: retek <107722825+faretek1@users.noreply.github.com> Date: Thu, 9 Oct 2025 20:19:16 +0100 Subject: [PATCH 34/42] chore: sync cli with main (#520) --- scratchattach/other/other_apis.py | 375 ++++++++++++++++++++++++------ tests/test_other_apis.py | 61 +++++ 2 files changed, 369 insertions(+), 67 deletions(-) create mode 100644 tests/test_other_apis.py diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index ff6cecf5..6253ae7a 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -8,7 +8,7 @@ from scratchattach.utils.enums import Languages, Language, TTSVoices, TTSVoice from scratchattach.utils.exceptions import BadRequest, InvalidLanguage, InvalidTTSGender from scratchattach.utils.requests import requests -from typing import Optional +from typing import Optional, TypedDict # --- Front page --- @@ -17,7 +17,7 @@ def get_news(*, limit=10, offset=0): return commons.api_iterative("https://api.scratch.mit.edu/news", limit=limit, offset=offset) -def featured_data(): +def featured_data() -> dict[str, list[dict[str, str | int]]]: return requests.get("https://api.scratch.mit.edu/proxy/featured").json() @@ -51,19 +51,322 @@ def design_studio_projects(): # --- Statistics --- -def total_site_stats(): +class TotalSiteStats(TypedDict): + PROJECT_COUNT: int + USER_COUNT: int + STUDIO_COMMENT_COUNT: int + PROFILE_COMMENT_COUNT: int + STUDIO_COUNT: int + COMMENT_COUNT: int + PROJECT_COMMENT_COUNT: int + + +def total_site_stats() -> TotalSiteStats: data = requests.get("https://scratch.mit.edu/statistics/data/daily/").json() data.pop("_TS") return data -def monthly_site_traffic(): +class MonthlySiteTraffic(TypedDict): + pageviews: str + users: str + sessions: str + + +def monthly_site_traffic() -> MonthlySiteTraffic: data = requests.get("https://scratch.mit.edu/statistics/data/monthly-ga/").json() data.pop("_TS") return data -def country_counts(): +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: return requests.get("https://scratch.mit.edu/statistics/data/monthly/").json()["country_distribution"] @@ -139,68 +442,6 @@ def get_resource_urls(): return requests.get("https://resources.scratch.mit.edu/localized-urls.json").json() -# --- ScratchTools endpoints --- -def scratchtools_online_status(username: str) -> bool | None: - """ - Get the online status of an account. - :return: Boolean whether the account is online; if they do not use scratchtools, return None. - """ - data = requests.get(f"https://data.scratchtools.app/isonline/{username}").json() - - if data["scratchtools"]: - return data["online"] - else: - return None - - -def scratchtools_beta_user(username: str) -> bool: - """ - Get whether a user is a scratchtools beta tester (I think that's what it means) - """ - return requests.get(f"https://data.scratchtools.app/isbeta/{username}").json()["beta"] - - -def scratchtools_display_name(username: str) -> str | None: - """ - Get the display name of a user for scratchtools. Returns none if there is no display name or the username is invalid - """ - return requests.get(f"https://data.scratchtools.app/name/{username}").json().get("displayName") - - -@dataclass(init=True, repr=True) -class ScratchToolsTutorial: - title: str - description: str = field(repr=False) - id: str - - @classmethod - def from_json(cls, data: dict[str, str]) -> ScratchToolsTutorial: - return cls(**data) - - @property - def yt_link(self): - return f"https://www.youtube.com/watch?v={self.id}" - - -def scratchtools_tutorials() -> list[ScratchToolsTutorial]: - """ - Returns a list of scratchtools tutorials (just yt videos) - """ - data_list = requests.get("https://data.scratchtools.app/tutorials/").json() - return [ScratchToolsTutorial.from_json(data) for data in data_list] - - -def scratchtools_emoji_status(username: str) -> str | None: - return requests.get(f"https://data.scratchtools.app/status/{username}").json().get("status", - '🍪') # Cookie is the default status, even if the user does not use ScratchTools - - -def scratchtools_pinned_comment(project_id: int) -> dict[str, str | int]: - data = requests.get(f"https://data.scratchtools.app/pinned/{project_id}/").json() - # Maybe use this info to instantiate a partial comment object? - return data - - # --- Misc --- # I'm not sure what to label this as def scratch_team_members() -> dict: diff --git a/tests/test_other_apis.py b/tests/test_other_apis.py new file mode 100644 index 00000000..5f9942e3 --- /dev/null +++ b/tests/test_other_apis.py @@ -0,0 +1,61 @@ +import pprint +import sys +from datetime import datetime, timedelta, timezone +import warnings + + +def test_activity(): + sys.path.insert(0, ".") + import scratchattach as sa + from scratchattach.utils import exceptions + import util + + news = sa.get_news() + found_wiki_wednesday = False + for newsitem in news: + if newsitem["headline"] == "Wiki Wednesday!": + found_wiki_wednesday = True + if not found_wiki_wednesday: + warnings.warn(f"Did not find wiki wednesday! News dict: {news}") + + featured_projects = sa.featured_projects() + featured_studios = sa.featured_studios() + top_loved = sa.top_loved() + top_remixed = sa.top_remixed() + newest_projects = sa.newest_projects() + curated_projects = sa.curated_projects() + design_studio_projects = sa.design_studio_projects() + + def test_featured_data(name, data: list[dict[str, str | int]]): + if not data: + warnings.warn(f"Did not find {name}! {data}") + + test_featured_data("featured featured_projects", featured_projects) + test_featured_data("featured featured_studios", featured_studios) + test_featured_data("featured top_loved", top_loved) + test_featured_data("featured top_remixed", top_remixed) + test_featured_data("featured newest_projects", newest_projects) + test_featured_data("featured curated_projects", curated_projects) + test_featured_data("featured design_studio_projects", design_studio_projects) + + stats = sa.total_site_stats() + assert stats["PROJECT_COUNT"] >= 164307034 + assert stats["USER_COUNT"] >= 135078559 + assert stats["STUDIO_COMMENT_COUNT"] >= 259801679 + assert stats["PROFILE_COMMENT_COUNT"] >= 330786513 + assert stats["STUDIO_COUNT"] >= 34866300 + assert stats["COMMENT_COUNT"] >= 989937824 + assert stats["PROJECT_COMMENT_COUNT"] >= 399349632 + + site_traffic = sa.monthly_site_traffic() + assert int(site_traffic["pageviews"]) > 10000 + assert int(site_traffic["users"]) > 10000 + assert int(site_traffic["sessions"]) > 10000 + + country_counts = sa.country_counts() + for name, count in country_counts.items(): + assert count > 0, f"country_counts[{name!r}] = {count}" + + +if __name__ == "__main__": + test_activity() From c4e38a389d79350274d8a86d23cda24870923ae3 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 11 Oct 2025 17:33:11 +0100 Subject: [PATCH 35/42] feat: delete group --- scratchattach/__main__.py | 1 + scratchattach/cli/cmd/group.py | 12 ++++++++++++ scratchattach/cli/context.py | 11 +++++++++++ scratchattach/cli/namespace.py | 2 +- 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index 5d15409f..bd78ff17 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -32,6 +32,7 @@ def main(): group_commands.add_parser("list", help="List all session groups") group_commands.add_parser("add", help="Add sessions to group") group_commands.add_parser("remove", help="Remove sessions from a group") + group_commands.add_parser("delete", help="Delete current group") if group_new := group_commands.add_parser("new", help="Create a new group"): group_new.add_argument("group_name") if group_switch := group_commands.add_parser("switch", help="Change the current group"): diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index a7bb32aa..6f715118 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -76,6 +76,14 @@ 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): + raise ValueError(f"Group {group_name} does not exist") + + ctx.db_group_delete(group_name) + + def group(): match ctx.args.group_command: case "list": @@ -88,5 +96,9 @@ def group(): add(ctx.current_group_name) case "remove": remove(ctx.current_group_name) + case "delete": + if input("Are you sure? (y/N): ").lower() != "y": + return + delete(ctx.current_group_name) case None: _group(ctx.current_group_name) diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index 027a371d..90b73553 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -100,5 +100,16 @@ def db_add_to_group(self, group_name: str, username: str): "VALUES (?, ?)", (group_name, username)) db.conn.commit() + @staticmethod + def db_group_delete(group_name: str): + db.conn.execute("BEGIN") + # delete links to sessions first + db.cursor.execute("DELETE FROM GROUP_USERS WHERE GROUP_NAME = ?", (group_name,)) + # delete group itself + db.cursor.execute("DELETE FROM GROUPS WHERE NAME = ?", (group_name,)) + db.conn.commit() + + + ctx = _Ctx() console = rich.console.Console() diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index c8de5b85..8da49d2c 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -9,5 +9,5 @@ class ArgSpace(argparse.Namespace): studio_id: Optional[str] project_id: Optional[str] - group_command: Optional[Literal['list', 'new', 'switch', 'add', 'remove']] + group_command: Optional[Literal['list', 'new', 'switch', 'add', 'remove', 'delete']] group_name: str From 169e0f8c61745b5788f206bb71200c7b75291dc3 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 11 Oct 2025 17:47:33 +0100 Subject: [PATCH 36/42] feat: copy group --- scratchattach/__main__.py | 2 ++ scratchattach/cli/cmd/group.py | 9 +++++++++ scratchattach/cli/context.py | 12 ++++++++++++ scratchattach/cli/namespace.py | 2 +- 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index bd78ff17..6455e64b 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -33,6 +33,8 @@ def main(): group_commands.add_parser("add", help="Add sessions to group") group_commands.add_parser("remove", help="Remove sessions from a group") group_commands.add_parser("delete", help="Delete current group") + if group_copy := group_commands.add_parser("copy", help="Copy current group with a new name"): + group_copy.add_argument("group_name", help="New group name") if group_new := group_commands.add_parser("new", help="Create a new group"): group_new.add_argument("group_name") if group_switch := group_commands.add_parser("switch", help="Change the current group"): diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index 6f715118..de8f4d83 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -83,6 +83,13 @@ 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): + raise ValueError(f"Group {group_name} does not exist") + + ctx.db_group_copy(group_name, new_name) + def group(): match ctx.args.group_command: @@ -100,5 +107,7 @@ def group(): if input("Are you sure? (y/N): ").lower() != "y": return delete(ctx.current_group_name) + case "copy": + copy(ctx.current_group_name, ctx.args.group_name) case None: _group(ctx.current_group_name) diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index 90b73553..3d126800 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -109,6 +109,18 @@ def db_group_delete(group_name: str): db.cursor.execute("DELETE FROM GROUPS WHERE NAME = ?", (group_name,)) db.conn.commit() + @staticmethod + 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,)) + # 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.conn.commit() + ctx = _Ctx() diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index 8da49d2c..daac9d05 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -9,5 +9,5 @@ class ArgSpace(argparse.Namespace): studio_id: Optional[str] project_id: Optional[str] - group_command: Optional[Literal['list', 'new', 'switch', 'add', 'remove', 'delete']] + group_command: Optional[Literal['list', 'new', 'switch', 'add', 'remove', 'delete', 'copy']] group_name: str From ad401ef599cd2d5e71eb1b643670c31825a29890 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 11 Oct 2025 17:51:20 +0100 Subject: [PATCH 37/42] fix: switch group on deletion, and don't delete last group --- scratchattach/cli/cmd/group.py | 6 ++++++ scratchattach/cli/context.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index de8f4d83..16207e12 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -80,8 +80,14 @@ def delete(group_name: str): print(f"Deleting {group_name}") if not ctx.db_group_exists(group_name): raise ValueError(f"Group {group_name} does not exist") + if ctx.db_group_count == 1: + raise ValueError(f"Make another group first") ctx.db_group_delete(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 copy(group_name: str, new_name: str): print(f"Copying {group_name} as {new_name}") diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index 3d126800..23c4b8cf 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -121,6 +121,16 @@ def db_group_copy(group_name: str, copy_name: str): db.conn.commit() + @property + def db_first_group_name(self) -> str: + """Just get a group, I don't care which""" + db.cursor.execute("SELECT NAME FROM GROUPS") + return db.cursor.fetchone()[0] + + @property + def db_group_count(self): + db.cursor.execute("SELECT COUNT(*) FROM GROUPS") + return db.cursor.fetchone()[0] ctx = _Ctx() From 2d8a1c8456749427ef3e5d44ba541ca02c62fb61 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 11 Oct 2025 17:56:54 +0100 Subject: [PATCH 38/42] feat: group rename --- scratchattach/__main__.py | 2 ++ scratchattach/cli/cmd/group.py | 16 ++++++++++++---- scratchattach/cli/namespace.py | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index 6455e64b..2474d382 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -35,6 +35,8 @@ def main(): group_commands.add_parser("delete", help="Delete current group") if group_copy := group_commands.add_parser("copy", help="Copy current group with a new name"): group_copy.add_argument("group_name", help="New group name") + if group_rename := group_commands.add_parser("rename", help="Rename current group"): + group_rename.add_argument("group_name", help="New group name") if group_new := group_commands.add_parser("new", help="Create a new group"): group_new.add_argument("group_name") if group_switch := group_commands.add_parser("switch", help="Change the current group"): diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py index 16207e12..b28c5685 100644 --- a/scratchattach/cli/cmd/group.py +++ b/scratchattach/cli/cmd/group.py @@ -84,10 +84,6 @@ def delete(group_name: str): raise ValueError(f"Make another group first") ctx.db_group_delete(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 copy(group_name: str, new_name: str): print(f"Copying {group_name} as {new_name}") @@ -96,6 +92,9 @@ 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 group(): match ctx.args.group_command: @@ -113,7 +112,16 @@ def group(): 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) + case "copy": copy(ctx.current_group_name, ctx.args.group_name) + case "rename": + rename(ctx.current_group_name, ctx.args.group_name) + ctx.current_group_name = ctx.args.group_name + _group(ctx.args.group_name) case None: _group(ctx.current_group_name) diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index daac9d05..861bd9bb 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -9,5 +9,5 @@ class ArgSpace(argparse.Namespace): studio_id: Optional[str] project_id: Optional[str] - group_command: Optional[Literal['list', 'new', 'switch', 'add', 'remove', 'delete', 'copy']] + group_command: Optional[Literal['list', 'new', 'switch', 'add', 'remove', 'delete', 'copy', 'rename']] group_name: str From f7802781b9df6cc611b064b621323f283246328b Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 11 Oct 2025 18:17:00 +0100 Subject: [PATCH 39/42] feat: list sessions Also changed __str__ representation. This should be implemented into CLI before publish --- scratchattach/__main__.py | 3 ++ scratchattach/cli/cmd/__init__.py | 1 + scratchattach/cli/cmd/sessions.py | 5 ++++ scratchattach/cli/namespace.py | 2 +- scratchattach/site/session.py | 48 ++++++++++++++++++++++++------- 5 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 scratchattach/cli/cmd/sessions.py diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index 2474d382..fa120db3 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -24,6 +24,7 @@ def main(): # Using walrus operator & ifs for artificial indentation 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") @@ -51,6 +52,8 @@ def main(): cli.ctx.parser = parser match args.command: + case "sessions": + cmd.sessions() case "login": cmd.login() case "group": diff --git a/scratchattach/cli/cmd/__init__.py b/scratchattach/cli/cmd/__init__.py index 7bd0e242..4316c131 100644 --- a/scratchattach/cli/cmd/__init__.py +++ b/scratchattach/cli/cmd/__init__.py @@ -1,3 +1,4 @@ from .login import login from .group import group from .profile import profile +from .sessions import sessions diff --git a/scratchattach/cli/cmd/sessions.py b/scratchattach/cli/cmd/sessions.py new file mode 100644 index 00000000..cdf0f4d6 --- /dev/null +++ b/scratchattach/cli/cmd/sessions.py @@ -0,0 +1,5 @@ +from scratchattach.cli.context import ctx, console + +@ctx.sessionable +def sessions(): + console.print(ctx.session) diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index 861bd9bb..f691616b 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -3,7 +3,7 @@ class ArgSpace(argparse.Namespace): - command: Optional[Literal['login', 'group', 'profile']] + command: Optional[Literal['login', 'group', 'profile', 'sessions']] sessid: bool | str username: Optional[str] studio_id: Optional[str] diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index 88526fe6..2afe0082 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -61,7 +61,7 @@ def enforce_ratelimit(__type: str, name: str, amount: int = 5, duration: int = 6 "Don't spam-create studios or similar, it WILL get you banned." ) -C = TypeVar("C", bound=BaseSiteComponent) +C = TypeVar("C", bound=BaseSiteComponent) @dataclass class Session(BaseSiteComponent): @@ -91,14 +91,40 @@ class Session(BaseSiteComponent): time_created: datetime.datetime = field(repr=False, default=datetime.datetime.fromtimestamp(0.0)) language: str = field(repr=False, default="en") - + has_outstanding_email_confirmation: bool = field(repr=False, default=False) is_teacher: bool = field(repr=False, default=False) is_teacher_invitee: bool = field(repr=False, default=False) _session: Optional[Session] = field(kw_only=True, default=None) def __str__(self) -> str: - return f"" + return f"-L {self.username}" + + def __rich__(self): + from rich.panel import Panel + from rich.table import Table + from rich import box + from rich.markup import escape + + try: + self.update() + except KeyError as e: + warnings.warn(f"Ignored KeyError: {e}") + + ret = Table( + f"[link={self.connect_linked_user().url}]{escape(self.username)}[/]", + f"Created: {self.time_created}", expand=True) + + ret.add_row("Email", escape(str(self.email))) + ret.add_row("Language", escape(str(self.language))) + ret.add_row("Mute status", escape(str(self.mute_status))) + ret.add_row("New scratcher?", str(self.new_scratcher)) + ret.add_row("Banned?", str(self.banned)) + ret.add_row("Has outstanding email confirmation?", str(self.has_outstanding_email_confirmation)) + ret.add_row("Is teacher invitee?", str(self.is_teacher_invitee)) + ret.add_row("Is teacher?", str(self.is_teacher)) + + return ret @property def _username(self) -> str: @@ -121,14 +147,14 @@ def __post_init__(self): if self.id: self._process_session_id() - + self._session = self def _update_from_dict(self, data: Union[dict, typed_dicts.SessionDict]): # Note: there are a lot more things you can get from this data dict. # Maybe it would be a good idea to also store the dict itself? # self.data = data - + data = cast(typed_dicts.SessionDict, data) self.xtoken = data['user']['token'] @@ -142,7 +168,7 @@ def _update_from_dict(self, data: Union[dict, typed_dicts.SessionDict]): self.is_teacher = data["permissions"]["educator"] self.is_teacher_invitee = data["permissions"]["educator_invitee"] - self.mute_status = data["permissions"]["mute_status"] + self.mute_status: dict = data["permissions"]["mute_status"] self.username = data["user"]["username"] self.banned = data["user"]["banned"] @@ -577,7 +603,7 @@ def create_studio(self, *, title: Optional[str] = None, description: Optional[st To prevent accidental spam, a rate limit (5 studios per minute) is implemented for this function. """ enforce_ratelimit("create_scratch_studio", "creating Scratch studios") - + if self.new_scratcher: raise exceptions.Unauthorized(f"\nNew scratchers (like {self.username}) cannot create studios.") @@ -781,7 +807,7 @@ def become_scratcher_invite(self) -> dict: # --- Connect classes inheriting from BaseCloud --- - + @overload def connect_cloud(self, project_id, *, cloud_class: type[T]) -> T: """ @@ -796,7 +822,7 @@ def connect_cloud(self, project_id, *, cloud_class: type[T]) -> T: Returns: Type[scratchattach.cloud._base.BaseCloud]: An object representing the cloud of a project. Can be of any class inheriting from BaseCloud. """ - + @overload def connect_cloud(self, project_id) -> cloud.ScratchCloud: """ @@ -1042,10 +1068,10 @@ def connect_filterbot(self, *, log_deletions=True) -> filterbot.Filterbot: def get_session_string(self) -> str: assert self.session_string return self.session_string - + def get_headers(self) -> dict[str, str]: return self._headers - + def get_cookies(self) -> dict[str, str]: return self._cookies From 6dc32d43130c5ee80c9a41f1c0f79bcfa8d731c5 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 11 Oct 2025 18:40:22 +0100 Subject: [PATCH 40/42] feat: print session by name --- scratchattach/__main__.py | 9 +++++++++ scratchattach/cli/context.py | 5 +++++ scratchattach/cli/namespace.py | 1 + setup.py | 2 +- 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scratchattach/__main__.py b/scratchattach/__main__.py index fa120db3..f14ff463 100644 --- a/scratchattach/__main__.py +++ b/scratchattach/__main__.py @@ -46,6 +46,8 @@ 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") args = parser.parse_args(namespace=cli.ArgSpace()) cli.ctx.args = args @@ -76,6 +78,13 @@ def main(): 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() diff --git a/scratchattach/cli/context.py b/scratchattach/cli/context.py index 23c4b8cf..7d61f184 100644 --- a/scratchattach/cli/context.py +++ b/scratchattach/cli/context.py @@ -132,6 +132,11 @@ def db_group_count(self): db.cursor.execute("SELECT COUNT(*) FROM GROUPS") return db.cursor.fetchone()[0] + def db_get_sess(self, sess_name: str): + if sess_id := self.db_get_sessid(sess_name): + return sa.login_by_id(sess_id) + return None + ctx = _Ctx() console = rich.console.Console() diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py index f691616b..b7187bbc 100644 --- a/scratchattach/cli/namespace.py +++ b/scratchattach/cli/namespace.py @@ -8,6 +8,7 @@ class ArgSpace(argparse.Namespace): username: Optional[str] studio_id: Optional[str] project_id: Optional[str] + session_name: Optional[str] group_command: Optional[Literal['list', 'new', 'switch', 'add', 'remove', 'delete', 'copy', 'rename']] group_name: str diff --git a/setup.py b/setup.py index 71e8c086..5b480908 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import codecs import os -VERSION = '2.1.13' +VERSION = '2.1.13' # consider updating the CLI version number too DESCRIPTION = 'A Scratch API Wrapper' with open('README.md', encoding='utf-8') as f: LONG_DESCRIPTION = f.read() From ae7585d69f364fb07e9bd37a5703cf2d5799c832 Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 11 Oct 2025 19:05:43 +0100 Subject: [PATCH 41/42] feat: add password column to sessions --- scratchattach/cli/db.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scratchattach/cli/db.py b/scratchattach/cli/db.py index fd869881..948af157 100644 --- a/scratchattach/cli/db.py +++ b/scratchattach/cli/db.py @@ -29,7 +29,8 @@ def _gen_appdata_folder() -> Path: # Init any tables def add_col(table: LiteralString, column: LiteralString, _type: LiteralString): try: - return cursor.execute("ALTER TABLE ? ADD COLUMN ? ?", (table, column, _type)) + # strangely, using `?` here doesn't seem to work. Make sure to use LiteralStrings, not str + return cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {_type}") except sqlite3.OperationalError as e: if "duplicate column name" not in str(e).lower(): raise @@ -59,5 +60,7 @@ def add_col(table: LiteralString, column: LiteralString, _type: LiteralString): GROUP_NAME TEXT NOT NULL ); """) +add_col("SESSIONS", "PASSWORD", "TEXT") + conn.commit() From 4f32543ae37afd8d2a2a6430eb3b06ccc4fe263a Mon Sep 17 00:00:00 2001 From: faretek Date: Sat, 11 Oct 2025 19:08:11 +0100 Subject: [PATCH 42/42] feat: login with password saves password --- scratchattach/cli/cmd/login.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scratchattach/cli/cmd/login.py b/scratchattach/cli/cmd/login.py index c684594c..b0137a1b 100644 --- a/scratchattach/cli/cmd/login.py +++ b/scratchattach/cli/cmd/login.py @@ -16,6 +16,7 @@ def login(): ctx.args.sessid = getpass("Session ID: ") session = sa.login_by_id(ctx.args.sessid) + password = None else: username = input("Username: ") password = getpass() @@ -28,8 +29,8 @@ def login(): # register session db.conn.execute("BEGIN") db.cursor.execute( - "INSERT OR REPLACE INTO SESSIONS (ID, USERNAME) " - "VALUES (?, ?)", (session.id, session.username) + "INSERT OR REPLACE INTO SESSIONS (ID, USERNAME, PASSWORD) " + "VALUES (?, ?, ?)", (session.id, session.username, password) ) db.conn.commit()