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,19 @@
|
|
|
1
|
+
"""`outo-models-cli` — the user-facing command-line client for self-hosted outo-models servers.
|
|
2
|
+
|
|
3
|
+
Mirrors the surface area of `huggingface-cli` so users familiar with the Hub
|
|
4
|
+
can drive a self-hosted server without learning a new vocabulary:
|
|
5
|
+
|
|
6
|
+
* `omc auth login --server ...` stores a PAT per server.
|
|
7
|
+
* `omc repo create / delete / list` manage repositories.
|
|
8
|
+
* `omc ls <owner>/<name>` lists a directory at a revision.
|
|
9
|
+
* `omc download <owner>/<name>` streams a repository (resumable).
|
|
10
|
+
* `omc upload <owner>/<name> <path>` ships one or many files.
|
|
11
|
+
|
|
12
|
+
The package is intentionally tiny: it owns the user-visible surface, nothing
|
|
13
|
+
else. The server itself ships separately as `outo-models`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Typed client for the public outo-models REST API.
|
|
2
|
+
|
|
3
|
+
Each public method in this package is a thin wrapper around one HTTP
|
|
4
|
+
call. The shared logic — error mapping, transport-layer wrapping, auth
|
|
5
|
+
header insertion — lives in `http.build_client`. Command modules never
|
|
6
|
+
touch `httpx` directly so a wire-format change (renamed field, new
|
|
7
|
+
envelope) only touches one file.
|
|
8
|
+
|
|
9
|
+
The methods are *synchronous* because the CLI commands that use them
|
|
10
|
+
(single-shot ops like `repo create`, `repo list`, `auth whoami`, `ls`)
|
|
11
|
+
are short-lived and benefit from the simpler control flow. The
|
|
12
|
+
streaming download command uses `http.build_async_client` directly
|
|
13
|
+
because parallelism is the whole point of that command.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from outo_models_cli.errors import (
|
|
25
|
+
BadResponseError,
|
|
26
|
+
map_transport_error,
|
|
27
|
+
)
|
|
28
|
+
from outo_models_cli.http import build_client
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Dataclasses — response shapes, pinned for the wire contract
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class WhoAmI:
|
|
37
|
+
"""Result of `GET /api/auth/me`."""
|
|
38
|
+
|
|
39
|
+
username: str
|
|
40
|
+
role: str
|
|
41
|
+
server: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class RepoSummary:
|
|
46
|
+
"""Subset of `GET /api/repos` / `POST /api/repos` payloads.
|
|
47
|
+
|
|
48
|
+
Fields not needed by the CLI (e.g. `id`, timestamps) are dropped on
|
|
49
|
+
the server's JSON payload so the response stays minimal. Commands
|
|
50
|
+
that need the full record should call `get_repo()`.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
kind: str
|
|
55
|
+
visibility: str
|
|
56
|
+
description: str | None
|
|
57
|
+
size_bytes: int
|
|
58
|
+
owner: str
|
|
59
|
+
clone_url: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class RepoDetail(RepoSummary):
|
|
64
|
+
"""`GET /api/repos/{owner}/{name}` payload."""
|
|
65
|
+
|
|
66
|
+
downloads_count: int = 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class FileEntry:
|
|
71
|
+
"""One row in `GET /api/repos/{owner}/{name}/files`."""
|
|
72
|
+
|
|
73
|
+
name: str
|
|
74
|
+
path: str
|
|
75
|
+
kind: str # "file" | "dir"
|
|
76
|
+
size_bytes: int | None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class UploadResult:
|
|
81
|
+
"""Server's response to a successful `POST .../upload`."""
|
|
82
|
+
|
|
83
|
+
commit_sha: str
|
|
84
|
+
files: list[str]
|
|
85
|
+
message: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Shared JSON unwrap + error funnel
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def unwrap(response: httpx.Response) -> dict[str, Any]:
|
|
94
|
+
"""Parse a JSON object body or raise a clean error."""
|
|
95
|
+
if response.status_code == 204 or not response.content:
|
|
96
|
+
return {}
|
|
97
|
+
try:
|
|
98
|
+
payload: Any = response.json()
|
|
99
|
+
except ValueError as exc:
|
|
100
|
+
raise BadResponseError("The server returned a non-JSON response.") from exc
|
|
101
|
+
if not isinstance(payload, dict):
|
|
102
|
+
raise BadResponseError("The server returned an unexpected response shape.")
|
|
103
|
+
return payload
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def send(client: httpx.Client, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
107
|
+
"""Issue `method path` and translate transport failures."""
|
|
108
|
+
try:
|
|
109
|
+
return client.request(method, path, **kwargs)
|
|
110
|
+
except httpx.HTTPError as exc:
|
|
111
|
+
raise map_transport_error(exc) from exc
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Repos helpers (shared by submodules)
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def summary_from(payload: dict[str, Any]) -> RepoSummary:
|
|
120
|
+
"""Parse a `/api/repos` row into a `RepoSummary`."""
|
|
121
|
+
owner = payload.get("owner", "")
|
|
122
|
+
description = payload.get("description")
|
|
123
|
+
return RepoSummary(
|
|
124
|
+
name=str(payload.get("name", "")),
|
|
125
|
+
kind=str(payload.get("kind", "")),
|
|
126
|
+
visibility=str(payload.get("visibility", "")),
|
|
127
|
+
description=None if description is None else str(description),
|
|
128
|
+
size_bytes=int(payload.get("size_bytes", 0)),
|
|
129
|
+
owner=str(owner),
|
|
130
|
+
clone_url=str(payload.get("clone_url", "")),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Facade — issue calls against a fresh `httpx.Client`
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def with_client(
|
|
140
|
+
base_url: str,
|
|
141
|
+
token: str,
|
|
142
|
+
*,
|
|
143
|
+
transport: httpx.BaseTransport | None = None,
|
|
144
|
+
) -> httpx.Client:
|
|
145
|
+
"""Construct a short-lived client for the caller to use as a context.
|
|
146
|
+
|
|
147
|
+
`transport=` is forwarded so tests can inject an `httpx.MockTransport`
|
|
148
|
+
(or a `respx` router) without monkeypatching — same convention as
|
|
149
|
+
the server's admin client.
|
|
150
|
+
"""
|
|
151
|
+
kwargs: dict[str, Any] = {}
|
|
152
|
+
if transport is not None:
|
|
153
|
+
kwargs["transport"] = transport
|
|
154
|
+
return build_client(base_url, token, **kwargs)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# Display-name helper used by `upload`
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def display_filename(path_str: str, *, file_root: Path | None, idx: int) -> str:
|
|
163
|
+
"""Compute the filename sent in the multipart part.
|
|
164
|
+
|
|
165
|
+
For file uploads (no `file_root`), the basename is sent verbatim.
|
|
166
|
+
For folder uploads, the relative path under `file_root` is sent so
|
|
167
|
+
the server stores `dir/file.txt` rather than `file.txt`.
|
|
168
|
+
"""
|
|
169
|
+
name = Path(path_str).name
|
|
170
|
+
if file_root is None:
|
|
171
|
+
return name
|
|
172
|
+
try:
|
|
173
|
+
rel = Path(path_str).resolve().relative_to(file_root.resolve())
|
|
174
|
+
except ValueError:
|
|
175
|
+
# Fallback to basename if the file isn't actually under `file_root`.
|
|
176
|
+
# The server will still store it under `path_in_repo`, so the
|
|
177
|
+
# upload cannot silently land in the wrong place — it just
|
|
178
|
+
# degrades to a flat listing.
|
|
179
|
+
return name
|
|
180
|
+
rel_str = rel.as_posix()
|
|
181
|
+
return rel_str or name or f"file-{idx}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# Submodule re-exports
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# Imported last so the dataclasses defined above are visible to the
|
|
188
|
+
# submodules' `from outo_models_cli.api import ...` statements. Call
|
|
189
|
+
# sites can write `api.me(...)` instead of `api.auth.me(...)`.
|
|
190
|
+
|
|
191
|
+
from outo_models_cli.api.auth import me # noqa: E402
|
|
192
|
+
from outo_models_cli.api.repos import ( # noqa: E402
|
|
193
|
+
create_repo,
|
|
194
|
+
delete_repo,
|
|
195
|
+
get_repo,
|
|
196
|
+
list_files,
|
|
197
|
+
list_repos,
|
|
198
|
+
resolve_url,
|
|
199
|
+
walk_repo,
|
|
200
|
+
)
|
|
201
|
+
from outo_models_cli.api.upload import upload # noqa: E402
|
|
202
|
+
|
|
203
|
+
__all__ = [
|
|
204
|
+
"FileEntry",
|
|
205
|
+
"RepoDetail",
|
|
206
|
+
"RepoSummary",
|
|
207
|
+
"UploadResult",
|
|
208
|
+
"WhoAmI",
|
|
209
|
+
"create_repo",
|
|
210
|
+
"delete_repo",
|
|
211
|
+
"display_filename",
|
|
212
|
+
"get_repo",
|
|
213
|
+
"list_files",
|
|
214
|
+
"list_repos",
|
|
215
|
+
"me",
|
|
216
|
+
"resolve_url",
|
|
217
|
+
"send",
|
|
218
|
+
"summary_from",
|
|
219
|
+
"unwrap",
|
|
220
|
+
"upload",
|
|
221
|
+
"walk_repo",
|
|
222
|
+
"with_client",
|
|
223
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""`GET /api/auth/me` — verify the bearer token."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from outo_models_cli.api import WhoAmI, send, unwrap
|
|
8
|
+
from outo_models_cli.errors import BadResponseError, map_response_error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def me(client: httpx.Client) -> WhoAmI:
|
|
12
|
+
"""Return the authenticated user's identity.
|
|
13
|
+
|
|
14
|
+
Raises `AuthInvalidError` if the token is rejected (HTTP 401).
|
|
15
|
+
"""
|
|
16
|
+
response = send(client, "GET", "/api/auth/me")
|
|
17
|
+
if response.status_code >= 400:
|
|
18
|
+
raise map_response_error(response)
|
|
19
|
+
payload = unwrap(response)
|
|
20
|
+
username = payload.get("username")
|
|
21
|
+
role = payload.get("role")
|
|
22
|
+
if not isinstance(username, str) or not isinstance(role, str):
|
|
23
|
+
raise BadResponseError("`/api/auth/me` returned an unexpected payload shape.")
|
|
24
|
+
return WhoAmI(username=username, role=role, server=str(client.base_url))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["me"]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Repo CRUD + tree listing over the public REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from outo_models_cli.api import (
|
|
10
|
+
FileEntry,
|
|
11
|
+
RepoDetail,
|
|
12
|
+
RepoSummary,
|
|
13
|
+
send,
|
|
14
|
+
summary_from,
|
|
15
|
+
unwrap,
|
|
16
|
+
)
|
|
17
|
+
from outo_models_cli.errors import BadResponseError, map_response_error
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_repo(
|
|
21
|
+
client: httpx.Client,
|
|
22
|
+
*,
|
|
23
|
+
name: str,
|
|
24
|
+
kind: str,
|
|
25
|
+
visibility: str,
|
|
26
|
+
description: str | None,
|
|
27
|
+
) -> RepoSummary:
|
|
28
|
+
"""`POST /api/repos` — returns the new repo summary."""
|
|
29
|
+
body: dict[str, Any] = {
|
|
30
|
+
"name": name,
|
|
31
|
+
"kind": kind,
|
|
32
|
+
"visibility": visibility,
|
|
33
|
+
"description": description,
|
|
34
|
+
}
|
|
35
|
+
response = send(client, "POST", "/api/repos", json=body)
|
|
36
|
+
if response.status_code >= 400:
|
|
37
|
+
raise map_response_error(response)
|
|
38
|
+
return summary_from(unwrap(response))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def delete_repo(client: httpx.Client, *, owner: str, name: str, kind: str) -> None:
|
|
42
|
+
"""`DELETE /api/repos/{owner}/{name}?kind=` — no body."""
|
|
43
|
+
response = send(client, "DELETE", f"/api/repos/{owner}/{name}", params={"kind": kind})
|
|
44
|
+
if response.status_code >= 400:
|
|
45
|
+
raise map_response_error(response)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def list_repos(
|
|
49
|
+
client: httpx.Client,
|
|
50
|
+
*,
|
|
51
|
+
kind: str | None = None,
|
|
52
|
+
owner: str | None = None,
|
|
53
|
+
) -> list[RepoSummary]:
|
|
54
|
+
"""`GET /api/repos` — paginated server-side; client returns the full list."""
|
|
55
|
+
params: dict[str, str] = {}
|
|
56
|
+
if kind:
|
|
57
|
+
params["kind"] = kind
|
|
58
|
+
if owner:
|
|
59
|
+
params["owner"] = owner
|
|
60
|
+
response = send(client, "GET", "/api/repos", params=params)
|
|
61
|
+
if response.status_code >= 400:
|
|
62
|
+
raise map_response_error(response)
|
|
63
|
+
try:
|
|
64
|
+
rows: Any = response.json()
|
|
65
|
+
except ValueError as exc:
|
|
66
|
+
raise BadResponseError("`/api/repos` returned a non-JSON response.") from exc
|
|
67
|
+
if not isinstance(rows, list):
|
|
68
|
+
raise BadResponseError("`/api/repos` returned a non-list response.")
|
|
69
|
+
return [summary_from(item) for item in rows if isinstance(item, dict)]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_repo(client: httpx.Client, *, owner: str, name: str) -> RepoDetail:
|
|
73
|
+
"""`GET /api/repos/{owner}/{name}` — full detail payload."""
|
|
74
|
+
response = send(client, "GET", f"/api/repos/{owner}/{name}")
|
|
75
|
+
if response.status_code >= 400:
|
|
76
|
+
raise map_response_error(response)
|
|
77
|
+
payload = unwrap(response)
|
|
78
|
+
summary = summary_from(payload)
|
|
79
|
+
return RepoDetail(
|
|
80
|
+
name=summary.name,
|
|
81
|
+
kind=summary.kind,
|
|
82
|
+
visibility=summary.visibility,
|
|
83
|
+
description=summary.description,
|
|
84
|
+
size_bytes=summary.size_bytes,
|
|
85
|
+
owner=summary.owner,
|
|
86
|
+
clone_url=summary.clone_url,
|
|
87
|
+
downloads_count=int(payload.get("downloads_count", 0)),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def list_files(
|
|
92
|
+
client: httpx.Client,
|
|
93
|
+
*,
|
|
94
|
+
owner: str,
|
|
95
|
+
name: str,
|
|
96
|
+
path: str = "",
|
|
97
|
+
revision: str | None = None,
|
|
98
|
+
) -> list[FileEntry]:
|
|
99
|
+
"""`GET /api/repos/{owner}/{name}/files?path=&revision=`.
|
|
100
|
+
|
|
101
|
+
The server may ignore the `revision` parameter (the current
|
|
102
|
+
implementation only lists the default branch tip). When it does
|
|
103
|
+
the response is still well-formed; this client simply forwards the
|
|
104
|
+
flag verbatim so a future revision-aware server picks it up without
|
|
105
|
+
a CLI release.
|
|
106
|
+
"""
|
|
107
|
+
params: dict[str, str] = {}
|
|
108
|
+
if path:
|
|
109
|
+
params["path"] = path
|
|
110
|
+
if revision:
|
|
111
|
+
params["revision"] = revision
|
|
112
|
+
response = send(
|
|
113
|
+
client,
|
|
114
|
+
"GET",
|
|
115
|
+
f"/api/repos/{owner}/{name}/files",
|
|
116
|
+
params=params,
|
|
117
|
+
)
|
|
118
|
+
if response.status_code >= 400:
|
|
119
|
+
raise map_response_error(response)
|
|
120
|
+
payload = unwrap(response)
|
|
121
|
+
rows = payload.get("entries", [])
|
|
122
|
+
if not isinstance(rows, list):
|
|
123
|
+
raise BadResponseError("`/files` returned a malformed `entries` field.")
|
|
124
|
+
entries: list[FileEntry] = []
|
|
125
|
+
for row in rows:
|
|
126
|
+
if not isinstance(row, dict):
|
|
127
|
+
continue
|
|
128
|
+
size = row.get("size_bytes")
|
|
129
|
+
entries.append(
|
|
130
|
+
FileEntry(
|
|
131
|
+
name=str(row.get("name", "")),
|
|
132
|
+
path=str(row.get("path", "")),
|
|
133
|
+
kind=str(row.get("kind", "")),
|
|
134
|
+
size_bytes=int(size) if isinstance(size, (int, float)) else None,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return entries
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def walk_repo(
|
|
141
|
+
client: httpx.Client,
|
|
142
|
+
*,
|
|
143
|
+
owner: str,
|
|
144
|
+
name: str,
|
|
145
|
+
revision: str | None = None,
|
|
146
|
+
include: list[str] | None = None,
|
|
147
|
+
exclude: list[str] | None = None,
|
|
148
|
+
) -> list[FileEntry]:
|
|
149
|
+
"""Recursively enumerate every file under a repo."""
|
|
150
|
+
from outo_models_cli.matchers import passes
|
|
151
|
+
|
|
152
|
+
out: list[FileEntry] = []
|
|
153
|
+
|
|
154
|
+
def visit(directory: str) -> None:
|
|
155
|
+
rows = list_files(client, owner=owner, name=name, path=directory, revision=revision)
|
|
156
|
+
for entry in rows:
|
|
157
|
+
if entry.kind == "dir":
|
|
158
|
+
visit(entry.path)
|
|
159
|
+
elif entry.kind == "file" and passes(entry.path, include=include, exclude=exclude):
|
|
160
|
+
out.append(entry)
|
|
161
|
+
|
|
162
|
+
visit("")
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def resolve_url(*, owner: str, name: str, revision: str, path: str) -> str:
|
|
167
|
+
"""Build the path-component-only URL for a raw file resolution.
|
|
168
|
+
|
|
169
|
+
Returned as a string (no client needed) so the download command can
|
|
170
|
+
hand it to its own `httpx.AsyncClient` and stream the body directly.
|
|
171
|
+
"""
|
|
172
|
+
from urllib.parse import quote
|
|
173
|
+
|
|
174
|
+
encoded_owner = quote(owner, safe="")
|
|
175
|
+
encoded_name = quote(name, safe="")
|
|
176
|
+
encoded_revision = quote(revision, safe="")
|
|
177
|
+
encoded_path = quote(path, safe="/")
|
|
178
|
+
return f"/{encoded_owner}/{encoded_name}/resolve/{encoded_revision}/{encoded_path}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
__all__ = [
|
|
182
|
+
"create_repo",
|
|
183
|
+
"delete_repo",
|
|
184
|
+
"get_repo",
|
|
185
|
+
"list_files",
|
|
186
|
+
"list_repos",
|
|
187
|
+
"resolve_url",
|
|
188
|
+
"walk_repo",
|
|
189
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Multipart upload via `POST /api/repos/{owner}/{name}/upload`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import BinaryIO
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from outo_models_cli.api import (
|
|
12
|
+
UploadResult,
|
|
13
|
+
display_filename,
|
|
14
|
+
send,
|
|
15
|
+
unwrap,
|
|
16
|
+
)
|
|
17
|
+
from outo_models_cli.errors import BadResponseError, map_response_error
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def upload(
|
|
21
|
+
client: httpx.Client,
|
|
22
|
+
*,
|
|
23
|
+
owner: str,
|
|
24
|
+
name: str,
|
|
25
|
+
files: Iterable[Path],
|
|
26
|
+
path_in_repo: str = "",
|
|
27
|
+
message: str | None = None,
|
|
28
|
+
file_root: Path | None = None,
|
|
29
|
+
file_handles: Iterable[BinaryIO] | None = None,
|
|
30
|
+
) -> UploadResult:
|
|
31
|
+
"""Multipart upload of one or many files.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
owner / name: Target repo coordinates.
|
|
35
|
+
files: Paths to local files. The filename of each multipart part
|
|
36
|
+
is the *basename* of the path, with the in-repo `path` prefix
|
|
37
|
+
applied by the server using the multipart `path` field.
|
|
38
|
+
path_in_repo: Subdirectory inside the repo where files land
|
|
39
|
+
(server applies it to every filename).
|
|
40
|
+
message: Optional commit message.
|
|
41
|
+
file_root: When set, the in-repo subpath is computed relative to
|
|
42
|
+
this root (used for folder uploads where the user wants
|
|
43
|
+
`dir/file.txt` rather than `file.txt` inside the destination).
|
|
44
|
+
file_handles: Optional iterable of pre-opened binary handles; the
|
|
45
|
+
caller is responsible for closing them. Pass `None` to have
|
|
46
|
+
this function open the paths itself.
|
|
47
|
+
|
|
48
|
+
The caller must pre-validate that no single file exceeds
|
|
49
|
+
100 MiB; this function trusts the caller and does not re-check
|
|
50
|
+
(re-checking after opening the file would mean reading the file's
|
|
51
|
+
bytes into Python just to count them).
|
|
52
|
+
"""
|
|
53
|
+
opens_handles = file_handles is None
|
|
54
|
+
opened: list[BinaryIO] = []
|
|
55
|
+
try:
|
|
56
|
+
if opens_handles:
|
|
57
|
+
opened = [f.open("rb") for f in files]
|
|
58
|
+
handles = opened
|
|
59
|
+
elif file_handles is not None:
|
|
60
|
+
handles = list(file_handles)
|
|
61
|
+
else:
|
|
62
|
+
handles = []
|
|
63
|
+
|
|
64
|
+
multipart_files: list[tuple[str, tuple[str, BinaryIO, str]]] = []
|
|
65
|
+
for idx, handle in enumerate(handles):
|
|
66
|
+
filename = handle.name
|
|
67
|
+
display_name = display_filename(filename, file_root=file_root, idx=idx)
|
|
68
|
+
multipart_files.append(
|
|
69
|
+
("files", (display_name, handle, "application/octet-stream")),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# httpx treats `data=[(name, value)]` as a raw (name, value) iterator,
|
|
73
|
+
# NOT as multipart form fields. Build a dict so the keys become
|
|
74
|
+
# `Content-Disposition: form-data; name=...` parts of the envelope.
|
|
75
|
+
fields: dict[str, str] = {}
|
|
76
|
+
if path_in_repo:
|
|
77
|
+
fields["path"] = path_in_repo
|
|
78
|
+
if message:
|
|
79
|
+
fields["message"] = message
|
|
80
|
+
|
|
81
|
+
response = send(
|
|
82
|
+
client,
|
|
83
|
+
"POST",
|
|
84
|
+
f"/api/repos/{owner}/{name}/upload",
|
|
85
|
+
files=multipart_files,
|
|
86
|
+
data=fields,
|
|
87
|
+
)
|
|
88
|
+
if response.status_code >= 400:
|
|
89
|
+
raise map_response_error(response)
|
|
90
|
+
payload = unwrap(response)
|
|
91
|
+
commit_sha = payload.get("commit_sha")
|
|
92
|
+
files_acked = payload.get("files", [])
|
|
93
|
+
if not isinstance(commit_sha, str) or not commit_sha:
|
|
94
|
+
raise BadResponseError("`/upload` response is missing `commit_sha`.")
|
|
95
|
+
if not isinstance(files_acked, list):
|
|
96
|
+
files_acked = []
|
|
97
|
+
return UploadResult(
|
|
98
|
+
commit_sha=commit_sha,
|
|
99
|
+
files=[str(name) for name in files_acked],
|
|
100
|
+
message=(str(payload["message"]) if isinstance(payload.get("message"), str) else None),
|
|
101
|
+
)
|
|
102
|
+
finally:
|
|
103
|
+
for handle in opened:
|
|
104
|
+
handle.close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["upload"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""`omc auth ...` — manage stored credentials."""
|