diff --git a/requirements.txt b/requirements.txt index a0886982..5bf0c0cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,5 @@ SimpleWebSocketServer typing-extensions browser_cookie3 aiohttp +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 new file mode 100644 index 00000000..f14ff463 --- /dev/null +++ b/scratchattach/__main__.py @@ -0,0 +1,93 @@ +""" +Scratchattach CLI. Most source code is in the `cli` directory +""" + +import argparse + +from scratchattach import cli +from scratchattach.cli import db, cmd +from scratchattach.cli.context import ctx, console + +import rich.traceback + +rich.traceback.install() + + +# noinspection PyUnusedLocal +def main(): + parser = argparse.ArgumentParser( + prog="scratch", + description="Scratchattach CLI", + epilog=f"Running scratchattach CLI version {cli.VERSION}", + ) + + # 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") + 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") + 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_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"): + 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") + 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 + cli.ctx.parser = parser + + match args.command: + case "sessions": + cmd.sessions() + case "login": + cmd.login() + case "group": + cmd.group() + case "profile": + cmd.profile() + case None: + if args.username: + user = ctx.session.connect_user(args.username) + console.print(cli.try_get_img(user.icon, (30, 30))) + console.print(user) + return + if args.studio_id: + studio = ctx.session.connect_studio(args.studio_id) + console.print(cli.try_get_img(studio.thumbnail, (34, 20))) + console.print(studio) + return + if args.project_id: + project = ctx.session.connect_project(args.project_id) + console.print(cli.try_get_img(project.thumbnail, (30, 23))) + console.print(project) + return + if args.session_name: + if sess := ctx.db_get_sess(args.session_name): + console.print(sess) + else: + raise ValueError(f"No session logged in called {args.session_name!r} " + f"- try using `scratch sessions` to see available sessions") + return + + parser.print_help() + + +if __name__ == '__main__': + 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..2c9cf540 --- /dev/null +++ b/scratchattach/cli/__init__.py @@ -0,0 +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/cli/cmd/__init__.py b/scratchattach/cli/cmd/__init__.py new file mode 100644 index 00000000..4316c131 --- /dev/null +++ b/scratchattach/cli/cmd/__init__.py @@ -0,0 +1,4 @@ +from .login import login +from .group import group +from .profile import profile +from .sessions import sessions diff --git a/scratchattach/cli/cmd/group.py b/scratchattach/cli/cmd/group.py new file mode 100644 index 00000000..b28c5685 --- /dev/null +++ b/scratchattach/cli/cmd/group.py @@ -0,0 +1,127 @@ +from scratchattach.cli import db +from scratchattach.cli.context import console, ctx + +from rich.markup 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 add(group_name: str): + accounts = input("Add accounts (split by space): ").split() + for account in accounts: + ctx.db_add_to_group(group_name, account) + +def remove(group_name: str): + accounts = input("Remove accounts (split by space): ").split() + for account in accounts: + ctx.db_remove_from_group(group_name, account) + +def new(): + console.rule(f"New group {escape(ctx.args.group_name)}") + if ctx.db_group_exists(ctx.args.group_name): + raise ValueError(f"Group {escape(ctx.args.group_name)} already exists") + + db.conn.execute("BEGIN") + db.cursor.execute("INSERT INTO GROUPS (NAME, DESCRIPTION) " + "VALUES (?, ?)", (ctx.args.group_name, input("Description: "))) + db.conn.commit() + add(ctx.args.group_name) + + _group(ctx.args.group_name) + +def _group(group_name: str): + """ + Display information about a group + """ + db.cursor.execute( + "SELECT NAME, DESCRIPTION FROM GROUPS WHERE NAME = ?", (group_name,)) + 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()] + + table = Table(title=escape(group_name)) + 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) + +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 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) + +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 rename(group_name: str, new_name: str): + copy(group_name, new_name) + delete(group_name) + +def group(): + match ctx.args.group_command: + case "list": + _list() + case "new": + new() + case "switch": + switch() + case "add": + 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) + 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/cmd/login.py b/scratchattach/cli/cmd/login.py new file mode 100644 index 00000000..b0137a1b --- /dev/null +++ b/scratchattach/cli/cmd/login.py @@ -0,0 +1,60 @@ +from scratchattach.cli.context import ctx, console +from scratchattach.cli import db +from rich.markup import escape + +from getpass import getpass + +import scratchattach as sa +import warnings + +warnings.filterwarnings("ignore", category=sa.LoginDataWarning) + + +def login(): + if ctx.args.sessid: + if isinstance(ctx.args.sessid, bool): + ctx.args.sessid = getpass("Session ID: ") + + session = sa.login_by_id(ctx.args.sessid) + password = None + else: + username = input("Username: ") + password = getpass() + + 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( + "INSERT OR REPLACE INTO SESSIONS (ID, USERNAME, PASSWORD) " + "VALUES (?, ?, ?)", (session.id, session.username, password) + ) + db.conn.commit() + + # make new group + db.cursor.execute("SELECT NAME FROM GROUPS WHERE NAME = ?", (session.username,)) + if not db.cursor.fetchone(): + console.rule(f"Registering [b]{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}: ")) + ).execute( + "INSERT INTO GROUP_USERS (GROUP_NAME, USERNAME) " + "VALUES (?, ?)", (session.username, session.username) + ) + + 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/cmd/profile.py b/scratchattach/cli/cmd/profile.py new file mode 100644 index 00000000..66971737 --- /dev/null +++ b/scratchattach/cli/cmd/profile.py @@ -0,0 +1,7 @@ +from scratchattach.cli.context import ctx, console + +@ctx.sessionable +def profile(): + console.rule() + user = ctx.session.connect_linked_user() + console.print(user) 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/context.py b/scratchattach/cli/context.py new file mode 100644 index 00000000..7d61f184 --- /dev/null +++ b/scratchattach/cli/context.py @@ -0,0 +1,142 @@ +""" +Handles data like current session for 'sessionable' commands. +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 +from scratchattach.cli import db +import scratchattach as sa + + +@dataclass +class _Ctx: + args: ArgSpace = field(default_factory=ArgSpace) + parser: argparse.ArgumentParser = field(default_factory=argparse.ArgumentParser) + _username: Optional[str] = None + _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 username in self.db_users_in_group(self.current_group_name): + self._username = username + self._session = None + func(*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)) + + 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] + + @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 + + @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_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() + + @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 + db.conn.execute("BEGIN") + db.cursor.execute("INSERT INTO GROUP_USERS (GROUP_NAME, USERNAME) " + "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() + + @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() + + @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] + + 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/db.py b/scratchattach/cli/db.py new file mode 100644 index 00000000..948af157 --- /dev/null +++ b/scratchattach/cli/db.py @@ -0,0 +1,66 @@ +""" +Basic connections to the scratch.sqlite file +""" +import sqlite3 +import sys +import os + +from pathlib import Path + +from typing_extensions import LiteralString + + +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() + +# Init any tables +def add_col(table: LiteralString, column: LiteralString, _type: LiteralString): + try: + # 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 + +# 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.executescript(""" + CREATE TABLE IF NOT EXISTS SESSIONS ( + ID TEXT NOT NULL, + USERNAME TEXT NOT NULL PRIMARY KEY + ); + + 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 + ); + + 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 + ); +""") +add_col("SESSIONS", "PASSWORD", "TEXT") + + +conn.commit() diff --git a/scratchattach/cli/namespace.py b/scratchattach/cli/namespace.py new file mode 100644 index 00000000..b7187bbc --- /dev/null +++ b/scratchattach/cli/namespace.py @@ -0,0 +1,14 @@ +import argparse +from typing_extensions import Optional, Literal + + +class ArgSpace(argparse.Namespace): + command: Optional[Literal['login', 'group', 'profile', 'sessions']] + sessid: bool | str + 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/scratchattach/site/project.py b/scratchattach/site/project.py index af4110bb..2dac940c 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,12 @@ class Project(PartialProject): """ def __str__(self): - return str(self.title) + 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() 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 diff --git a/scratchattach/site/studio.py b/scratchattach/site/studio.py index a9eaf93f..309b6571 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,26 +80,75 @@ 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"] + try: self.image_url = studio["image"] # rename/alias to thumbnail_url? except Exception: pass try: self.created = studio["history"]["created"] 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}" + + @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` diff --git a/scratchattach/site/user.py b/scratchattach/site/user.py index f1c4aec5..759788d1 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: @@ -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 @@ -152,6 +157,62 @@ 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.markup import escape + + featured_data = self.featured_data() or {} + 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)) + desc.add_row(escape(featured_data.get("label", "Featured Project")), + escape(str(self.connect_featured_project()))) + + 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 + + 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: """ @@ -164,6 +225,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 diff --git a/setup.py b/setup.py index 512d2dff..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() @@ -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"] },