Skip to content

Commit b0dd8aa

Browse files
committed
Add command line utility
1 parent 325683a commit b0dd8aa

File tree

5 files changed

+280
-0
lines changed

5 files changed

+280
-0
lines changed

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies = [
88
"fs>=2.4.16",
99
"httpx>=0.28.1",
1010
"pyelftools>=0.31",
11+
"typer>=0.15.1",
1112
"typing-extensions>=4.12.2",
1213
"unicorn>=2.1.1",
1314
]
@@ -69,6 +70,10 @@ pythonpath = [
6970
"."
7071
]
7172

73+
[project.scripts]
74+
ani = "anisette.cli:app"
75+
anisette = "anisette.cli:app"
76+
7277
[build-system]
7378
requires = ["setuptools", "setuptools-scm"]
7479
build-backend = "setuptools.build_meta"

src/anisette/__main__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""__main__.py."""
2+
3+
if __name__ == "__main__":
4+
from .cli import app
5+
6+
app()

src/anisette/_util.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from __future__ import annotations
22

3+
import logging
4+
import os
5+
import platform
36
import re
47
from contextlib import contextmanager
58
from io import BytesIO
@@ -59,3 +62,29 @@ def open_file(fp: BinaryIO | str | Path, mode: Literal["rb", "wb+"] = "rb") -> I
5962

6063
if do_close:
6164
file.close()
65+
66+
67+
def get_config_dir(dir_name: str) -> Path | None:
68+
plat = platform.system()
69+
if plat == "Windows":
70+
path_str = os.getenv("LOCALAPPDATA")
71+
elif plat in ("Linux", "Darwin"):
72+
path_str = os.getenv("XDG_CONFIG_HOME")
73+
if path_str is None:
74+
home = os.getenv("HOME")
75+
if home is None:
76+
logging.info("Could not determine home directory")
77+
return None
78+
subpath = os.path.join(home, ".config" if plat == "Linux" else "Library/Preferences") # noqa: PTH118
79+
path_str = os.getenv("XDG_CONFIG_HOME", subpath)
80+
else:
81+
logging.info("Platform unsupported: %s", plat)
82+
return None
83+
84+
if path_str is None:
85+
logging.info("Could not determine config directory")
86+
return None
87+
88+
path = Path(path_str) / dir_name
89+
path.mkdir(parents=True, exist_ok=True)
90+
return path

src/anisette/cli.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""CLI functionality."""
2+
3+
# ruff: noqa: T201
4+
5+
from __future__ import annotations
6+
7+
import hashlib
8+
import json
9+
import logging
10+
from typing import TYPE_CHECKING, Annotated
11+
12+
import typer
13+
from rich.console import Console
14+
from rich.table import Table
15+
16+
from ._util import get_config_dir
17+
from .anisette import Anisette
18+
19+
if TYPE_CHECKING:
20+
from pathlib import Path
21+
22+
app = typer.Typer(no_args_is_help=True)
23+
console = Console()
24+
25+
26+
class _AniError(Exception):
27+
pass
28+
29+
30+
class _SessionManager:
31+
def __init__(self, conf_dir: Path | None = None) -> None:
32+
self._conf_dir: Path | None = conf_dir
33+
34+
@property
35+
def can_save(self) -> bool:
36+
return self.config_dir is not None
37+
38+
@property
39+
def config_dir(self) -> Path | None:
40+
if self._conf_dir is not None:
41+
return self._conf_dir
42+
43+
self._conf_dir = get_config_dir("anisette-py")
44+
if self._conf_dir is None:
45+
logging.warning("Could not find user config directory")
46+
return None
47+
return self._conf_dir
48+
49+
@property
50+
def libs_path(self) -> Path | None:
51+
if self.config_dir is None:
52+
return None
53+
return self.config_dir / "libs.bin"
54+
55+
def _get_prov_path(self, name: str) -> Path:
56+
assert self.config_dir is not None
57+
58+
return self.config_dir / f"{name}.prov"
59+
60+
def save(self, session: Anisette, name: str) -> None:
61+
assert self.config_dir is not None
62+
63+
prov_path = self._get_prov_path(name)
64+
session.save_provisioning(prov_path)
65+
66+
def get_hash(self, name: str) -> str:
67+
assert self.config_dir is not None
68+
69+
prov_path = self._get_prov_path(name)
70+
if not prov_path.exists():
71+
msg = f"Session does not exist: '{name}'"
72+
raise _AniError(msg)
73+
74+
with prov_path.open("rb") as f:
75+
return hashlib.sha256(f.read()).hexdigest()[:10]
76+
77+
def new(self, name: str) -> Anisette:
78+
assert self.config_dir is not None
79+
assert self.libs_path is not None
80+
81+
prov_path = self._get_prov_path(name)
82+
if prov_path.exists():
83+
msg = f"Session with name '{name}' already exists"
84+
raise _AniError(msg)
85+
86+
if not self.libs_path.exists():
87+
session = Anisette.init()
88+
session.save_libs(self.libs_path)
89+
else:
90+
session = Anisette.init(self.libs_path)
91+
self.save(session, name)
92+
93+
return session
94+
95+
def remove(self, name: str) -> None:
96+
assert self.config_dir is not None
97+
98+
self._get_prov_path(name).unlink(missing_ok=True)
99+
100+
def get(self, name: str) -> Anisette:
101+
assert self.config_dir is not None
102+
assert self.libs_path is not None
103+
104+
if not self.libs_path.exists():
105+
msg = "Libraries are not available"
106+
raise _AniError(msg)
107+
108+
prov_path = self._get_prov_path(name)
109+
if not prov_path.exists():
110+
msg = f"Session with name '{name}' does not exist"
111+
raise _AniError(msg)
112+
113+
return Anisette.load(self.libs_path, prov_path)
114+
115+
def list(self) -> list[str]:
116+
assert self.config_dir is not None
117+
118+
return [path.stem for path in self.config_dir.glob("*.prov")]
119+
120+
121+
@app.command()
122+
def new(name: Annotated[str, typer.Argument()] = "default") -> None:
123+
"""Create a new Anisette session."""
124+
sessions = _SessionManager()
125+
if not sessions.can_save:
126+
print("Unable to figure out a config directory to store new sessions")
127+
raise typer.Exit(code=1)
128+
129+
try:
130+
sessions.new(name)
131+
except _AniError as e:
132+
print(str(e))
133+
raise typer.Abort from None
134+
print(f"Successfully created new session: '{name}'")
135+
136+
137+
@app.command()
138+
def remove(name: Annotated[str, typer.Argument()] = "default") -> None:
139+
"""Remove a saved Anisette session."""
140+
sessions = _SessionManager()
141+
if not sessions.can_save:
142+
print("Unable to figure out a config directory to retrieve sessions from")
143+
raise typer.Exit(code=1)
144+
145+
try:
146+
sessions.remove(name)
147+
except _AniError as e:
148+
print(str(e))
149+
raise typer.Abort from None
150+
print(f"Successfully destroyed session: '{name}'")
151+
152+
153+
@app.command()
154+
def get(name: Annotated[str, typer.Argument()] = "default") -> None:
155+
"""Get Anisette data for a saved session."""
156+
sessions = _SessionManager()
157+
if not sessions.can_save:
158+
print("Unable to figure out a config directory to retrieve sessions from")
159+
raise typer.Exit(code=1)
160+
161+
try:
162+
ani = sessions.get(name)
163+
except _AniError as e:
164+
print(str(e))
165+
raise typer.Abort from None
166+
data = ani.get_data()
167+
sessions.save(ani, name)
168+
169+
print(json.dumps(data, indent=2))
170+
171+
172+
@app.command(name="list")
173+
def list_() -> None:
174+
"""List Anisette sessions."""
175+
sessions = _SessionManager()
176+
if not sessions.can_save:
177+
print("Unable to figure out a config directory to retrieve sessions from")
178+
raise typer.Exit(code=1)
179+
180+
table = Table("Session name", "Revision")
181+
for name in sessions.list():
182+
digest = sessions.get_hash(name)
183+
table.add_row(name, digest)
184+
console.print(table)
185+
186+
187+
if __name__ == "__main__":
188+
app()

uv.lock

Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)