systemonemodels 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- systemone/__init__.py +35 -0
- systemone/auth.py +68 -0
- systemone/cache.py +131 -0
- systemone/cli.py +453 -0
- systemone/client.py +276 -0
- systemone/config.py +111 -0
- systemone/errors.py +42 -0
- systemone/inspect.py +139 -0
- systemone/py.typed +0 -0
- systemone/transfer.py +215 -0
- systemonemodels-0.1.0.dist-info/METADATA +148 -0
- systemonemodels-0.1.0.dist-info/RECORD +15 -0
- systemonemodels-0.1.0.dist-info/WHEEL +4 -0
- systemonemodels-0.1.0.dist-info/entry_points.txt +2 -0
- systemonemodels-0.1.0.dist-info/licenses/LICENSE +202 -0
systemone/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""System One — client and CLI for the decision-model registry.
|
|
2
|
+
|
|
3
|
+
from systemone import Client
|
|
4
|
+
|
|
5
|
+
with Client() as registry:
|
|
6
|
+
registry.search("routing", capability="route")
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
from systemone.client import Client # noqa: E402
|
|
12
|
+
from systemone.errors import ( # noqa: E402
|
|
13
|
+
ApiError,
|
|
14
|
+
AuthError,
|
|
15
|
+
ChecksumMismatch,
|
|
16
|
+
ConnectionFailed,
|
|
17
|
+
LoginFailed,
|
|
18
|
+
NotFound,
|
|
19
|
+
SystemOneError,
|
|
20
|
+
)
|
|
21
|
+
from systemone.transfer import push_files, snapshot_download # noqa: E402
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ApiError",
|
|
25
|
+
"AuthError",
|
|
26
|
+
"ChecksumMismatch",
|
|
27
|
+
"Client",
|
|
28
|
+
"ConnectionFailed",
|
|
29
|
+
"LoginFailed",
|
|
30
|
+
"NotFound",
|
|
31
|
+
"SystemOneError",
|
|
32
|
+
"__version__",
|
|
33
|
+
"push_files",
|
|
34
|
+
"snapshot_download",
|
|
35
|
+
]
|
systemone/auth.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Browser login: the device authorization grant (RFC 8628).
|
|
2
|
+
|
|
3
|
+
The CLI asks the registry for a pair of codes, shows the short one, and polls
|
|
4
|
+
with the long one while the person approves it on the website. No password
|
|
5
|
+
passes through the terminal, and it works over SSH: the browser can be on any
|
|
6
|
+
machine, as long as the person can type eight letters into it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from systemone.client import Client
|
|
18
|
+
from systemone.errors import ApiError, LoginFailed
|
|
19
|
+
|
|
20
|
+
SLOW_DOWN_STEP = 5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def device_login(
|
|
24
|
+
client: Client,
|
|
25
|
+
client_name: str,
|
|
26
|
+
on_code: Callable[[dict[str, Any]], None],
|
|
27
|
+
*,
|
|
28
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
29
|
+
clock: Callable[[], float] = time.monotonic,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
"""Run the whole flow and return `{token, token_name, username}`.
|
|
32
|
+
|
|
33
|
+
`on_code` is called once with the codes and URLs, so the caller decides how
|
|
34
|
+
to show them. `sleep` and `clock` exist so tests do not wait in real time.
|
|
35
|
+
"""
|
|
36
|
+
started = client.start_device_login(client_name)
|
|
37
|
+
on_code(started)
|
|
38
|
+
|
|
39
|
+
interval = float(started["interval"])
|
|
40
|
+
deadline = clock() + float(started["expires_in"])
|
|
41
|
+
while clock() < deadline:
|
|
42
|
+
sleep(interval)
|
|
43
|
+
try:
|
|
44
|
+
return client.poll_device_login(started["device_code"])
|
|
45
|
+
except ApiError as exc:
|
|
46
|
+
if exc.code == "authorization_pending":
|
|
47
|
+
continue
|
|
48
|
+
if exc.code == "slow_down":
|
|
49
|
+
interval += SLOW_DOWN_STEP
|
|
50
|
+
continue
|
|
51
|
+
if exc.code in ("access_denied", "expired_token"):
|
|
52
|
+
raise LoginFailed(exc.detail) from exc
|
|
53
|
+
raise
|
|
54
|
+
raise LoginFailed("The code expired before it was approved. Run `systemone login` again.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def can_open_browser() -> bool:
|
|
58
|
+
"""Whether opening a browser here would reach the person at the keyboard.
|
|
59
|
+
|
|
60
|
+
Over SSH it would open on the remote machine, if at all — and on a Linux
|
|
61
|
+
box without a display, Python's webbrowser falls back to a text browser
|
|
62
|
+
that takes over the terminal the login is running in.
|
|
63
|
+
"""
|
|
64
|
+
if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
|
|
65
|
+
return False
|
|
66
|
+
if sys.platform.startswith("linux"):
|
|
67
|
+
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
68
|
+
return True
|
systemone/cache.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Local cache, laid out the way Hugging Face's is and for the same reasons.
|
|
2
|
+
|
|
3
|
+
~/.cache/systemone/
|
|
4
|
+
blobs/ab/abcdef… one file per distinct content
|
|
5
|
+
models/biplov--snake/
|
|
6
|
+
snapshots/0.3.0/coreml-int4/model.onnx → link to a blob
|
|
7
|
+
refs/latest "0.3.0"
|
|
8
|
+
|
|
9
|
+
Blobs are keyed by SHA-256 and shared by every repository and every version, so
|
|
10
|
+
the tokenizer that six exports of one model have in common is downloaded and
|
|
11
|
+
stored once. A snapshot is a directory of links into the blobs — the tree a
|
|
12
|
+
version describes, costing no extra disk.
|
|
13
|
+
|
|
14
|
+
Deterministic in the sense that matters: a version is immutable on the server,
|
|
15
|
+
so a snapshot for it is built once and never changes. A second pull of the same
|
|
16
|
+
version touches the network only to read the version's file list.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import hashlib
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from systemone.config import cache_dir
|
|
28
|
+
|
|
29
|
+
CHUNK = 1024 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def blob_path(sha256: str) -> Path:
|
|
33
|
+
return cache_dir() / "blobs" / sha256[:2] / sha256
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def snapshot_root(repo: str, version: str) -> Path:
|
|
37
|
+
namespace, _, name = repo.partition("/")
|
|
38
|
+
return cache_dir() / "models" / f"{namespace}--{name}" / "snapshots" / version
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ref_path(repo: str, ref: str = "latest") -> Path:
|
|
42
|
+
namespace, _, name = repo.partition("/")
|
|
43
|
+
return cache_dir() / "models" / f"{namespace}--{name}" / "refs" / ref
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def verify(path: Path, sha256: str) -> bool:
|
|
47
|
+
digest = hashlib.sha256()
|
|
48
|
+
try:
|
|
49
|
+
with path.open("rb") as handle:
|
|
50
|
+
while chunk := handle.read(CHUNK):
|
|
51
|
+
digest.update(chunk)
|
|
52
|
+
except OSError:
|
|
53
|
+
return False
|
|
54
|
+
return digest.hexdigest() == sha256
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def has_blob(sha256: str) -> bool:
|
|
58
|
+
"""Present and intact.
|
|
59
|
+
|
|
60
|
+
Checked by content rather than existence, because a blob that was
|
|
61
|
+
truncated by a crash, or edited by someone poking around the cache, must be
|
|
62
|
+
fetched again rather than silently handed out.
|
|
63
|
+
"""
|
|
64
|
+
path = blob_path(sha256)
|
|
65
|
+
return path.exists() and verify(path, sha256)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def adopt(downloaded: Path, sha256: str) -> Path:
|
|
69
|
+
"""Move a finished download into the blob store."""
|
|
70
|
+
target = blob_path(sha256)
|
|
71
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
os.replace(downloaded, target)
|
|
73
|
+
# Read-only, so a snapshot link cannot be used to modify a blob that other
|
|
74
|
+
# snapshots share.
|
|
75
|
+
with contextlib.suppress(OSError):
|
|
76
|
+
target.chmod(0o444)
|
|
77
|
+
return target
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def link(blob: Path, target: Path) -> None:
|
|
81
|
+
"""Materialise a blob at a path inside a snapshot or a destination.
|
|
82
|
+
|
|
83
|
+
Hard link first: no extra disk, and the file behaves as a real file to
|
|
84
|
+
every tool that opens it. Symlink if the destination is on another volume.
|
|
85
|
+
Copy as a last resort, which is what Windows without developer mode gets.
|
|
86
|
+
"""
|
|
87
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
if target.exists() or target.is_symlink():
|
|
89
|
+
target.unlink()
|
|
90
|
+
try:
|
|
91
|
+
os.link(blob, target)
|
|
92
|
+
return
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
try:
|
|
96
|
+
target.symlink_to(blob)
|
|
97
|
+
return
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
shutil.copyfile(blob, target)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def write_ref(repo: str, version: str, ref: str = "latest") -> None:
|
|
104
|
+
path = ref_path(repo, ref)
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
path.write_text(version)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def read_ref(repo: str, ref: str = "latest") -> str | None:
|
|
110
|
+
path = ref_path(repo, ref)
|
|
111
|
+
return path.read_text().strip() if path.exists() else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def size() -> int:
|
|
115
|
+
root = cache_dir() / "blobs"
|
|
116
|
+
if not root.exists():
|
|
117
|
+
return 0
|
|
118
|
+
return sum(p.stat().st_size for p in root.rglob("*") if p.is_file())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def clear() -> int:
|
|
122
|
+
"""Delete the whole cache. Returns bytes freed."""
|
|
123
|
+
freed = size()
|
|
124
|
+
root = cache_dir()
|
|
125
|
+
if root.exists():
|
|
126
|
+
# Blobs are read-only by design; make them writable so rmtree can go.
|
|
127
|
+
for path in root.rglob("*"):
|
|
128
|
+
with contextlib.suppress(OSError):
|
|
129
|
+
path.chmod(0o644 if path.is_file() else 0o755)
|
|
130
|
+
shutil.rmtree(root)
|
|
131
|
+
return freed
|
systemone/cli.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""The `systemone` command line.
|
|
2
|
+
|
|
3
|
+
systemone login
|
|
4
|
+
systemone search routing --capability route
|
|
5
|
+
systemone show biplov/snake-balanced-multilingual
|
|
6
|
+
systemone pull biplov/snake-balanced-multilingual --variant onnx-int8
|
|
7
|
+
systemone push ./exports/my-model --repo me/my-model --variant onnx-int8
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import socket
|
|
13
|
+
import sys
|
|
14
|
+
import webbrowser
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Annotated, Any, NoReturn
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.progress import BarColumn, Progress, TextColumn, TimeRemainingColumn
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
|
|
23
|
+
from systemone import __version__, config
|
|
24
|
+
from systemone import cache as local_cache
|
|
25
|
+
from systemone.auth import can_open_browser, device_login
|
|
26
|
+
from systemone.client import Client
|
|
27
|
+
from systemone.errors import SystemOneError
|
|
28
|
+
from systemone.inspect import build_manifest, inspect
|
|
29
|
+
from systemone.transfer import pull_version, push_files
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
name="systemone",
|
|
33
|
+
help="Client for the System One decision-model registry.",
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
add_completion=False,
|
|
36
|
+
)
|
|
37
|
+
console = Console()
|
|
38
|
+
errs = Console(stderr=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# NoReturn, so the code after a fail() is understood as unreachable and the
|
|
42
|
+
# values it uses are not optional there.
|
|
43
|
+
def fail(message: str) -> NoReturn:
|
|
44
|
+
errs.print(f"[red]error[/red] {message}")
|
|
45
|
+
raise typer.Exit(1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def human(n: float) -> str:
|
|
49
|
+
for unit in ("B", "kB", "MB", "GB", "TB"):
|
|
50
|
+
if n < 1024 or unit == "TB":
|
|
51
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
52
|
+
n /= 1024
|
|
53
|
+
return f"{n:.1f} TB"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def client() -> Client:
|
|
57
|
+
return Client(config.load())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def version() -> None:
|
|
62
|
+
"""Print the client version."""
|
|
63
|
+
console.print(f"systemone {__version__}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def login(
|
|
68
|
+
token: Annotated[
|
|
69
|
+
str | None, typer.Option(help="Use this token instead of the browser. For CI.")
|
|
70
|
+
] = None,
|
|
71
|
+
paste: Annotated[
|
|
72
|
+
bool, typer.Option("--paste", help="Paste a token at a hidden prompt instead.")
|
|
73
|
+
] = False,
|
|
74
|
+
browser: Annotated[
|
|
75
|
+
bool, typer.Option("--browser/--no-browser", help="Open the approval page automatically.")
|
|
76
|
+
] = True,
|
|
77
|
+
endpoint: Annotated[str | None, typer.Option(help="For a self-hosted registry.")] = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Sign in through your browser.
|
|
80
|
+
|
|
81
|
+
Shows a one-time code and opens the registry, where you approve this
|
|
82
|
+
machine. Over SSH, open the printed address on any device instead.
|
|
83
|
+
For CI, pass --token or set SYSTEMONE_TOKEN.
|
|
84
|
+
"""
|
|
85
|
+
current = config.load()
|
|
86
|
+
if endpoint:
|
|
87
|
+
current.endpoint = endpoint.rstrip("/")
|
|
88
|
+
|
|
89
|
+
if token or paste:
|
|
90
|
+
if not token:
|
|
91
|
+
# hide_input: a pasted token should not stay in the scrollback, nor
|
|
92
|
+
# in the terminal's own buffer for the next person at this machine.
|
|
93
|
+
token = typer.prompt("Token", hide_input=True)
|
|
94
|
+
current.token = token.strip()
|
|
95
|
+
with Client(current) as probe:
|
|
96
|
+
try:
|
|
97
|
+
me = probe.whoami()
|
|
98
|
+
except SystemOneError as exc:
|
|
99
|
+
fail(str(exc))
|
|
100
|
+
if not me:
|
|
101
|
+
fail("That token was not accepted.")
|
|
102
|
+
username = str(me["username"])
|
|
103
|
+
else:
|
|
104
|
+
if not sys.stdin.isatty():
|
|
105
|
+
fail("Not a terminal. Pass --token, or set SYSTEMONE_TOKEN.")
|
|
106
|
+
current.token = None
|
|
107
|
+
|
|
108
|
+
def show(codes: dict[str, Any]) -> None:
|
|
109
|
+
console.print(f"\nYour one-time code: [bold cyan]{codes['user_code']}[/bold cyan]")
|
|
110
|
+
opened = (
|
|
111
|
+
browser
|
|
112
|
+
and can_open_browser()
|
|
113
|
+
and webbrowser.open(codes["verification_uri_complete"])
|
|
114
|
+
)
|
|
115
|
+
if opened:
|
|
116
|
+
console.print(f"Opened [cyan]{codes['verification_uri']}[/cyan] in your browser.")
|
|
117
|
+
else:
|
|
118
|
+
console.print(
|
|
119
|
+
f"Open [cyan]{codes['verification_uri']}[/cyan] on any device "
|
|
120
|
+
"and enter the code."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
with Client(current) as anonymous:
|
|
124
|
+
try:
|
|
125
|
+
with console.status("Waiting for approval in the browser (Ctrl+C to cancel)"):
|
|
126
|
+
granted = device_login(anonymous, socket.gethostname() or "systemone CLI", show)
|
|
127
|
+
except SystemOneError as exc:
|
|
128
|
+
fail(str(exc))
|
|
129
|
+
current.token = granted["token"]
|
|
130
|
+
username = str(granted["username"])
|
|
131
|
+
|
|
132
|
+
current.username = username
|
|
133
|
+
path = config.save(current)
|
|
134
|
+
console.print(f"[green]✓[/green] Signed in as [bold]{username}[/bold]")
|
|
135
|
+
console.print(f"[dim]Token stored in {path} (0600)[/dim]")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def logout() -> None:
|
|
140
|
+
"""Forget the stored token."""
|
|
141
|
+
config.clear()
|
|
142
|
+
console.print("Signed out. The token itself is still valid — revoke it in settings.")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command()
|
|
146
|
+
def whoami() -> None:
|
|
147
|
+
"""Show who the stored token belongs to."""
|
|
148
|
+
with client() as registry:
|
|
149
|
+
me = registry.whoami()
|
|
150
|
+
if not me:
|
|
151
|
+
fail("Not signed in. Run `systemone login`.")
|
|
152
|
+
console.print(f"[bold]{me['username']}[/bold] {me['email']}")
|
|
153
|
+
if not me.get("email_verified"):
|
|
154
|
+
errs.print("[yellow]Email not verified — publishing is blocked until it is.[/yellow]")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.command()
|
|
158
|
+
def search(
|
|
159
|
+
query: Annotated[str | None, typer.Argument(help="Free text.")] = None,
|
|
160
|
+
capability: Annotated[list[str] | None, typer.Option(help="choice, score, rank…")] = None,
|
|
161
|
+
architecture: Annotated[list[str] | None, typer.Option()] = None,
|
|
162
|
+
license: Annotated[list[str] | None, typer.Option()] = None,
|
|
163
|
+
sort: Annotated[
|
|
164
|
+
str, typer.Option(help="relevance|recent|stars|downloads|accuracy|latency")
|
|
165
|
+
] = "relevance",
|
|
166
|
+
limit: Annotated[int, typer.Option()] = 20,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Search the registry."""
|
|
169
|
+
with client() as registry:
|
|
170
|
+
try:
|
|
171
|
+
results = registry.search(
|
|
172
|
+
query,
|
|
173
|
+
capability=capability,
|
|
174
|
+
architecture=architecture,
|
|
175
|
+
license=license,
|
|
176
|
+
sort=sort,
|
|
177
|
+
limit=limit,
|
|
178
|
+
)
|
|
179
|
+
except SystemOneError as exc:
|
|
180
|
+
fail(str(exc))
|
|
181
|
+
|
|
182
|
+
if not results["items"]:
|
|
183
|
+
console.print("[dim]No models matched.[/dim]")
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
table = Table(box=None, pad_edge=False, header_style="dim")
|
|
187
|
+
table.add_column("MODEL")
|
|
188
|
+
table.add_column("ARCH", style="dim")
|
|
189
|
+
table.add_column("CAPABILITIES", style="dim")
|
|
190
|
+
table.add_column("ACC", justify="right")
|
|
191
|
+
table.add_column("p50", justify="right", style="dim")
|
|
192
|
+
table.add_column("↓", justify="right", style="dim")
|
|
193
|
+
|
|
194
|
+
for item in results["items"]:
|
|
195
|
+
accuracy = item.get("decision_accuracy")
|
|
196
|
+
latency = item.get("median_latency_ms")
|
|
197
|
+
table.add_row(
|
|
198
|
+
item["full_name"],
|
|
199
|
+
item.get("architecture") or "—",
|
|
200
|
+
", ".join(item.get("capabilities") or []) or "—",
|
|
201
|
+
f"{accuracy * 100:.1f}%" if accuracy is not None else "—",
|
|
202
|
+
f"{latency:.0f}ms" if latency is not None else "—",
|
|
203
|
+
str(item.get("downloads", 0)),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
console.print(table)
|
|
207
|
+
console.print(f"[dim]{results['total']} total[/dim]")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command()
|
|
211
|
+
def show(repo: Annotated[str, typer.Argument(help="namespace/name")]) -> None:
|
|
212
|
+
"""Print a model's metadata."""
|
|
213
|
+
with client() as registry:
|
|
214
|
+
try:
|
|
215
|
+
model = registry.model(repo)
|
|
216
|
+
versions = registry.versions(repo)
|
|
217
|
+
except SystemOneError as exc:
|
|
218
|
+
fail(str(exc))
|
|
219
|
+
|
|
220
|
+
latest = model.get("latest_version") or ""
|
|
221
|
+
console.print(f"[bold]{model['full_name']}[/bold] [dim]{latest}[/dim]")
|
|
222
|
+
if model.get("summary"):
|
|
223
|
+
console.print(model["summary"])
|
|
224
|
+
console.print()
|
|
225
|
+
|
|
226
|
+
facts = Table(box=None, show_header=False, pad_edge=False)
|
|
227
|
+
facts.add_column(style="dim")
|
|
228
|
+
facts.add_column()
|
|
229
|
+
facts.add_row("architecture", model.get("architecture") or "—")
|
|
230
|
+
facts.add_row("capabilities", ", ".join(model.get("capabilities") or []) or "—")
|
|
231
|
+
facts.add_row("license", model.get("license") or "—")
|
|
232
|
+
if model.get("base_model"):
|
|
233
|
+
facts.add_row("base model", model["base_model"])
|
|
234
|
+
evaluation = model.get("evaluation") or {}
|
|
235
|
+
if evaluation.get("decision_accuracy") is not None:
|
|
236
|
+
facts.add_row("accuracy", f"{evaluation['decision_accuracy'] * 100:.1f}%")
|
|
237
|
+
if evaluation.get("calibration_error") is not None:
|
|
238
|
+
facts.add_row("calibration error", str(evaluation["calibration_error"]))
|
|
239
|
+
if evaluation.get("median_latency_ms") is not None:
|
|
240
|
+
facts.add_row("median latency", f"{evaluation['median_latency_ms']} ms")
|
|
241
|
+
facts.add_row("downloads", str(model.get("downloads", 0)))
|
|
242
|
+
console.print(facts)
|
|
243
|
+
|
|
244
|
+
if versions:
|
|
245
|
+
console.print("\n[dim]VERSIONS[/dim]")
|
|
246
|
+
for entry in versions:
|
|
247
|
+
size = sum(a.get("size_bytes") or 0 for a in entry["artifacts"])
|
|
248
|
+
folders = sorted({(a.get("path") or "").split("/")[0] for a in entry["artifacts"]})
|
|
249
|
+
console.print(
|
|
250
|
+
f" {entry['version']:<10} {len(entry['artifacts'])} files {human(size):>9}"
|
|
251
|
+
f" [dim]{', '.join(f for f in folders if f)}[/dim]"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.command()
|
|
256
|
+
def validate(manifest: Annotated[Path, typer.Argument(help="Path to systemone.yaml")]) -> None:
|
|
257
|
+
"""Check a manifest without publishing."""
|
|
258
|
+
if not manifest.exists():
|
|
259
|
+
fail(f"no such file: {manifest}")
|
|
260
|
+
|
|
261
|
+
with client() as registry:
|
|
262
|
+
result = registry.validate_manifest(manifest.read_text())
|
|
263
|
+
|
|
264
|
+
if result["valid"]:
|
|
265
|
+
console.print("[green]valid[/green]")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
# Every problem at once, rather than one per attempt.
|
|
269
|
+
for issue in result["issues"]:
|
|
270
|
+
errs.print(f"[red]{issue['path']}[/red] {issue['message']}")
|
|
271
|
+
raise typer.Exit(1)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@app.command()
|
|
275
|
+
def pull(
|
|
276
|
+
repo: Annotated[str, typer.Argument(help="namespace/name")],
|
|
277
|
+
dest: Annotated[
|
|
278
|
+
Path | None,
|
|
279
|
+
typer.Option("--dest", "-d", help="Link the tree here. Omit to use the cache path."),
|
|
280
|
+
] = None,
|
|
281
|
+
version: Annotated[str | None, typer.Option(help="Defaults to the latest.")] = None,
|
|
282
|
+
variant: Annotated[str | None, typer.Option(help="Only this folder, e.g. onnx-int8.")] = None,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Download a model.
|
|
285
|
+
|
|
286
|
+
Files land in a content-addressed cache shared by every repository, so a
|
|
287
|
+
file you already have — the tokenizer six exports share — is never
|
|
288
|
+
downloaded twice. The tree is then linked where you asked for it.
|
|
289
|
+
"""
|
|
290
|
+
with (
|
|
291
|
+
client() as registry,
|
|
292
|
+
Progress(
|
|
293
|
+
TextColumn("[dim]{task.description}"),
|
|
294
|
+
BarColumn(),
|
|
295
|
+
TextColumn("{task.completed}/{task.total} files"),
|
|
296
|
+
TimeRemainingColumn(),
|
|
297
|
+
console=console,
|
|
298
|
+
) as progress,
|
|
299
|
+
):
|
|
300
|
+
task = progress.add_task("resolving", total=None)
|
|
301
|
+
|
|
302
|
+
def tick(path: str, _size: int, count: int, _cached: bool) -> None:
|
|
303
|
+
if progress.tasks[task].total is None:
|
|
304
|
+
progress.update(task, total=count)
|
|
305
|
+
progress.update(task, advance=1, description=path[-40:])
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
result = pull_version(registry, repo, dest, version, variant, tick)
|
|
309
|
+
except SystemOneError as exc:
|
|
310
|
+
fail(str(exc))
|
|
311
|
+
|
|
312
|
+
console.print(f"[bold]{result.root}[/bold]")
|
|
313
|
+
parts = []
|
|
314
|
+
if result.downloaded:
|
|
315
|
+
parts.append(f"downloaded {human(result.downloaded)}")
|
|
316
|
+
if result.from_cache:
|
|
317
|
+
parts.append(f"{human(result.from_cache)} from cache")
|
|
318
|
+
console.print(f"[dim]{result.files} files · {' · '.join(parts) or 'nothing to fetch'}[/dim]")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@app.command()
|
|
322
|
+
def create(
|
|
323
|
+
repo: Annotated[str, typer.Argument(help="namespace/name")],
|
|
324
|
+
private: Annotated[bool, typer.Option(help="Only your namespace can see it.")] = False,
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Create an empty repository to push into later."""
|
|
327
|
+
namespace, _, name = repo.partition("/")
|
|
328
|
+
if not namespace or not name:
|
|
329
|
+
fail("repository must be namespace/name")
|
|
330
|
+
|
|
331
|
+
with client() as registry:
|
|
332
|
+
try:
|
|
333
|
+
registry.create_model(namespace, name, None, private)
|
|
334
|
+
except SystemOneError as exc:
|
|
335
|
+
fail(str(exc))
|
|
336
|
+
|
|
337
|
+
endpoint = config.web_url(config.load().endpoint)
|
|
338
|
+
console.print(f"Created [bold]{repo}[/bold]{' (private)' if private else ''}")
|
|
339
|
+
console.print(f"[dim]{endpoint}/{repo}[/dim]")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
cache_app = typer.Typer(help="Inspect or clear the local download cache.", no_args_is_help=True)
|
|
343
|
+
app.add_typer(cache_app, name="cache")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@cache_app.command("info")
|
|
347
|
+
def cache_info() -> None:
|
|
348
|
+
"""Where the cache is and how big it is."""
|
|
349
|
+
console.print(f"{config.cache_dir()}")
|
|
350
|
+
console.print(f"[dim]{human(local_cache.size())} in blobs[/dim]")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@cache_app.command("clear")
|
|
354
|
+
def cache_clear(
|
|
355
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask.")] = False,
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Delete every cached file."""
|
|
358
|
+
if not yes and not typer.confirm(f"Delete {human(local_cache.size())} of cached models?"):
|
|
359
|
+
raise typer.Exit(0)
|
|
360
|
+
console.print(f"Freed {human(local_cache.clear())}")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@app.command()
|
|
364
|
+
def push(
|
|
365
|
+
source: Annotated[Path, typer.Argument(help="Directory to publish.")],
|
|
366
|
+
repo: Annotated[str, typer.Option("--repo", "-r", help="namespace/name")],
|
|
367
|
+
version: Annotated[str, typer.Option(help="Version string.")] = "0.1.0",
|
|
368
|
+
variant: Annotated[str, typer.Option(help="Place files under this folder.")] = "",
|
|
369
|
+
manifest: Annotated[
|
|
370
|
+
Path | None, typer.Option(help="Use this instead of inferring one.")
|
|
371
|
+
] = None,
|
|
372
|
+
notes: Annotated[str | None, typer.Option(help="Release notes.")] = None,
|
|
373
|
+
private: Annotated[bool, typer.Option(help="Create the repository private.")] = False,
|
|
374
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would happen.")] = False,
|
|
375
|
+
) -> None:
|
|
376
|
+
"""Publish a directory as a version."""
|
|
377
|
+
source = source.expanduser().resolve()
|
|
378
|
+
if not source.is_dir():
|
|
379
|
+
fail(f"not a directory: {source}")
|
|
380
|
+
namespace, _, name = repo.partition("/")
|
|
381
|
+
if not namespace or not name:
|
|
382
|
+
fail("--repo must be namespace/name")
|
|
383
|
+
|
|
384
|
+
found = inspect(source)
|
|
385
|
+
if not found.files:
|
|
386
|
+
fail(f"no files under {source}")
|
|
387
|
+
|
|
388
|
+
document = manifest.read_text() if manifest else build_manifest(found, namespace, name)
|
|
389
|
+
|
|
390
|
+
console.print(
|
|
391
|
+
f"[bold]{repo}[/bold]@{version} {len(found.files)} files {human(found.total_bytes)}"
|
|
392
|
+
)
|
|
393
|
+
if not manifest:
|
|
394
|
+
source_of_truth = "inferred from the export" if found.recognised else "a starting point"
|
|
395
|
+
console.print(f"[dim]manifest {source_of_truth}[/dim]")
|
|
396
|
+
|
|
397
|
+
if dry_run:
|
|
398
|
+
console.print()
|
|
399
|
+
console.print(document.rstrip())
|
|
400
|
+
console.print("\n[dim]files:[/dim]")
|
|
401
|
+
for path in found.files:
|
|
402
|
+
placed = path.relative_to(source).as_posix()
|
|
403
|
+
console.print(f" {f'{variant}/' if variant else ''}{placed}")
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
with client() as registry:
|
|
407
|
+
check = registry.validate_manifest(document)
|
|
408
|
+
if not check["valid"]:
|
|
409
|
+
for issue in check["issues"]:
|
|
410
|
+
errs.print(f"[red]{issue['path']}[/red] {issue['message']}")
|
|
411
|
+
fail("the manifest is not valid; fix it or pass --manifest")
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
registry.model(repo)
|
|
415
|
+
except SystemOneError:
|
|
416
|
+
registry.create_model(namespace, name, document, private)
|
|
417
|
+
console.print(f"created {repo}")
|
|
418
|
+
|
|
419
|
+
deduplicated = 0
|
|
420
|
+
with Progress(
|
|
421
|
+
TextColumn("[dim]{task.description}"),
|
|
422
|
+
BarColumn(),
|
|
423
|
+
TextColumn("{task.completed}/{task.total} files"),
|
|
424
|
+
TimeRemainingColumn(),
|
|
425
|
+
console=console,
|
|
426
|
+
) as progress:
|
|
427
|
+
task = progress.add_task("uploading", total=len(found.files))
|
|
428
|
+
|
|
429
|
+
def tick(path: str, size: int, _total: int, skipped: bool) -> None:
|
|
430
|
+
nonlocal deduplicated
|
|
431
|
+
if skipped:
|
|
432
|
+
deduplicated += size
|
|
433
|
+
progress.update(task, advance=1, description=path[-40:])
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
artifacts = push_files(registry, repo, source, found.files, variant, tick)
|
|
437
|
+
except SystemOneError as exc:
|
|
438
|
+
fail(str(exc))
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
registry.publish_version(repo, version, document, artifacts, notes)
|
|
442
|
+
except SystemOneError as exc:
|
|
443
|
+
fail(str(exc))
|
|
444
|
+
|
|
445
|
+
console.print(f"Published [bold]{repo}@{version}[/bold] with {len(artifacts)} files")
|
|
446
|
+
if deduplicated:
|
|
447
|
+
console.print(
|
|
448
|
+
f"[dim]{human(deduplicated)} was already in the registry and was not sent[/dim]"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
if __name__ == "__main__":
|
|
453
|
+
app()
|