outo-models-cli 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.
@@ -0,0 +1,173 @@
1
+ """Shared helpers for the `omc` Typer commands.
2
+
3
+ This module owns:
4
+
5
+ * the standard consoles (`console`, `err_console`) every command uses,
6
+ * the byte-size and visibility helpers,
7
+ * the `owner/name` parser,
8
+ * the `Store → (base_url, httpx.Client)` resolver,
9
+ * the local-path collector used by `upload`.
10
+
11
+ The Typer `app` and command registration live in `main.py`. Concentrating
12
+ the shared infrastructure here keeps each command module under the
13
+ 250-LOC ceiling without scattering the error contract across files.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from collections.abc import Iterable
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING
22
+
23
+ import httpx
24
+ from rich.console import Console
25
+
26
+ from outo_models_cli import api
27
+ from outo_models_cli.errors import (
28
+ FileMissingError,
29
+ FileTooLargeError,
30
+ OmcError,
31
+ )
32
+
33
+ if TYPE_CHECKING:
34
+ from outo_models_cli.config import Store
35
+
36
+ # 100 MiB per-file cap mirrors the server's multipart boundary. Anything
37
+ # larger must be uploaded via git + LFS.
38
+ _MAX_FILE_BYTES = 100 * 1024 * 1024
39
+
40
+ # 50 MiB total upload cap before the CLI auto-batches per-file. Anything
41
+ # smaller goes up in one multipart request.
42
+ _SINGLE_REQUEST_TOTAL_BYTES = 50 * 1024 * 1024
43
+
44
+ _VALID_KINDS = ("model", "dataset", "space")
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Consoles
49
+ # ---------------------------------------------------------------------------
50
+
51
+ console = Console(stderr=False)
52
+ err_console = Console(stderr=True)
53
+
54
+
55
+ def _errprint(message: str) -> None:
56
+ """Print a single-line message to stderr."""
57
+ err_console.print(f"[bold red]error[/bold red]: {message}")
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Validators
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ def _validate_kind(raw: str) -> str:
66
+ """Return `raw` if it's a known repo kind, else raise `OmcError`."""
67
+ if raw not in _VALID_KINDS:
68
+ valid = ", ".join(_VALID_KINDS)
69
+ raise OmcError(f"Invalid kind {raw!r}. Use one of: {valid}.")
70
+ return raw
71
+
72
+
73
+ def _visibility(public: bool, private: bool) -> str:
74
+ """Resolve the two-flag visibility pair into a single keyword."""
75
+ if public and private:
76
+ raise OmcError("Pass only one of --public / --private.")
77
+ if public:
78
+ return "public"
79
+ if private:
80
+ return "private"
81
+ return "private" # default matches the server's default
82
+
83
+
84
+ def _parse_owner_repo(raw: str) -> tuple[str, str]:
85
+ """Split `owner/name` into `(owner, name)`; raise on malformed input."""
86
+ if "/" not in raw or raw.count("/") > 1:
87
+ raise OmcError("Argument must be in the form `<owner>/<name>`.")
88
+ owner, name = raw.split("/", 1)
89
+ if not owner or not name:
90
+ raise OmcError(
91
+ "Argument must be in the form `<owner>/<name>` (both parts required).",
92
+ )
93
+ return owner, name
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Server resolution
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ def _resolve_target(store: Store, *, server: str | None) -> tuple[str, httpx.Client]:
102
+ """Return `(base_url, httpx.Client)` for the requested target.
103
+
104
+ The CLI uses a short-lived client per command so a failed auth does
105
+ not poison subsequent calls. Token resolution happens inside
106
+ `store.resolve` (env var → stored entry).
107
+ """
108
+ base_url, token = store.resolve(server)
109
+ return base_url, api.with_client(base_url, token)
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Upload helpers
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ def _collect_files(path: Path) -> tuple[list[Path], Path]:
118
+ """Return `(file_paths, file_root)` from a user-supplied file or folder."""
119
+ if not path.exists():
120
+ raise FileMissingError(f"Local path does not exist: {path}")
121
+ if path.is_file():
122
+ return [path], path.parent
123
+ if path.is_dir():
124
+ files = [p for p in path.rglob("*") if p.is_file()]
125
+ if not files:
126
+ raise OmcError(f"No files found under {path}.")
127
+ return files, path
128
+ raise FileMissingError(f"Not a file or directory: {path}")
129
+
130
+
131
+ def _check_size_limit(files: Iterable[Path]) -> None:
132
+ """Raise `FileTooLargeError` if any single file exceeds the per-file cap."""
133
+ for fpath in files:
134
+ if fpath.stat().st_size > _MAX_FILE_BYTES:
135
+ size_mib = fpath.stat().st_size / (1024 * 1024)
136
+ raise FileTooLargeError(
137
+ f"{fpath} is {size_mib:.1f} MiB. Files over 100 MiB must be uploaded "
138
+ "via git + LFS. See `git lfs track` and the server's LFS guide.",
139
+ )
140
+
141
+
142
+ def _human_bytes(num: int) -> str:
143
+ """Render byte counts in the operator-facing summary line."""
144
+ units = ("B", "KiB", "MiB", "GiB", "TiB")
145
+ value = float(num)
146
+ idx = 0
147
+ while value >= 1024 and idx < len(units) - 1:
148
+ value /= 1024
149
+ idx += 1
150
+ if idx == 0:
151
+ return f"{int(value)} {units[idx]}"
152
+ return f"{value:.2f} {units[idx]}"
153
+
154
+
155
+ __all__ = [
156
+ "_MAX_FILE_BYTES",
157
+ "_SINGLE_REQUEST_TOTAL_BYTES",
158
+ "_VALID_KINDS",
159
+ "_check_size_limit",
160
+ "_collect_files",
161
+ "_errprint",
162
+ "_human_bytes",
163
+ "_parse_owner_repo",
164
+ "_resolve_target",
165
+ "_validate_kind",
166
+ "_visibility",
167
+ "console",
168
+ "err_console",
169
+ ]
170
+
171
+ # `os` is used implicitly through `os.environ` callers in `commands.auth`,
172
+ # but the import here documents the dependency for readers / linters.
173
+ _ = os
@@ -0,0 +1,196 @@
1
+ """`omc auth ...` — manage stored credentials.
2
+
3
+ Four subcommands mirror the HF CLI surface:
4
+
5
+ * `login --server <url> [--token PAT] [--set-default]`
6
+ * `logout [--server <url>]`
7
+ * `whoami [--server <url>]`
8
+ * `status`
9
+
10
+ All four share the `_auth_resolve` helper, which handles URL
11
+ normalization and the env-var overrides the store layer exposes.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import getpass
17
+ import os
18
+ from typing import TYPE_CHECKING, Annotated
19
+
20
+ import typer
21
+ from rich.table import Table
22
+
23
+ from outo_models_cli import api, config
24
+ from outo_models_cli.commands._shared import (
25
+ _errprint,
26
+ console,
27
+ )
28
+ from outo_models_cli.errors import (
29
+ AuthInvalidError,
30
+ AuthRequiredError,
31
+ ConfigError,
32
+ OmcError,
33
+ )
34
+
35
+ if TYPE_CHECKING:
36
+ from outo_models_cli.config import Store
37
+
38
+ auth_app = typer.Typer(help="Manage stored credentials.", no_args_is_help=True)
39
+
40
+
41
+ def _prompt_token(url: str) -> str:
42
+ """Prompt for a PAT (masked) on the controlling terminal."""
43
+ return getpass.getpass(f"Token for {url}: ")
44
+
45
+
46
+ def _resolve_target_url(
47
+ store: Store,
48
+ *,
49
+ requested: str | None,
50
+ ) -> str:
51
+ """Normalize and validate the user-supplied server URL.
52
+
53
+ Falls back to the env override (`OMC_SERVER`) and then the configured
54
+ default so `omc auth logout` without `--server` operates on the
55
+ current target.
56
+ """
57
+ raw = requested or os.environ.get("OMC_SERVER") or store.default_server
58
+ if not raw:
59
+ raise AuthRequiredError(
60
+ "No server given. Pass --server <url> or run `omc auth login --server <url>` first.",
61
+ )
62
+ return config.normalize_server_url(raw)
63
+
64
+
65
+ @auth_app.command("login")
66
+ def auth_login(
67
+ ctx: typer.Context,
68
+ server: Annotated[
69
+ str | None,
70
+ typer.Option(help="Server URL (default: pick the first configured server)."),
71
+ ] = None,
72
+ token: Annotated[
73
+ str | None,
74
+ typer.Option(
75
+ "--token",
76
+ help="PAT to store verbatim (skips the masked prompt). Use only in scripts.",
77
+ ),
78
+ ] = None,
79
+ set_default: Annotated[
80
+ bool,
81
+ typer.Option(
82
+ "--set-default",
83
+ help="Pin this server as the default target after login.",
84
+ ),
85
+ ] = False,
86
+ ) -> None:
87
+ """Log in to a server: verify the PAT and store it."""
88
+ store: Store = ctx.obj["store"]
89
+ try:
90
+ url = _resolve_target_url(store, requested=server)
91
+ except (OmcError, AuthRequiredError, ConfigError) as exc:
92
+ _errprint(str(exc))
93
+ raise typer.Exit(code=1) from exc
94
+
95
+ pat = token or _prompt_token(url)
96
+ if not pat:
97
+ _errprint("Empty token; aborting.")
98
+ raise typer.Exit(code=1)
99
+
100
+ # Verify before persisting — a wrong token must never end up on disk.
101
+ try:
102
+ with api.with_client(url, pat) as client:
103
+ identity = api.me(client)
104
+ except AuthInvalidError:
105
+ _errprint("Server rejected the token. Re-run with a valid PAT.")
106
+ raise typer.Exit(code=1) from None
107
+ except OmcError as exc:
108
+ _errprint(str(exc))
109
+ raise typer.Exit(code=1) from exc
110
+
111
+ new_store = store.with_login(url, pat)
112
+ if set_default:
113
+ new_store = new_store.with_default(url)
114
+ config.save_store(new_store, ctx.obj["config_path"])
115
+
116
+ source = "OMC_TOKEN env var" if os.environ.get("OMC_TOKEN") == pat else "stored credential"
117
+ console.print(
118
+ f"Logged in to {url} as [bold]{identity.username}[/bold] (role: {identity.role}); "
119
+ f"token source: {source}.",
120
+ )
121
+
122
+
123
+ @auth_app.command("logout")
124
+ def auth_logout(
125
+ ctx: typer.Context,
126
+ server: Annotated[
127
+ str | None,
128
+ typer.Option(help="Server to log out (default: the configured default)."),
129
+ ] = None,
130
+ ) -> None:
131
+ """Remove the stored credential for one server."""
132
+ store: Store = ctx.obj["store"]
133
+ try:
134
+ url = _resolve_target_url(store, requested=server)
135
+ except (OmcError, AuthRequiredError, ConfigError) as exc:
136
+ _errprint(str(exc))
137
+ raise typer.Exit(code=1) from exc
138
+
139
+ if url not in store.servers:
140
+ _errprint(f"No stored credential for {url}.")
141
+ raise typer.Exit(code=1)
142
+
143
+ new_store = store.without(url)
144
+ config.save_store(new_store, ctx.obj["config_path"])
145
+ console.print(f"Removed stored credential for {url}.")
146
+
147
+
148
+ @auth_app.command("whoami")
149
+ def auth_whoami(
150
+ ctx: typer.Context,
151
+ server: Annotated[
152
+ str | None,
153
+ typer.Option(help="Server to query (default: the configured default)."),
154
+ ] = None,
155
+ ) -> None:
156
+ """Print the authenticated user, server, and token source."""
157
+ store: Store = ctx.obj["store"]
158
+ try:
159
+ url, token = store.resolve(server)
160
+ except AuthRequiredError as exc:
161
+ _errprint(str(exc))
162
+ raise typer.Exit(code=1) from exc
163
+
164
+ try:
165
+ with api.with_client(url, token) as client:
166
+ identity = api.me(client)
167
+ except OmcError as exc:
168
+ _errprint(str(exc))
169
+ raise typer.Exit(code=1) from exc
170
+
171
+ source = "OMC_TOKEN env var" if os.environ.get("OMC_TOKEN") else "stored credential"
172
+ table = Table(show_header=False, box=None)
173
+ table.add_row("server", url)
174
+ table.add_row("username", identity.username)
175
+ table.add_row("role", identity.role)
176
+ table.add_row("token source", source)
177
+ console.print(table)
178
+
179
+
180
+ @auth_app.command("status")
181
+ def auth_status(ctx: typer.Context) -> None:
182
+ """List every configured server and mark the default."""
183
+ store: Store = ctx.obj["store"]
184
+ if not store.servers:
185
+ console.print("No servers configured. Run `omc auth login --server <url>`.")
186
+ return
187
+ table = Table(show_header=True, header_style="bold")
188
+ table.add_column("Server")
189
+ table.add_column("Default")
190
+ for url in store.list_servers():
191
+ is_default = "yes" if url == store.default_server else ""
192
+ table.add_row(url, is_default)
193
+ console.print(table)
194
+
195
+
196
+ __all__ = ["auth_app"]
@@ -0,0 +1,101 @@
1
+ """`omc download <owner>/<name>` — recursive, resumable repo download."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Annotated
8
+
9
+ import typer
10
+
11
+ from outo_models_cli import downloader
12
+ from outo_models_cli.commands._shared import (
13
+ _errprint,
14
+ _human_bytes,
15
+ _parse_owner_repo,
16
+ console,
17
+ )
18
+ from outo_models_cli.errors import (
19
+ AuthRequiredError,
20
+ OmcError,
21
+ )
22
+ from outo_models_cli.http import build_async_client
23
+
24
+ if TYPE_CHECKING:
25
+ from outo_models_cli.config import Store
26
+
27
+
28
+ def download_command(
29
+ ctx: typer.Context,
30
+ repo: Annotated[str, typer.Argument(help="Repository as `<owner>/<name>`.")],
31
+ revision: Annotated[
32
+ str,
33
+ typer.Option(help="Branch, tag, or commit SHA (default: main)."),
34
+ ] = "main",
35
+ include: Annotated[
36
+ list[str] | None,
37
+ typer.Option(
38
+ "--include",
39
+ help="Glob of paths to include (repeatable). Defaults to everything.",
40
+ ),
41
+ ] = None,
42
+ exclude: Annotated[
43
+ list[str] | None,
44
+ typer.Option(
45
+ "--exclude",
46
+ help="Glob of paths to exclude (repeatable). Defaults to nothing.",
47
+ ),
48
+ ] = None,
49
+ local_dir: Annotated[
50
+ Path,
51
+ typer.Option(help="Destination directory (default: ./<repo>)."),
52
+ ] = Path("."),
53
+ max_workers: Annotated[
54
+ int,
55
+ typer.Option(help="Parallel download workers (default: 8)."),
56
+ ] = 8,
57
+ server: Annotated[str | None, typer.Option(help="Target server URL.")] = None,
58
+ ) -> None:
59
+ """Recursively download a repository to `--local-dir`."""
60
+ store: Store = ctx.obj["store"]
61
+ try:
62
+ owner, name = _parse_owner_repo(repo)
63
+ base_url, token = store.resolve(server)
64
+ except (OmcError, AuthRequiredError) as exc:
65
+ _errprint(str(exc))
66
+ raise typer.Exit(code=1) from exc
67
+
68
+ target_dir = local_dir if local_dir != Path(".") else Path(name)
69
+ cfg = downloader.DownloadConfig(
70
+ revision=revision,
71
+ include=include or [],
72
+ exclude=exclude or [],
73
+ local_dir=target_dir,
74
+ max_workers=max_workers,
75
+ )
76
+
77
+ client = build_async_client(base_url, token)
78
+ try:
79
+ outcomes = asyncio.run(
80
+ downloader.download_repo(
81
+ client=client,
82
+ base_url=base_url,
83
+ token=token,
84
+ owner=owner,
85
+ name=name,
86
+ config=cfg,
87
+ )
88
+ )
89
+ except OmcError as exc:
90
+ _errprint(str(exc))
91
+ raise typer.Exit(code=1) from exc
92
+
93
+ total_bytes = sum(o.bytes_downloaded for o in outcomes)
94
+ resumed = sum(1 for o in outcomes if o.resumed)
95
+ console.print(
96
+ f"Downloaded {len(outcomes)} file(s) ({_human_bytes(total_bytes)}) to {target_dir}. "
97
+ f"Resumed: {resumed}.",
98
+ )
99
+
100
+
101
+ __all__ = ["download_command"]
@@ -0,0 +1,82 @@
1
+ """`omc ls <owner>/<name>` — list one directory of a repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Annotated
6
+
7
+ import typer
8
+ from rich.table import Table
9
+
10
+ from outo_models_cli import api
11
+ from outo_models_cli.commands._shared import (
12
+ _errprint,
13
+ _human_bytes,
14
+ _parse_owner_repo,
15
+ _resolve_target,
16
+ console,
17
+ )
18
+ from outo_models_cli.errors import (
19
+ AuthRequiredError,
20
+ OmcError,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from outo_models_cli.config import Store
25
+
26
+
27
+ def ls_command(
28
+ ctx: typer.Context,
29
+ repo: Annotated[str, typer.Argument(help="Repository as `<owner>/<name>`.")],
30
+ path: Annotated[
31
+ str,
32
+ typer.Option(help="Directory inside the repo to list (default: root)."),
33
+ ] = "",
34
+ revision: Annotated[
35
+ str,
36
+ typer.Option(help="Branch, tag, or commit SHA (default: main)."),
37
+ ] = "main",
38
+ server: Annotated[str | None, typer.Option(help="Target server URL.")] = None,
39
+ ) -> None:
40
+ """List one directory of a repository."""
41
+ store: Store = ctx.obj["store"]
42
+ try:
43
+ owner, name = _parse_owner_repo(repo)
44
+ _base_url, client = _resolve_target(store, server=server)
45
+ except (OmcError, AuthRequiredError) as exc:
46
+ _errprint(str(exc))
47
+ raise typer.Exit(code=1) from exc
48
+
49
+ try:
50
+ with client:
51
+ rows = api.list_files(
52
+ client,
53
+ owner=owner,
54
+ name=name,
55
+ path=path,
56
+ revision=revision,
57
+ )
58
+ except OmcError as exc:
59
+ _errprint(str(exc))
60
+ raise typer.Exit(code=1) from exc
61
+
62
+ if not rows:
63
+ console.print("(empty directory)")
64
+ return
65
+ table = Table(show_header=True, header_style="bold")
66
+ table.add_column("Name")
67
+ table.add_column("Kind")
68
+ table.add_column("Size")
69
+ for entry in rows:
70
+ if entry.kind == "dir":
71
+ size = "-"
72
+ elif entry.size_bytes is None:
73
+ size = "?"
74
+ elif entry.size_bytes == 0:
75
+ size = "0 B"
76
+ else:
77
+ size = _human_bytes(entry.size_bytes)
78
+ table.add_row(entry.name, entry.kind, size)
79
+ console.print(table)
80
+
81
+
82
+ __all__ = ["ls_command"]