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,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,6 @@
1
+ """`python -m outo_models_cli` entry point."""
2
+
3
+ from outo_models_cli.main import cli_main
4
+
5
+ if __name__ == "__main__":
6
+ cli_main()
@@ -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."""