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,161 @@
|
|
|
1
|
+
"""Recursive, resumable downloader.
|
|
2
|
+
|
|
3
|
+
Why one package owns the whole flow (not the CLI command):
|
|
4
|
+
|
|
5
|
+
* The CLI command renders progress bars; the downloader is the only
|
|
6
|
+
layer that knows chunk sizes, byte counts, and resume semantics.
|
|
7
|
+
* A single transport (`httpx.AsyncClient`) avoids the concurrency
|
|
8
|
+
trap of running sync calls inside a thread pool: one connection
|
|
9
|
+
pool, one auth header, one close path.
|
|
10
|
+
* Streaming integrity checks (ETag / Content-Length) belong with
|
|
11
|
+
the byte-level logic; mixing them into the CLI renderer would
|
|
12
|
+
scatter the wire contract across files.
|
|
13
|
+
|
|
14
|
+
The public entry point is `outo_models_cli.downloader.download_repo` —
|
|
15
|
+
a coroutine that walks the remote tree, schedules parallel file
|
|
16
|
+
transfers under an asyncio semaphore, and yields per-file outcomes the
|
|
17
|
+
CLI renderer can attach Rich bars to.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
from rich.progress import (
|
|
30
|
+
BarColumn,
|
|
31
|
+
DownloadColumn,
|
|
32
|
+
Progress,
|
|
33
|
+
TextColumn,
|
|
34
|
+
TimeRemainingColumn,
|
|
35
|
+
TransferSpeedColumn,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from outo_models_cli import api
|
|
39
|
+
from outo_models_cli.downloader.transfer import FileOutcome, transfer_one
|
|
40
|
+
from outo_models_cli.downloader.walk import walk_repo_async
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class DownloadConfig:
|
|
45
|
+
"""All knobs that change the downloader behavior.
|
|
46
|
+
|
|
47
|
+
Centralising them in a frozen dataclass keeps the download coroutine
|
|
48
|
+
signature short (the function takes one config object) and lets
|
|
49
|
+
tests pin every value at once. Defaults match the values the CLI
|
|
50
|
+
commands set when invoked from `omc download`.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
revision: str = "main"
|
|
54
|
+
include: list[str] = field(default_factory=list)
|
|
55
|
+
exclude: list[str] = field(default_factory=list)
|
|
56
|
+
local_dir: Path = Path(".")
|
|
57
|
+
max_workers: int = 8
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_progress() -> Progress:
|
|
61
|
+
"""Return the per-file Rich progress bar group.
|
|
62
|
+
|
|
63
|
+
Exposed for the CLI command so the same columns render in tests as
|
|
64
|
+
in production. A custom column set (instead of the default
|
|
65
|
+
`Progress()`) gives us a fixed-width `description` cell, which
|
|
66
|
+
keeps the multi-file bars aligned when filenames vary in length.
|
|
67
|
+
"""
|
|
68
|
+
return Progress(
|
|
69
|
+
TextColumn("[bold blue]{task.description}[/bold blue]", justify="left"),
|
|
70
|
+
BarColumn(bar_width=40),
|
|
71
|
+
DownloadColumn(),
|
|
72
|
+
TransferSpeedColumn(),
|
|
73
|
+
TextColumn("eta"),
|
|
74
|
+
TimeRemainingColumn(),
|
|
75
|
+
expand=True,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
ProgressCallback = Callable[[Progress, Any], None]
|
|
80
|
+
"""Hook the CLI uses to bind a per-file task. Kept as a thin callback
|
|
81
|
+
so the downloader never imports the CLI renderer directly."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def download_repo(
|
|
85
|
+
*,
|
|
86
|
+
client: httpx.AsyncClient,
|
|
87
|
+
base_url: str,
|
|
88
|
+
token: str,
|
|
89
|
+
owner: str,
|
|
90
|
+
name: str,
|
|
91
|
+
config: DownloadConfig,
|
|
92
|
+
on_progress: Callable[[str], None] | None = None,
|
|
93
|
+
) -> list[FileOutcome]:
|
|
94
|
+
"""Walk `owner/name` and stream every file under `config.local_dir`.
|
|
95
|
+
|
|
96
|
+
Returns one `FileOutcome` per file, in the order the tree walk
|
|
97
|
+
produced (depth-first, alphabetical inside each directory). The
|
|
98
|
+
caller is expected to run this coroutine under
|
|
99
|
+
`asyncio.run(...)` and is free to wrap it with their own progress
|
|
100
|
+
rendering — `on_progress` is a per-file completion notification the
|
|
101
|
+
CLI uses to print a one-line summary without subscribing to the
|
|
102
|
+
Rich bar stream.
|
|
103
|
+
"""
|
|
104
|
+
files = await walk_repo_async(
|
|
105
|
+
client,
|
|
106
|
+
owner=owner,
|
|
107
|
+
name=name,
|
|
108
|
+
revision=config.revision,
|
|
109
|
+
include=config.include,
|
|
110
|
+
exclude=config.exclude,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
config.local_dir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
semaphore = asyncio.Semaphore(max(1, config.max_workers))
|
|
115
|
+
|
|
116
|
+
progress = build_progress()
|
|
117
|
+
outcomes: list[FileOutcome] = []
|
|
118
|
+
with progress:
|
|
119
|
+
tasks: list[asyncio.Future[FileOutcome]] = []
|
|
120
|
+
|
|
121
|
+
async def _bound(entry: api.FileEntry) -> FileOutcome:
|
|
122
|
+
target = config.local_dir / entry.path
|
|
123
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
task_id = progress.add_task(
|
|
125
|
+
entry.path,
|
|
126
|
+
total=entry.size_bytes,
|
|
127
|
+
completed=0,
|
|
128
|
+
)
|
|
129
|
+
async with semaphore:
|
|
130
|
+
outcome = await transfer_one(
|
|
131
|
+
client,
|
|
132
|
+
owner=owner,
|
|
133
|
+
name=name,
|
|
134
|
+
revision=config.revision,
|
|
135
|
+
file=entry,
|
|
136
|
+
target=target,
|
|
137
|
+
progress=progress,
|
|
138
|
+
task_id=task_id,
|
|
139
|
+
)
|
|
140
|
+
progress.remove_task(task_id)
|
|
141
|
+
return outcome
|
|
142
|
+
|
|
143
|
+
for entry in files:
|
|
144
|
+
tasks.append(asyncio.ensure_future(_bound(entry)))
|
|
145
|
+
|
|
146
|
+
for coro in asyncio.as_completed(tasks):
|
|
147
|
+
outcome = await coro
|
|
148
|
+
outcomes.append(outcome)
|
|
149
|
+
if on_progress is not None:
|
|
150
|
+
on_progress(outcome.path)
|
|
151
|
+
|
|
152
|
+
return outcomes
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
__all__ = [
|
|
156
|
+
"DownloadConfig",
|
|
157
|
+
"FileOutcome",
|
|
158
|
+
"ProgressCallback",
|
|
159
|
+
"build_progress",
|
|
160
|
+
"download_repo",
|
|
161
|
+
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Stream one file with resume + integrity semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from rich.progress import Progress
|
|
13
|
+
|
|
14
|
+
from outo_models_cli import api
|
|
15
|
+
from outo_models_cli.errors import NotFoundError, map_response_error, map_transport_error
|
|
16
|
+
|
|
17
|
+
# 1 MiB chunk size — small enough to keep memory bounded even when the
|
|
18
|
+
# CLI streams many parallel downloads; large enough that the chunk
|
|
19
|
+
# overhead is negligible against the per-byte disk cost.
|
|
20
|
+
CHUNK_BYTES = 1024 * 1024
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class FileOutcome:
|
|
25
|
+
"""One row in the download report the CLI prints at the end."""
|
|
26
|
+
|
|
27
|
+
path: str
|
|
28
|
+
bytes_downloaded: int
|
|
29
|
+
resumed: bool
|
|
30
|
+
verified: bool # True when an ETag / sha check was performed
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def transfer_one(
|
|
34
|
+
client: httpx.AsyncClient,
|
|
35
|
+
*,
|
|
36
|
+
owner: str,
|
|
37
|
+
name: str,
|
|
38
|
+
revision: str,
|
|
39
|
+
file: api.FileEntry,
|
|
40
|
+
target: Path,
|
|
41
|
+
progress: Progress,
|
|
42
|
+
task_id: Any,
|
|
43
|
+
) -> FileOutcome:
|
|
44
|
+
"""Stream `file` from the server to `target`, honouring `.part` resume."""
|
|
45
|
+
url = api.resolve_url(owner=owner, name=name, revision=revision, path=file.path)
|
|
46
|
+
part_path = target.with_suffix(target.suffix + ".part")
|
|
47
|
+
existing_size = part_path.stat().st_size if part_path.exists() else 0
|
|
48
|
+
|
|
49
|
+
headers: dict[str, str] = {}
|
|
50
|
+
if existing_size > 0:
|
|
51
|
+
headers["Range"] = f"bytes={existing_size}-"
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
async with client.stream("GET", url, headers=headers) as response:
|
|
55
|
+
if response.status_code == 416:
|
|
56
|
+
# Range not satisfiable → server's file shrunk or our
|
|
57
|
+
# .part is corrupt. Restart from scratch.
|
|
58
|
+
part_path.unlink(missing_ok=True)
|
|
59
|
+
return await transfer_one(
|
|
60
|
+
client,
|
|
61
|
+
owner=owner,
|
|
62
|
+
name=name,
|
|
63
|
+
revision=revision,
|
|
64
|
+
file=file,
|
|
65
|
+
target=target,
|
|
66
|
+
progress=progress,
|
|
67
|
+
task_id=task_id,
|
|
68
|
+
)
|
|
69
|
+
if response.status_code == 404:
|
|
70
|
+
raise NotFoundError(f"File not found on server: {file.path}")
|
|
71
|
+
if response.status_code >= 400:
|
|
72
|
+
raise map_response_error(response)
|
|
73
|
+
|
|
74
|
+
server_etag = response.headers.get("ETag")
|
|
75
|
+
content_length_raw = response.headers.get("Content-Length")
|
|
76
|
+
content_length = (
|
|
77
|
+
int(content_length_raw)
|
|
78
|
+
if content_length_raw and content_length_raw.isdigit()
|
|
79
|
+
else None
|
|
80
|
+
)
|
|
81
|
+
expected_total = file.size_bytes
|
|
82
|
+
|
|
83
|
+
if response.status_code == 200:
|
|
84
|
+
# Server ignored Range and is sending the full file
|
|
85
|
+
# (ETag mismatch, or no Range header was sent).
|
|
86
|
+
resumed = False
|
|
87
|
+
part_path.unlink(missing_ok=True)
|
|
88
|
+
mode = "wb"
|
|
89
|
+
existing_size = 0
|
|
90
|
+
download_total = expected_total if expected_total is not None else 0
|
|
91
|
+
else: # 206 Partial Content
|
|
92
|
+
resumed = existing_size > 0
|
|
93
|
+
mode = "ab"
|
|
94
|
+
download_total = (
|
|
95
|
+
(expected_total - existing_size)
|
|
96
|
+
if expected_total is not None and existing_size <= expected_total
|
|
97
|
+
else 0
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
bytes_written = existing_size
|
|
101
|
+
progress.update(
|
|
102
|
+
task_id,
|
|
103
|
+
total=(download_total + bytes_written) or None,
|
|
104
|
+
completed=bytes_written,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# ETag may be a quoted hex sha or a weak validator. The
|
|
108
|
+
# integrity check is a verbatim match against the value the
|
|
109
|
+
# server sent on this response; mismatches between calls
|
|
110
|
+
# signal "the file changed; restart from scratch" above.
|
|
111
|
+
sha = hashlib.sha256() if server_etag and "sha256" in server_etag.lower() else None
|
|
112
|
+
|
|
113
|
+
with part_path.open(mode) as fp:
|
|
114
|
+
async for chunk in response.aiter_bytes(chunk_size=CHUNK_BYTES):
|
|
115
|
+
if not chunk:
|
|
116
|
+
continue
|
|
117
|
+
fp.write(chunk)
|
|
118
|
+
bytes_written += len(chunk)
|
|
119
|
+
progress.update(task_id, completed=bytes_written)
|
|
120
|
+
if sha is not None:
|
|
121
|
+
sha.update(chunk)
|
|
122
|
+
|
|
123
|
+
# Atomic rename to the final target. If anything in this block
|
|
124
|
+
# raises (disk full, permissions), the .part file is left on
|
|
125
|
+
# disk for the next run to resume.
|
|
126
|
+
os.replace(part_path, target)
|
|
127
|
+
|
|
128
|
+
verified = server_etag is not None or content_length is not None
|
|
129
|
+
|
|
130
|
+
return FileOutcome(
|
|
131
|
+
path=file.path,
|
|
132
|
+
bytes_downloaded=bytes_written,
|
|
133
|
+
resumed=resumed,
|
|
134
|
+
verified=verified,
|
|
135
|
+
)
|
|
136
|
+
except httpx.HTTPError as exc:
|
|
137
|
+
raise map_transport_error(exc) from exc
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
__all__ = ["transfer_one"]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Recursive tree walk over the server's `/files` endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from outo_models_cli import api
|
|
10
|
+
from outo_models_cli.errors import OmcError, map_response_error, map_transport_error
|
|
11
|
+
from outo_models_cli.matchers import passes
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def list_files_async(
|
|
15
|
+
client: httpx.AsyncClient,
|
|
16
|
+
*,
|
|
17
|
+
owner: str,
|
|
18
|
+
name: str,
|
|
19
|
+
path: str,
|
|
20
|
+
revision: str,
|
|
21
|
+
) -> list[api.FileEntry]:
|
|
22
|
+
"""Async equivalent of `api.list_files` (uses the shared async client)."""
|
|
23
|
+
try:
|
|
24
|
+
response = await client.get(
|
|
25
|
+
f"/api/repos/{owner}/{name}/files",
|
|
26
|
+
params={
|
|
27
|
+
"path": path,
|
|
28
|
+
**({"revision": revision} if revision else {}),
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
except httpx.HTTPError as exc:
|
|
32
|
+
raise map_transport_error(exc) from exc
|
|
33
|
+
if response.status_code >= 400:
|
|
34
|
+
raise map_response_error(response)
|
|
35
|
+
payload: Any = response.json()
|
|
36
|
+
rows = payload.get("entries", []) if isinstance(payload, dict) else []
|
|
37
|
+
if not isinstance(rows, list):
|
|
38
|
+
raise OmcError("`/files` returned a malformed `entries` field.")
|
|
39
|
+
entries: list[api.FileEntry] = []
|
|
40
|
+
for row in rows:
|
|
41
|
+
if not isinstance(row, dict):
|
|
42
|
+
continue
|
|
43
|
+
size = row.get("size_bytes")
|
|
44
|
+
entries.append(
|
|
45
|
+
api.FileEntry(
|
|
46
|
+
name=str(row.get("name", "")),
|
|
47
|
+
path=str(row.get("path", "")),
|
|
48
|
+
kind=str(row.get("kind", "")),
|
|
49
|
+
size_bytes=int(size) if isinstance(size, (int, float)) else None,
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
return entries
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def walk_repo_async(
|
|
56
|
+
client: httpx.AsyncClient,
|
|
57
|
+
*,
|
|
58
|
+
owner: str,
|
|
59
|
+
name: str,
|
|
60
|
+
revision: str,
|
|
61
|
+
include: list[str],
|
|
62
|
+
exclude: list[str],
|
|
63
|
+
) -> list[api.FileEntry]:
|
|
64
|
+
"""Recursively enumerate every file under a repo (depth-first)."""
|
|
65
|
+
out: list[api.FileEntry] = []
|
|
66
|
+
|
|
67
|
+
async def visit(directory: str) -> None:
|
|
68
|
+
rows = await list_files_async(
|
|
69
|
+
client,
|
|
70
|
+
owner=owner,
|
|
71
|
+
name=name,
|
|
72
|
+
path=directory,
|
|
73
|
+
revision=revision,
|
|
74
|
+
)
|
|
75
|
+
for entry in rows:
|
|
76
|
+
if entry.kind == "dir":
|
|
77
|
+
await visit(entry.path)
|
|
78
|
+
elif entry.kind == "file" and passes(
|
|
79
|
+
entry.path,
|
|
80
|
+
include=include,
|
|
81
|
+
exclude=exclude,
|
|
82
|
+
):
|
|
83
|
+
out.append(entry)
|
|
84
|
+
|
|
85
|
+
await visit("")
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = ["list_files_async", "walk_repo_async"]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Typed error hierarchy for `outo-models-cli`.
|
|
2
|
+
|
|
3
|
+
Every failure surfaced to the user flows through an `OmcError` subclass so
|
|
4
|
+
the CLI can render a single clean English line and exit 1, never a Python
|
|
5
|
+
traceback (matches the server's `OutoError` discipline in AGENTS.md §2.1).
|
|
6
|
+
|
|
7
|
+
The `code` is a stable machine identifier the caller (a shell wrapper, a
|
|
8
|
+
test) can branch on without parsing prose. The `message` is human-facing
|
|
9
|
+
English and may evolve.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OmcError(Exception):
|
|
20
|
+
"""Base class for every CLI-facing error.
|
|
21
|
+
|
|
22
|
+
`code` is the stable identifier (no spaces, no punctuation). `message`
|
|
23
|
+
is the user-facing English line printed by `render_error()`.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
code: str = "omc_error"
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str = "", *, code: str | None = None) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
if code is not None:
|
|
31
|
+
self.code = code
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConfigError(OmcError):
|
|
35
|
+
"""Configuration is missing, malformed, or refuses to load."""
|
|
36
|
+
|
|
37
|
+
code = "config_error"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthRequiredError(OmcError):
|
|
41
|
+
"""No credentials are stored for the requested server."""
|
|
42
|
+
|
|
43
|
+
code = "auth_required"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuthInvalidError(OmcError):
|
|
47
|
+
"""The server rejected the supplied token (HTTP 401)."""
|
|
48
|
+
|
|
49
|
+
code = "auth_invalid"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ForbiddenError(OmcError):
|
|
53
|
+
"""The server rejected the request (HTTP 403)."""
|
|
54
|
+
|
|
55
|
+
code = "forbidden"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class NotFoundError(OmcError):
|
|
59
|
+
"""The server replied 404 for the requested resource."""
|
|
60
|
+
|
|
61
|
+
code = "not_found"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FileTooLargeError(OmcError):
|
|
65
|
+
"""Single file exceeds the 100 MiB multipart cap; use git + LFS."""
|
|
66
|
+
|
|
67
|
+
code = "file_too_large"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ValidationFailedError(OmcError):
|
|
71
|
+
"""The server rejected the request as malformed (HTTP 422)."""
|
|
72
|
+
|
|
73
|
+
code = "validation_failed"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ServerUnreachableError(OmcError):
|
|
77
|
+
"""Cannot reach the server (DNS, connection refused, timeout)."""
|
|
78
|
+
|
|
79
|
+
code = "server_unreachable"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BadResponseError(OmcError):
|
|
83
|
+
"""The server replied but the response could not be consumed."""
|
|
84
|
+
|
|
85
|
+
code = "bad_response"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class FileMissingError(OmcError):
|
|
89
|
+
"""A local file passed to `omc upload` does not exist."""
|
|
90
|
+
|
|
91
|
+
code = "file_missing"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Status code → exception class mapping. Anything outside this table is
|
|
95
|
+
# treated as a generic `BadResponseError`.
|
|
96
|
+
_STATUS_MAP: dict[int, type[OmcError]] = {
|
|
97
|
+
401: AuthInvalidError,
|
|
98
|
+
403: ForbiddenError,
|
|
99
|
+
404: NotFoundError,
|
|
100
|
+
413: FileTooLargeError,
|
|
101
|
+
422: ValidationFailedError,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def map_response_error(response: httpx.Response, *, fallback: str | None = None) -> OmcError:
|
|
106
|
+
"""Convert an `httpx.Response` into the appropriate `OmcError`.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
response: The error response (status >= 400).
|
|
110
|
+
fallback: Override for the rendered message; defaults to the
|
|
111
|
+
status-mapped text below.
|
|
112
|
+
|
|
113
|
+
The function inspects `response.status_code` first and falls back to
|
|
114
|
+
`BadResponseError` for everything else, so a future 418 from the
|
|
115
|
+
server still surfaces as a clean line instead of leaking a traceback.
|
|
116
|
+
"""
|
|
117
|
+
status = response.status_code
|
|
118
|
+
detail: str = ""
|
|
119
|
+
try:
|
|
120
|
+
payload: Any = response.json()
|
|
121
|
+
except ValueError:
|
|
122
|
+
payload = None
|
|
123
|
+
if isinstance(payload, dict):
|
|
124
|
+
raw = payload.get("detail") or payload.get("message") or payload.get("error")
|
|
125
|
+
if isinstance(raw, str):
|
|
126
|
+
detail = raw
|
|
127
|
+
message = fallback or _default_message(status, detail)
|
|
128
|
+
cls = _STATUS_MAP.get(status, BadResponseError)
|
|
129
|
+
return cls(message, code=cls.code)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def map_transport_error(exc: httpx.HTTPError) -> OmcError:
|
|
133
|
+
"""Wrap an `httpx.HTTPError` as `ServerUnreachableError`.
|
|
134
|
+
|
|
135
|
+
Used by the API client so transport-layer failures (DNS, refusal,
|
|
136
|
+
timeouts, TLS) surface with a single stable message instead of leaking
|
|
137
|
+
the underlying class name into the operator's terminal.
|
|
138
|
+
"""
|
|
139
|
+
return ServerUnreachableError(
|
|
140
|
+
"Cannot reach the server. Check the URL, network, and that the server is running.",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _default_message(status: int, detail: str) -> str:
|
|
145
|
+
if detail:
|
|
146
|
+
return detail
|
|
147
|
+
if status == 401:
|
|
148
|
+
return "Not authenticated. Run `omc auth login --server <url>` to store a token."
|
|
149
|
+
if status == 403:
|
|
150
|
+
return "Forbidden: you do not have permission for this operation."
|
|
151
|
+
if status == 404:
|
|
152
|
+
return "Resource not found."
|
|
153
|
+
if status == 413:
|
|
154
|
+
return (
|
|
155
|
+
"Upload too large. Files over 100 MiB must be uploaded via git + LFS. "
|
|
156
|
+
"Try splitting the file or using `git lfs track`."
|
|
157
|
+
)
|
|
158
|
+
if status == 422:
|
|
159
|
+
return "The server rejected the request as invalid. Check paths and arguments."
|
|
160
|
+
if 400 <= status < 500:
|
|
161
|
+
return f"Request failed (HTTP {status})."
|
|
162
|
+
return f"Server returned an unexpected error (HTTP {status})."
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = [
|
|
166
|
+
"AuthInvalidError",
|
|
167
|
+
"AuthRequiredError",
|
|
168
|
+
"BadResponseError",
|
|
169
|
+
"ConfigError",
|
|
170
|
+
"FileMissingError",
|
|
171
|
+
"FileTooLargeError",
|
|
172
|
+
"ForbiddenError",
|
|
173
|
+
"NotFoundError",
|
|
174
|
+
"OmcError",
|
|
175
|
+
"ServerUnreachableError",
|
|
176
|
+
"ValidationFailedError",
|
|
177
|
+
"map_response_error",
|
|
178
|
+
"map_transport_error",
|
|
179
|
+
]
|
outo_models_cli/http.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""HTTP client construction helpers.
|
|
2
|
+
|
|
3
|
+
Centralises the timeout / header / base-URL defaults so every API call
|
|
4
|
+
shares the same transport. The factory returns a fresh `httpx.Client`
|
|
5
|
+
each call — short-lived, single-purpose clients are easier to reason
|
|
6
|
+
about than a long-lived shared one (no state bleed between commands),
|
|
7
|
+
and the cost is one TCP handshake per CLI invocation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
# 30s connect / read budget — the server is local in a self-hosted
|
|
17
|
+
# deployment, but a slow disk or large multipart may legitimately hold
|
|
18
|
+
# the connection for a few seconds. Anything longer is genuinely hung.
|
|
19
|
+
_TIMEOUT = httpx.Timeout(30.0, connect=5.0)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_client(
|
|
23
|
+
base_url: str,
|
|
24
|
+
token: str,
|
|
25
|
+
*,
|
|
26
|
+
follow_redirects: bool = False,
|
|
27
|
+
**client_kwargs: Any,
|
|
28
|
+
) -> httpx.Client:
|
|
29
|
+
"""Return an `httpx.Client` pre-wired with the bearer credential.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
base_url: The full origin the server listens on. The path prefix
|
|
33
|
+
(`/api`, `/{owner}/{name}/resolve/...`) is appended per call.
|
|
34
|
+
token: Personal Access Token used as the bearer credential.
|
|
35
|
+
follow_redirects: Set to `True` for LFS pointer redirects. Other
|
|
36
|
+
endpoints MUST stay non-redirecting so a misconfigured proxy
|
|
37
|
+
cannot silently absorb a token via 30x.
|
|
38
|
+
**client_kwargs: Forwarded to `httpx.Client` (used by tests to
|
|
39
|
+
inject a `respx` transport via `transport=`).
|
|
40
|
+
"""
|
|
41
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
42
|
+
return httpx.Client(
|
|
43
|
+
base_url=base_url.rstrip("/"),
|
|
44
|
+
timeout=_TIMEOUT,
|
|
45
|
+
headers=headers,
|
|
46
|
+
follow_redirects=follow_redirects,
|
|
47
|
+
**client_kwargs,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_async_client(
|
|
52
|
+
base_url: str,
|
|
53
|
+
token: str,
|
|
54
|
+
*,
|
|
55
|
+
follow_redirects: bool = False,
|
|
56
|
+
**client_kwargs: Any,
|
|
57
|
+
) -> httpx.AsyncClient:
|
|
58
|
+
"""Async equivalent of `build_client`.
|
|
59
|
+
|
|
60
|
+
Used by the parallel `download` command. `base_url` is normalized the
|
|
61
|
+
same way as the sync version so URL joins are consistent.
|
|
62
|
+
"""
|
|
63
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
64
|
+
return httpx.AsyncClient(
|
|
65
|
+
base_url=base_url.rstrip("/"),
|
|
66
|
+
timeout=_TIMEOUT,
|
|
67
|
+
headers=headers,
|
|
68
|
+
follow_redirects=follow_redirects,
|
|
69
|
+
**client_kwargs,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = ["build_async_client", "build_client"]
|