Skip to content

Commit e09d727

Browse files
authored
feat: create CLI (#514)
Closes #478
1 parent c740893 commit e09d727

File tree

17 files changed

+753
-21
lines changed

17 files changed

+753
-21
lines changed

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ SimpleWebSocketServer
55
typing-extensions
66
browser_cookie3
77
aiohttp
8+
rich
9+
# install rich-pixels if you want CLI image support. This is an optional dependency.

scratchattach/__main__.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""
2+
Scratchattach CLI. Most source code is in the `cli` directory
3+
"""
4+
5+
import argparse
6+
7+
from scratchattach import cli
8+
from scratchattach.cli import db, cmd
9+
from scratchattach.cli.context import ctx, console
10+
11+
import rich.traceback
12+
13+
rich.traceback.install()
14+
15+
16+
# noinspection PyUnusedLocal
17+
def main():
18+
parser = argparse.ArgumentParser(
19+
prog="scratch",
20+
description="Scratchattach CLI",
21+
epilog=f"Running scratchattach CLI version {cli.VERSION}",
22+
)
23+
24+
# Using walrus operator & ifs for artificial indentation
25+
if commands := parser.add_subparsers(dest="command"):
26+
commands.add_parser("profile", help="View your profile")
27+
commands.add_parser("sessions", help="View session list")
28+
if login := commands.add_parser("login", help="Login to Scratch"):
29+
login.add_argument("--sessid", dest="sessid", nargs="?", default=False, const=True,
30+
help="Login by session ID")
31+
if group := commands.add_parser("group", help="View current session group"):
32+
if group_commands := group.add_subparsers(dest="group_command"):
33+
group_commands.add_parser("list", help="List all session groups")
34+
group_commands.add_parser("add", help="Add sessions to group")
35+
group_commands.add_parser("remove", help="Remove sessions from a group")
36+
group_commands.add_parser("delete", help="Delete current group")
37+
if group_copy := group_commands.add_parser("copy", help="Copy current group with a new name"):
38+
group_copy.add_argument("group_name", help="New group name")
39+
if group_rename := group_commands.add_parser("rename", help="Rename current group"):
40+
group_rename.add_argument("group_name", help="New group name")
41+
if group_new := group_commands.add_parser("new", help="Create a new group"):
42+
group_new.add_argument("group_name")
43+
if group_switch := group_commands.add_parser("switch", help="Change the current group"):
44+
group_switch.add_argument("group_name")
45+
46+
parser.add_argument("-U", "--username", dest="username", help="Name of user to look at")
47+
parser.add_argument("-P", "--project", dest="project_id", help="ID of project to look at")
48+
parser.add_argument("-S", "--studio", dest="studio_id", help="ID of studio to look at")
49+
parser.add_argument("-L", "--session_name", dest="session_name",
50+
help="Name of (registered) session/login to look at")
51+
52+
args = parser.parse_args(namespace=cli.ArgSpace())
53+
cli.ctx.args = args
54+
cli.ctx.parser = parser
55+
56+
match args.command:
57+
case "sessions":
58+
cmd.sessions()
59+
case "login":
60+
cmd.login()
61+
case "group":
62+
cmd.group()
63+
case "profile":
64+
cmd.profile()
65+
case None:
66+
if args.username:
67+
user = ctx.session.connect_user(args.username)
68+
console.print(cli.try_get_img(user.icon, (30, 30)))
69+
console.print(user)
70+
return
71+
if args.studio_id:
72+
studio = ctx.session.connect_studio(args.studio_id)
73+
console.print(cli.try_get_img(studio.thumbnail, (34, 20)))
74+
console.print(studio)
75+
return
76+
if args.project_id:
77+
project = ctx.session.connect_project(args.project_id)
78+
console.print(cli.try_get_img(project.thumbnail, (30, 23)))
79+
console.print(project)
80+
return
81+
if args.session_name:
82+
if sess := ctx.db_get_sess(args.session_name):
83+
console.print(sess)
84+
else:
85+
raise ValueError(f"No session logged in called {args.session_name!r} "
86+
f"- try using `scratch sessions` to see available sessions")
87+
return
88+
89+
parser.print_help()
90+
91+
92+
if __name__ == '__main__':
93+
main()

scratchattach/cli/__about__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
VERSION = "0.0.0"

scratchattach/cli/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""
2+
Only for use within __main__.py
3+
"""
4+
import io
5+
6+
from rich.console import RenderableType
7+
from typing_extensions import Optional
8+
from scratchattach.cli.__about__ import VERSION
9+
from scratchattach.cli.namespace import ArgSpace
10+
from scratchattach.cli.context import ctx
11+
12+
13+
# noinspection PyPackageRequirements
14+
def try_get_img(image: bytes, size: tuple[int, int] | None = None) -> Optional[RenderableType]:
15+
try:
16+
from PIL import Image
17+
from rich_pixels import Pixels
18+
19+
with Image.open(io.BytesIO(image)) as image:
20+
if size is not None:
21+
image = image.resize(size)
22+
23+
return Pixels.from_image(image)
24+
25+
except ImportError:
26+
return ""

scratchattach/cli/cmd/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .login import login
2+
from .group import group
3+
from .profile import profile
4+
from .sessions import sessions

scratchattach/cli/cmd/group.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from scratchattach.cli import db
2+
from scratchattach.cli.context import console, ctx
3+
4+
from rich.markup import escape
5+
from rich.table import Table
6+
7+
def _list():
8+
table = Table(title="All groups")
9+
table.add_column("Name")
10+
table.add_column("Description")
11+
table.add_column("Usernames")
12+
13+
db.cursor.execute("SELECT NAME, DESCRIPTION FROM GROUPS")
14+
for name, description in db.cursor.fetchall():
15+
db.cursor.execute("SELECT USERNAME FROM GROUP_USERS WHERE GROUP_NAME=?", (name,))
16+
usernames = db.cursor.fetchall()
17+
18+
table.add_row(escape(name), escape(description),
19+
'\n'.join(f"{i}. {u}" for i, (u,) in enumerate(usernames)))
20+
21+
console.print(table)
22+
23+
def add(group_name: str):
24+
accounts = input("Add accounts (split by space): ").split()
25+
for account in accounts:
26+
ctx.db_add_to_group(group_name, account)
27+
28+
def remove(group_name: str):
29+
accounts = input("Remove accounts (split by space): ").split()
30+
for account in accounts:
31+
ctx.db_remove_from_group(group_name, account)
32+
33+
def new():
34+
console.rule(f"New group {escape(ctx.args.group_name)}")
35+
if ctx.db_group_exists(ctx.args.group_name):
36+
raise ValueError(f"Group {escape(ctx.args.group_name)} already exists")
37+
38+
db.conn.execute("BEGIN")
39+
db.cursor.execute("INSERT INTO GROUPS (NAME, DESCRIPTION) "
40+
"VALUES (?, ?)", (ctx.args.group_name, input("Description: ")))
41+
db.conn.commit()
42+
add(ctx.args.group_name)
43+
44+
_group(ctx.args.group_name)
45+
46+
def _group(group_name: str):
47+
"""
48+
Display information about a group
49+
"""
50+
db.cursor.execute(
51+
"SELECT NAME, DESCRIPTION FROM GROUPS WHERE NAME = ?", (group_name,))
52+
result = db.cursor.fetchone()
53+
if result is None:
54+
print("No group selected!!")
55+
return
56+
57+
name, description = result
58+
59+
db.cursor.execute("SELECT USERNAME FROM GROUP_USERS WHERE GROUP_NAME = ?", (name,))
60+
usernames = [name for (name,) in db.cursor.fetchall()]
61+
62+
table = Table(title=escape(group_name))
63+
table.add_column(escape(name))
64+
table.add_column('Usernames')
65+
66+
table.add_row(escape(description),
67+
'\n'.join(f"{i}. {u}" for i, u in enumerate(usernames)))
68+
69+
console.print(table)
70+
71+
def switch():
72+
console.rule(f"Switching to {escape(ctx.args.group_name)}")
73+
if not ctx.db_group_exists(ctx.args.group_name):
74+
raise ValueError(f"Group {escape(ctx.args.group_name)} does not exist")
75+
76+
ctx.current_group_name = ctx.args.group_name
77+
_group(ctx.current_group_name)
78+
79+
def delete(group_name: str):
80+
print(f"Deleting {group_name}")
81+
if not ctx.db_group_exists(group_name):
82+
raise ValueError(f"Group {group_name} does not exist")
83+
if ctx.db_group_count == 1:
84+
raise ValueError(f"Make another group first")
85+
86+
ctx.db_group_delete(group_name)
87+
88+
def copy(group_name: str, new_name: str):
89+
print(f"Copying {group_name} as {new_name}")
90+
if not ctx.db_group_exists(group_name):
91+
raise ValueError(f"Group {group_name} does not exist")
92+
93+
ctx.db_group_copy(group_name, new_name)
94+
95+
def rename(group_name: str, new_name: str):
96+
copy(group_name, new_name)
97+
delete(group_name)
98+
99+
def group():
100+
match ctx.args.group_command:
101+
case "list":
102+
_list()
103+
case "new":
104+
new()
105+
case "switch":
106+
switch()
107+
case "add":
108+
add(ctx.current_group_name)
109+
case "remove":
110+
remove(ctx.current_group_name)
111+
case "delete":
112+
if input("Are you sure? (y/N): ").lower() != "y":
113+
return
114+
delete(ctx.current_group_name)
115+
new_current = ctx.db_first_group_name
116+
print(f"Switching to {new_current}")
117+
ctx.current_group_name = new_current
118+
_group(new_current)
119+
120+
case "copy":
121+
copy(ctx.current_group_name, ctx.args.group_name)
122+
case "rename":
123+
rename(ctx.current_group_name, ctx.args.group_name)
124+
ctx.current_group_name = ctx.args.group_name
125+
_group(ctx.args.group_name)
126+
case None:
127+
_group(ctx.current_group_name)

scratchattach/cli/cmd/login.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from scratchattach.cli.context import ctx, console
2+
from scratchattach.cli import db
3+
from rich.markup import escape
4+
5+
from getpass import getpass
6+
7+
import scratchattach as sa
8+
import warnings
9+
10+
warnings.filterwarnings("ignore", category=sa.LoginDataWarning)
11+
12+
13+
def login():
14+
if ctx.args.sessid:
15+
if isinstance(ctx.args.sessid, bool):
16+
ctx.args.sessid = getpass("Session ID: ")
17+
18+
session = sa.login_by_id(ctx.args.sessid)
19+
password = None
20+
else:
21+
username = input("Username: ")
22+
password = getpass()
23+
24+
session = sa.login(username, password)
25+
26+
console.rule()
27+
console.print(f"Logged in as [b]{session.username}[/]")
28+
29+
# register session
30+
db.conn.execute("BEGIN")
31+
db.cursor.execute(
32+
"INSERT OR REPLACE INTO SESSIONS (ID, USERNAME, PASSWORD) "
33+
"VALUES (?, ?, ?)", (session.id, session.username, password)
34+
)
35+
db.conn.commit()
36+
37+
# make new group
38+
db.cursor.execute("SELECT NAME FROM GROUPS WHERE NAME = ?", (session.username,))
39+
if not db.cursor.fetchone():
40+
console.rule(f"Registering [b]{escape(session.username)}[/] as group")
41+
db.conn.execute("BEGIN")
42+
db.cursor.execute(
43+
"INSERT INTO GROUPS (NAME, DESCRIPTION) "
44+
"VALUES (?, ?)", (session.username, input(f"Description for {session.username}: "))
45+
).execute(
46+
"INSERT INTO GROUP_USERS (GROUP_NAME, USERNAME) "
47+
"VALUES (?, ?)", (session.username, session.username)
48+
)
49+
50+
db.conn.commit()
51+
52+
console.rule()
53+
if input("Add to current session group? (Y/n)").lower() not in ("y", ''):
54+
return
55+
56+
db.conn.execute("BEGIN")
57+
db.cursor.execute("INSERT INTO GROUP_USERS (GROUP_NAME, USERNAME) "
58+
"VALUES (?, ?)", (ctx.current_group_name, session.username))
59+
db.conn.commit()
60+
console.print(f"Added to [b]{escape(ctx.current_group_name)}[/]")

scratchattach/cli/cmd/profile.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from scratchattach.cli.context import ctx, console
2+
3+
@ctx.sessionable
4+
def profile():
5+
console.rule()
6+
user = ctx.session.connect_linked_user()
7+
console.print(user)

scratchattach/cli/cmd/sessions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from scratchattach.cli.context import ctx, console
2+
3+
@ctx.sessionable
4+
def sessions():
5+
console.print(ctx.session)

0 commit comments

Comments
 (0)