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