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.
- outo_models_cli/__init__.py +19 -0
- outo_models_cli/__main__.py +6 -0
- outo_models_cli/api/__init__.py +223 -0
- outo_models_cli/api/auth.py +27 -0
- outo_models_cli/api/repos.py +189 -0
- outo_models_cli/api/upload.py +107 -0
- outo_models_cli/commands/__init__.py +1 -0
- outo_models_cli/commands/_shared.py +173 -0
- outo_models_cli/commands/auth.py +196 -0
- outo_models_cli/commands/download.py +101 -0
- outo_models_cli/commands/ls.py +82 -0
- outo_models_cli/commands/repo.py +169 -0
- outo_models_cli/commands/upload.py +118 -0
- outo_models_cli/config.py +308 -0
- outo_models_cli/downloader/__init__.py +161 -0
- outo_models_cli/downloader/transfer.py +140 -0
- outo_models_cli/downloader/walk.py +89 -0
- outo_models_cli/errors.py +179 -0
- outo_models_cli/http.py +73 -0
- outo_models_cli/main.py +162 -0
- outo_models_cli/matchers.py +72 -0
- outo_models_cli-0.1.0.dist-info/METADATA +105 -0
- outo_models_cli-0.1.0.dist-info/RECORD +26 -0
- outo_models_cli-0.1.0.dist-info/WHEEL +4 -0
- outo_models_cli-0.1.0.dist-info/entry_points.txt +3 -0
- outo_models_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -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"]
|