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,169 @@
1
+ """`omc repo ...` — manage repositories on the configured server."""
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
+ _resolve_target,
15
+ _validate_kind,
16
+ _visibility,
17
+ console,
18
+ )
19
+ from outo_models_cli.errors import (
20
+ AuthRequiredError,
21
+ OmcError,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from outo_models_cli.config import Store
26
+
27
+ repo_app = typer.Typer(help="Manage repositories.", no_args_is_help=True)
28
+
29
+
30
+ @repo_app.command("create")
31
+ def repo_create(
32
+ ctx: typer.Context,
33
+ name: Annotated[str, typer.Argument(help="Repository name.")],
34
+ kind: Annotated[str, typer.Option(help="One of model, dataset, space.")] = "model",
35
+ public: Annotated[
36
+ bool,
37
+ typer.Option("--public", help="Make the repo public (overrides --private)."),
38
+ ] = False,
39
+ private: Annotated[
40
+ bool,
41
+ typer.Option("--private", help="Make the repo private (default)."),
42
+ ] = False,
43
+ description: Annotated[
44
+ str | None,
45
+ typer.Option(help="One-line description stored on the repo."),
46
+ ] = None,
47
+ server: Annotated[str | None, typer.Option(help="Target server URL.")] = None,
48
+ ) -> None:
49
+ """Create a new repository on the target server."""
50
+ store: Store = ctx.obj["store"]
51
+ try:
52
+ kind = _validate_kind(kind)
53
+ visibility = _visibility(public, private)
54
+ _base_url, client = _resolve_target(store, server=server)
55
+ except (OmcError, AuthRequiredError) as exc:
56
+ _errprint(str(exc))
57
+ raise typer.Exit(code=1) from exc
58
+
59
+ try:
60
+ with client:
61
+ summary = api.create_repo(
62
+ client,
63
+ name=name,
64
+ kind=kind,
65
+ visibility=visibility,
66
+ description=description,
67
+ )
68
+ except OmcError as exc:
69
+ _errprint(str(exc))
70
+ raise typer.Exit(code=1) from exc
71
+
72
+ console.print(
73
+ f"Created {summary.visibility} {summary.kind} [bold]{summary.owner}/{summary.name}[/bold].",
74
+ )
75
+ console.print(f"Clone URL: {summary.clone_url}")
76
+
77
+
78
+ @repo_app.command("delete")
79
+ def repo_delete(
80
+ ctx: typer.Context,
81
+ repo: Annotated[str, typer.Argument(help="Repository as `<owner>/<name>`.")],
82
+ kind: Annotated[
83
+ str,
84
+ typer.Option(help="Repo kind: model, dataset, or space."),
85
+ ] = "model",
86
+ yes: Annotated[
87
+ bool,
88
+ typer.Option("--yes", "-y", help="Skip the confirmation prompt."),
89
+ ] = False,
90
+ server: Annotated[str | None, typer.Option(help="Target server URL.")] = None,
91
+ ) -> None:
92
+ """Delete a repository (owner or admin only)."""
93
+ store: Store = ctx.obj["store"]
94
+ if "/" not in repo:
95
+ _errprint("Argument must be `<owner>/<name>`.")
96
+ raise typer.Exit(code=1)
97
+ owner, name = repo.split("/", 1)
98
+ if not yes:
99
+ typer.confirm(
100
+ f"Delete {kind} {owner}/{name}? This cannot be undone.",
101
+ abort=True,
102
+ )
103
+ try:
104
+ kind = _validate_kind(kind)
105
+ _base_url, client = _resolve_target(store, server=server)
106
+ except (OmcError, AuthRequiredError) as exc:
107
+ _errprint(str(exc))
108
+ raise typer.Exit(code=1) from exc
109
+
110
+ try:
111
+ with client:
112
+ api.delete_repo(client, owner=owner, name=name, kind=kind)
113
+ except OmcError as exc:
114
+ _errprint(str(exc))
115
+ raise typer.Exit(code=1) from exc
116
+
117
+ console.print(f"Deleted {kind} {owner}/{name}.")
118
+
119
+
120
+ @repo_app.command("list")
121
+ def repo_list(
122
+ ctx: typer.Context,
123
+ owner: Annotated[str | None, typer.Option(help="Limit to one owner's repos.")] = None,
124
+ kind: Annotated[
125
+ str | None,
126
+ typer.Option(help="Filter by kind: model, dataset, or space."),
127
+ ] = None,
128
+ server: Annotated[str | None, typer.Option(help="Target server URL.")] = None,
129
+ ) -> None:
130
+ """List repositories on the target server."""
131
+ store: Store = ctx.obj["store"]
132
+ try:
133
+ kind = _validate_kind(kind) if kind else None
134
+ _base_url, client = _resolve_target(store, server=server)
135
+ except (OmcError, AuthRequiredError) as exc:
136
+ _errprint(str(exc))
137
+ raise typer.Exit(code=1) from exc
138
+
139
+ try:
140
+ with client:
141
+ rows = api.list_repos(client, kind=kind, owner=owner)
142
+ except OmcError as exc:
143
+ _errprint(str(exc))
144
+ raise typer.Exit(code=1) from exc
145
+
146
+ if not rows:
147
+ console.print("No repositories found.")
148
+ return
149
+ table = Table(show_header=True, header_style="bold")
150
+ table.add_column("Owner")
151
+ table.add_column("Name")
152
+ table.add_column("Kind")
153
+ table.add_column("Visibility")
154
+ table.add_column("Size")
155
+ table.add_column("Description")
156
+ for row in rows:
157
+ size = _human_bytes(row.size_bytes) if row.size_bytes else "0 B"
158
+ table.add_row(
159
+ row.owner,
160
+ row.name,
161
+ row.kind,
162
+ row.visibility,
163
+ size,
164
+ row.description or "",
165
+ )
166
+ console.print(table)
167
+
168
+
169
+ __all__ = ["repo_app"]
@@ -0,0 +1,118 @@
1
+ """`omc upload <owner>/<name> <local-path>` — file or folder upload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Annotated
7
+
8
+ import typer
9
+
10
+ from outo_models_cli import api
11
+ from outo_models_cli.commands._shared import (
12
+ _SINGLE_REQUEST_TOTAL_BYTES,
13
+ _check_size_limit,
14
+ _collect_files,
15
+ _errprint,
16
+ _parse_owner_repo,
17
+ _resolve_target,
18
+ console,
19
+ )
20
+ from outo_models_cli.errors import (
21
+ AuthRequiredError,
22
+ OmcError,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from outo_models_cli.config import Store
27
+
28
+
29
+ def _path_in_repo_for(file: Path, *, root: Path, default: str) -> str:
30
+ """Compute the destination subdirectory for one file in a folder upload.
31
+
32
+ The server applies `path` to every filename in the multipart batch,
33
+ so when the user uploads a folder of files we want the *common
34
+ parent directory* (not the file's full relative path) to land under
35
+ `path_in_repo`. Files at the root go straight under the user's
36
+ `path_in_repo` verbatim.
37
+ """
38
+ try:
39
+ rel_parent = file.resolve().relative_to(root.resolve()).parent
40
+ except ValueError:
41
+ return default
42
+ if rel_parent == Path("."):
43
+ return default
44
+ base = default.rstrip("/")
45
+ return f"{base}/{rel_parent.as_posix()}" if base else rel_parent.as_posix()
46
+
47
+
48
+ def upload_command(
49
+ ctx: typer.Context,
50
+ repo: Annotated[str, typer.Argument(help="Repository as `<owner>/<name>`.")],
51
+ local_path: Annotated[
52
+ Path,
53
+ typer.Argument(help="Local file or directory to upload."),
54
+ ],
55
+ path_in_repo: Annotated[
56
+ str,
57
+ typer.Option(help="Subdirectory inside the repo (default: root)."),
58
+ ] = "",
59
+ message: Annotated[
60
+ str | None,
61
+ typer.Option(help="Commit message."),
62
+ ] = None,
63
+ server: Annotated[str | None, typer.Option(help="Target server URL.")] = None,
64
+ ) -> None:
65
+ """Upload a file or folder to a repository."""
66
+ store: Store = ctx.obj["store"]
67
+ try:
68
+ owner, name = _parse_owner_repo(repo)
69
+ files, file_root = _collect_files(local_path)
70
+ _check_size_limit(files)
71
+ _base_url, client = _resolve_target(store, server=server)
72
+ except (OmcError, AuthRequiredError) as exc:
73
+ _errprint(str(exc))
74
+ raise typer.Exit(code=1) from exc
75
+
76
+ try:
77
+ with client:
78
+ total = sum(f.stat().st_size for f in files)
79
+ if total < _SINGLE_REQUEST_TOTAL_BYTES:
80
+ result = api.upload(
81
+ client,
82
+ owner=owner,
83
+ name=name,
84
+ files=files,
85
+ path_in_repo=path_in_repo,
86
+ message=message,
87
+ file_root=file_root,
88
+ )
89
+ console.print(
90
+ f"Uploaded {len(result.files)} file(s) as commit {result.commit_sha}.",
91
+ )
92
+ return
93
+
94
+ # Large folders: upload file-by-file so a partial failure
95
+ # does not roll back the whole tree. Each batch is one
96
+ # commit; the server stores them sequentially.
97
+ last_sha = ""
98
+ for f in files:
99
+ sub = _path_in_repo_for(f, root=file_root, default=path_in_repo)
100
+ single = api.upload(
101
+ client,
102
+ owner=owner,
103
+ name=name,
104
+ files=[f],
105
+ path_in_repo=sub,
106
+ message=message,
107
+ file_root=file_root,
108
+ )
109
+ last_sha = single.commit_sha
110
+ console.print(
111
+ f"Uploaded {len(files)} file(s) in sequential commits; last commit {last_sha}.",
112
+ )
113
+ except OmcError as exc:
114
+ _errprint(str(exc))
115
+ raise typer.Exit(code=1) from exc
116
+
117
+
118
+ __all__ = ["upload_command"]
@@ -0,0 +1,308 @@
1
+ """Multi-server credential store at `~/.config/omc/config.json`.
2
+
3
+ The store is a single JSON file holding the PAT for every server the user
4
+ has authenticated against. The server URL (normalized — see `normalize_server_url`)
5
+ is the key, so a user can run `omc auth login --server https://a.example` and
6
+ later `omc auth login --server https://b.example` without overwriting either.
7
+
8
+ File mode is 0600 — only the owner may read or write. The store is created
9
+ with that mode on first write; an existing file with looser permissions is
10
+ tightened on every save so accidental `chmod 644` does not silently leak
11
+ the token.
12
+
13
+ Two environment variables override the file for CI / scripted use:
14
+
15
+ * `OMC_SERVER` — picks a server URL without touching the config.
16
+ * `OMC_TOKEN` — supplies the bearer credential directly.
17
+
18
+ Both are read-only overrides; `omc auth login` never persists to the file
19
+ when they are set, so a CI job can never accidentally clobber a personal
20
+ credential with a one-shot bot token.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ from dataclasses import dataclass, replace
28
+ from pathlib import Path
29
+ from typing import Any, Self
30
+
31
+ from outo_models_cli.errors import AuthRequiredError, ConfigError
32
+
33
+ # File mode for the config file and its parent directory.
34
+ _DIR_MODE = 0o700
35
+ _FILE_MODE = 0o600
36
+
37
+ # Environment variables consulted before the config file. The values are
38
+ # treated as ephemeral overrides: `auth login` does NOT persist them.
39
+ _ENV_SERVER = "OMC_SERVER"
40
+ _ENV_TOKEN = "OMC_TOKEN" # noqa: S105 — env var name, not a credential
41
+
42
+
43
+ def normalize_server_url(raw: str) -> str:
44
+ """Canonicalize a server URL for storage and HTTP use.
45
+
46
+ Rules:
47
+ * Strip whitespace and a single trailing slash.
48
+ * If the URL carries no scheme, prepend `http://`. Plain hostnames
49
+ and bare IPs are common in self-hosted setups; defaulting to
50
+ `http://` (instead of guessing `https`) keeps a local LAN server
51
+ working out of the box — `https://` would reject the request
52
+ outright because the bundled Caddy is the only TLS terminator.
53
+ * Lowercase the scheme + host portion so `HTTPS://Example.com` and
54
+ `https://example.com/` collapse onto the same key.
55
+
56
+ Raises `ConfigError` for empty input — silently storing `""` as a key
57
+ would mean every subsequent `omc auth whoami` looks up the wrong entry.
58
+ """
59
+ cleaned = raw.strip().rstrip("/")
60
+ if not cleaned:
61
+ raise ConfigError("Server URL must not be empty.")
62
+ if "://" not in cleaned:
63
+ cleaned = "http://" + cleaned
64
+ scheme, _, rest = cleaned.partition("://")
65
+ host_part, _, _ = rest.partition("/")
66
+ return f"{scheme.lower()}://{host_part.lower()}"
67
+
68
+
69
+ def config_dir(home_dir: str | os.PathLike[str] | None = None) -> Path:
70
+ """Return the directory that holds `config.json`.
71
+
72
+ Honors `OMC_CONFIG_DIR` (full override), `XDG_CONFIG_HOME` (with
73
+ `~/.config` as the fallback); the dev environment and CI tests can
74
+ point this at a temp dir by passing `home_dir` explicitly. Passing
75
+ `home_dir` takes priority over the env var so `pytest -q` can run in
76
+ parallel without leaking into the host config.
77
+ """
78
+ if home_dir is not None:
79
+ base = Path(home_dir).expanduser().resolve()
80
+ return base / ".config" / "omc"
81
+ override = os.environ.get("OMC_CONFIG_DIR")
82
+ if override:
83
+ return Path(override).expanduser().resolve()
84
+ xdg = os.environ.get("XDG_CONFIG_HOME")
85
+ if xdg:
86
+ return Path(xdg).expanduser().resolve() / "omc"
87
+ home = Path(os.environ.get("HOME", "~")).expanduser().resolve()
88
+ return home / ".config" / "omc"
89
+
90
+
91
+ def config_path(home_dir: str | os.PathLike[str] | None = None) -> Path:
92
+ """Return the full path to the config JSON file."""
93
+ return config_dir(home_dir) / "config.json"
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class ServerEntry:
98
+ """One server's stored credential."""
99
+
100
+ url: str
101
+ token: str
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class Store:
106
+ """In-memory snapshot of the credential store.
107
+
108
+ `default_server` is the URL the CLI uses when no `--server` flag is
109
+ passed. It must be a member of `servers`; the constructor enforces
110
+ this so a stale `default_server` cannot outlive its entry.
111
+
112
+ `servers` is keyed by the normalized server URL. Order is preserved
113
+ by the underlying JSON object; ``display_name`` is derived from the URL
114
+ at render time so the on-disk format stays small.
115
+ """
116
+
117
+ default_server: str | None
118
+ servers: dict[str, ServerEntry]
119
+
120
+ def __post_init__(self) -> None:
121
+ if self.default_server is not None and self.default_server not in self.servers:
122
+ raise ConfigError(
123
+ f"Default server {self.default_server!r} is not in the credential store.",
124
+ )
125
+ # Reject duplicate empty keys (defensive — the constructor is the
126
+ # only path that builds the dict, but a malformed on-disk file
127
+ # could in principle contain one).
128
+ for url in self.servers:
129
+ if not url:
130
+ raise ConfigError("Server URL must not be empty.")
131
+
132
+ # ------------------------------------------------------------------
133
+ # Construction / persistence
134
+ # ------------------------------------------------------------------
135
+
136
+ @classmethod
137
+ def empty(cls) -> Self:
138
+ """Return an in-memory store with no servers."""
139
+ return cls(default_server=None, servers={})
140
+
141
+ @classmethod
142
+ def load(cls, path: Path) -> Self:
143
+ """Load the store from `path`.
144
+
145
+ Missing file → `empty()`. Malformed JSON or missing required
146
+ fields raise `ConfigError` with a message that points at the
147
+ offending location so the user knows to delete the file.
148
+ """
149
+ if not path.exists():
150
+ return cls.empty()
151
+ try:
152
+ raw = json.loads(path.read_text(encoding="utf-8"))
153
+ except json.JSONDecodeError as exc:
154
+ raise ConfigError(
155
+ f"Config file is not valid JSON: {path} ({exc.msg} at line {exc.lineno}).",
156
+ ) from exc
157
+ if not isinstance(raw, dict):
158
+ raise ConfigError(f"Config file root must be an object: {path}.")
159
+ default_server = raw.get("default_server")
160
+ if default_server is not None and not isinstance(default_server, str):
161
+ raise ConfigError("`default_server` must be a string when present.")
162
+ servers_raw = raw.get("servers", {})
163
+ if not isinstance(servers_raw, dict):
164
+ raise ConfigError("`servers` must be an object.")
165
+ servers: dict[str, ServerEntry] = {}
166
+ for url, entry in servers_raw.items():
167
+ if not isinstance(url, str) or not url:
168
+ raise ConfigError("Every server key must be a non-empty string.")
169
+ if not isinstance(entry, dict):
170
+ raise ConfigError(f"Server entry for {url!r} must be an object.")
171
+ token = entry.get("token")
172
+ if not isinstance(token, str) or not token:
173
+ raise ConfigError(f"Server entry for {url!r} is missing a token.")
174
+ servers[url] = ServerEntry(url=url, token=token)
175
+ return cls(default_server=default_server, servers=servers)
176
+
177
+ def save(self, path: Path) -> None:
178
+ """Persist the store to `path`, creating parents with 0700 and file 0600.
179
+
180
+ If the file already exists with looser permissions, the mode is
181
+ tightened as part of the same atomic write. The write itself uses
182
+ `os.replace` against a sibling temp file so a Ctrl-C mid-write
183
+ cannot leave the user with a half-written config.
184
+ """
185
+ path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_MODE)
186
+ payload: dict[str, Any] = {
187
+ "default_server": self.default_server,
188
+ "servers": {url: {"token": entry.token} for url, entry in self.servers.items()},
189
+ }
190
+ tmp = path.with_suffix(path.suffix + ".tmp")
191
+ tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
192
+ os.chmod(tmp, _FILE_MODE)
193
+ os.replace(tmp, path)
194
+ os.chmod(path, _FILE_MODE)
195
+
196
+ # ------------------------------------------------------------------
197
+ # Mutations (return new `Store` so the dataclass stays frozen)
198
+ # ------------------------------------------------------------------
199
+
200
+ def with_login(self, url: str, token: str) -> Self:
201
+ """Add or replace `url → token`, leaving the default server alone."""
202
+ new_servers = dict(self.servers)
203
+ new_servers[url] = ServerEntry(url=url, token=token)
204
+ default = self.default_server if self.default_server in new_servers else None
205
+ if default is None and new_servers:
206
+ # First-ever login: pin the default to the new server so a
207
+ # `omc auth whoami` immediately afterwards works without
208
+ # `--server`. Re-login on an existing server keeps the prior
209
+ # default so `auth login --set-default` is still the only way
210
+ # to switch targets.
211
+ default = url
212
+ return replace(self, default_server=default, servers=new_servers)
213
+
214
+ def without(self, url: str) -> Self:
215
+ """Remove the entry for `url`. `default_server` is repointed if it
216
+ pointed at the removed server."""
217
+ if url not in self.servers:
218
+ return self
219
+ new_servers = {k: v for k, v in self.servers.items() if k != url}
220
+ default = self.default_server
221
+ if default == url:
222
+ default = next(iter(new_servers), None)
223
+ return replace(self, default_server=default, servers=new_servers)
224
+
225
+ def with_default(self, url: str) -> Self:
226
+ """Pin `url` as the default; raises if the server is unknown."""
227
+ if url not in self.servers:
228
+ raise ConfigError(
229
+ f"Unknown server: {url!r}. Run `omc auth login --server <url>` first.",
230
+ )
231
+ return replace(self, default_server=url)
232
+
233
+ # ------------------------------------------------------------------
234
+ # Lookup
235
+ # ------------------------------------------------------------------
236
+
237
+ def resolve(
238
+ self,
239
+ requested: str | None,
240
+ *,
241
+ env: dict[str, str] | None = None,
242
+ ) -> tuple[str, str]:
243
+ """Pick `(server_url, token)` honoring env overrides.
244
+
245
+ Resolution order:
246
+ 1. `requested` argument (from `--server`).
247
+ 2. `OMC_SERVER` env var.
248
+ 3. `default_server` in the config file.
249
+
250
+ The token is sourced from `OMC_TOKEN` first, then from the
251
+ resolved server's stored entry. Splitting URL and token sources
252
+ lets a CI job run `OMC_SERVER=… OMC_TOKEN=… omc ...` without ever
253
+ touching the on-disk store, and lets `--server` carry just the URL.
254
+ """
255
+ env_map = env if env is not None else os.environ
256
+ url = requested or env_map.get(_ENV_SERVER) or self.default_server
257
+ if not url:
258
+ raise AuthRequiredError(
259
+ "No server configured. Run `omc auth login --server <url>`.",
260
+ )
261
+ url = normalize_server_url(url)
262
+ token = env_map.get(_ENV_TOKEN)
263
+ if token is None:
264
+ entry = self.servers.get(url)
265
+ if entry is None:
266
+ raise AuthRequiredError(
267
+ f"No token stored for {url}. Run `omc auth login --server {url}`.",
268
+ )
269
+ token = entry.token
270
+ return url, token
271
+
272
+ # ------------------------------------------------------------------
273
+ # Introspection (used by `omc auth status`)
274
+ # ------------------------------------------------------------------
275
+
276
+ def list_servers(self) -> list[str]:
277
+ """Return the sorted list of configured server URLs."""
278
+ return sorted(self.servers.keys())
279
+
280
+ def get(self, url: str) -> ServerEntry | None:
281
+ """Return the stored entry for `url` (already-normalized) or `None`."""
282
+ return self.servers.get(url)
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Convenience facade: open + save the file in one call.
287
+ # ---------------------------------------------------------------------------
288
+
289
+
290
+ def load_store(path: Path | None = None) -> Store:
291
+ """Load the store from `path`, defaulting to the user's config dir."""
292
+ return Store.load(path if path is not None else config_path())
293
+
294
+
295
+ def save_store(store: Store, path: Path | None = None) -> None:
296
+ """Save `store` to `path`, defaulting to the user's config dir."""
297
+ store.save(path if path is not None else config_path())
298
+
299
+
300
+ __all__ = [
301
+ "ServerEntry",
302
+ "Store",
303
+ "config_dir",
304
+ "config_path",
305
+ "load_store",
306
+ "normalize_server_url",
307
+ "save_store",
308
+ ]