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,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
+ ]
@@ -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"]