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 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()