sediment-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.
- sediment_cli/__init__.py +22 -0
- sediment_cli/attribution.py +3881 -0
- sediment_cli/cli.py +1922 -0
- sediment_cli/client.py +323 -0
- sediment_cli/delivery.py +1334 -0
- sediment_cli/local_postgres.py +337 -0
- sediment_cli/transcript.py +1764 -0
- sediment_cli/ui.py +123 -0
- sediment_cli-0.1.0.dist-info/METADATA +16 -0
- sediment_cli-0.1.0.dist-info/RECORD +13 -0
- sediment_cli-0.1.0.dist-info/WHEEL +4 -0
- sediment_cli-0.1.0.dist-info/entry_points.txt +2 -0
- sediment_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
sediment_cli/client.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
HTTP client seam for the remote CLI verbs.
|
|
4
|
+
|
|
5
|
+
Client verbs speak HTTP through this module; only ``server`` and explicit
|
|
6
|
+
local mode open the fact store. Credentials resolve from environment
|
|
7
|
+
overrides (``SEDIMENT_URL``, ``SEDIMENT_SESSION_TOKEN``) else the current
|
|
8
|
+
entry in ``~/.sediment/config.json``. The two failure modes an operator
|
|
9
|
+
actually hits map to clean one-line errors: a rejected token (401) and an
|
|
10
|
+
unreachable server (connection refused) — never a traceback.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ipaddress
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
from urllib.parse import urlsplit
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from . import __version__, ui
|
|
26
|
+
|
|
27
|
+
CONFIG_PATH = Path.home() / ".sediment" / "config.json"
|
|
28
|
+
_TIMEOUT_SECONDS = 10.0
|
|
29
|
+
|
|
30
|
+
# Tests inject an in-process transport so login/facts/commit exercise the
|
|
31
|
+
# real app without opening a socket.
|
|
32
|
+
_transport: httpx.BaseTransport | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ClientError(Exception):
|
|
36
|
+
"""A remote verb failed for a reason the operator can act on."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _http() -> httpx.Client:
|
|
40
|
+
return httpx.Client(
|
|
41
|
+
transport=_transport,
|
|
42
|
+
timeout=_TIMEOUT_SECONDS,
|
|
43
|
+
follow_redirects=False,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def norm_url(url: str) -> str:
|
|
48
|
+
"""One spelling of a server URL: trailing slash stripped, so ``login``
|
|
49
|
+
and a later ``SEDIMENT_URL`` override resolve to the same config key."""
|
|
50
|
+
return url.strip().rstrip("/")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_URL_ERROR = (
|
|
54
|
+
"server URL must use HTTPS, or loopback HTTP with localhost, "
|
|
55
|
+
"127.0.0.0/8, or [::1]; omit credentials, query, fragment, and base path"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_loopback_host(host: str) -> bool:
|
|
60
|
+
"""Whether *host* is localhost or a literal loopback IP address."""
|
|
61
|
+
if host.lower() == "localhost":
|
|
62
|
+
return True
|
|
63
|
+
try:
|
|
64
|
+
address = ipaddress.ip_address(host)
|
|
65
|
+
except ValueError:
|
|
66
|
+
return False
|
|
67
|
+
return address.is_loopback and (
|
|
68
|
+
isinstance(address, ipaddress.IPv4Address)
|
|
69
|
+
or address == ipaddress.IPv6Address("::1")
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _valid_host(host: str) -> bool:
|
|
74
|
+
try:
|
|
75
|
+
ipaddress.ip_address(host)
|
|
76
|
+
except ValueError:
|
|
77
|
+
candidate = host[:-1] if host.endswith(".") else host
|
|
78
|
+
try:
|
|
79
|
+
candidate = candidate.encode("idna").decode("ascii")
|
|
80
|
+
except UnicodeError:
|
|
81
|
+
return False
|
|
82
|
+
if "." in candidate and all(
|
|
83
|
+
character.isdigit() or character == "." for character in candidate
|
|
84
|
+
):
|
|
85
|
+
return False
|
|
86
|
+
labels = candidate.split(".")
|
|
87
|
+
return (
|
|
88
|
+
bool(candidate)
|
|
89
|
+
and len(candidate) <= 253
|
|
90
|
+
and all(
|
|
91
|
+
label
|
|
92
|
+
and len(label) <= 63
|
|
93
|
+
and label[0].isalnum()
|
|
94
|
+
and label[-1].isalnum()
|
|
95
|
+
and all(character.isalnum() or character == "-" for character in label)
|
|
96
|
+
for label in labels
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def validate_server_url(url: str) -> str:
|
|
103
|
+
"""Validate and normalize a deployment base URL before token use."""
|
|
104
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in url):
|
|
105
|
+
raise ClientError(_URL_ERROR)
|
|
106
|
+
value = url.strip()
|
|
107
|
+
try:
|
|
108
|
+
parsed = urlsplit(value)
|
|
109
|
+
host = parsed.hostname
|
|
110
|
+
parsed.port
|
|
111
|
+
except ValueError as exc:
|
|
112
|
+
raise ClientError(_URL_ERROR) from exc
|
|
113
|
+
if (
|
|
114
|
+
parsed.scheme not in {"http", "https"}
|
|
115
|
+
or not host
|
|
116
|
+
or not _valid_host(host)
|
|
117
|
+
or parsed.username is not None
|
|
118
|
+
or parsed.password is not None
|
|
119
|
+
or parsed.path not in {"", "/"}
|
|
120
|
+
or "?" in value
|
|
121
|
+
or "#" in value
|
|
122
|
+
or parsed.netloc.endswith(":")
|
|
123
|
+
or (parsed.scheme == "http" and not is_loopback_host(host))
|
|
124
|
+
):
|
|
125
|
+
raise ClientError(_URL_ERROR)
|
|
126
|
+
return value[:-1] if parsed.path == "/" else value
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_config() -> dict[str, Any]:
|
|
130
|
+
"""The config file as a dict; ``{}`` when absent or unparseable. Unknown
|
|
131
|
+
keys are preserved by callers — the attribution stamper keeps its own
|
|
132
|
+
``auto_install_remotes`` key in the same file."""
|
|
133
|
+
try:
|
|
134
|
+
raw = CONFIG_PATH.read_text(encoding="utf-8")
|
|
135
|
+
except OSError:
|
|
136
|
+
return {}
|
|
137
|
+
try:
|
|
138
|
+
cfg = json.loads(raw)
|
|
139
|
+
except ValueError:
|
|
140
|
+
return {}
|
|
141
|
+
return cfg if isinstance(cfg, dict) else {}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def write_config(cfg: dict[str, Any]) -> None:
|
|
145
|
+
"""Write the config at 0600 — it holds a bearer token. Created with
|
|
146
|
+
``O_CREAT`` + mode so it never exists for a moment at the default umask
|
|
147
|
+
(0644)."""
|
|
148
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
data = json.dumps(cfg, indent=2) + "\n"
|
|
150
|
+
fd = os.open(CONFIG_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
151
|
+
# O_CREAT's mode applies only at creation: a pre-existing file (the
|
|
152
|
+
# stamper's hand-authored auto_install_remotes config) keeps its old
|
|
153
|
+
# mode, so re-assert 0600 now that a token is landing in it.
|
|
154
|
+
os.fchmod(fd, 0o600)
|
|
155
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
156
|
+
fh.write(data)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _resolve() -> tuple[str, str]:
|
|
160
|
+
"""(base_url, token) from env-else-config.
|
|
161
|
+
|
|
162
|
+
``SEDIMENT_URL`` overrides the current server; ``SEDIMENT_SESSION_TOKEN``
|
|
163
|
+
overrides its stored token. The config entry for the resolved URL holds
|
|
164
|
+
the token when neither env var does."""
|
|
165
|
+
base_url = os.environ.get("SEDIMENT_URL")
|
|
166
|
+
token = os.environ.get("SEDIMENT_SESSION_TOKEN")
|
|
167
|
+
if base_url is None or token is None:
|
|
168
|
+
cfg = read_config()
|
|
169
|
+
if base_url is None:
|
|
170
|
+
current = cfg.get("current")
|
|
171
|
+
base_url = current if isinstance(current, str) and current else None
|
|
172
|
+
if token is None and base_url:
|
|
173
|
+
base_url = validate_server_url(base_url)
|
|
174
|
+
entry = (cfg.get("servers") or {}).get(base_url)
|
|
175
|
+
token = entry.get("token") if isinstance(entry, dict) else None
|
|
176
|
+
if not base_url or not token:
|
|
177
|
+
raise ClientError("not logged in — run sediment login")
|
|
178
|
+
return validate_server_url(base_url), token
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _url(base_url: str, path: str) -> str:
|
|
182
|
+
return f"{base_url}/{path.lstrip('/')}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _get(base_url: str, token: str, path: str) -> httpx.Response:
|
|
186
|
+
base_url = validate_server_url(base_url)
|
|
187
|
+
try:
|
|
188
|
+
return _http().get(
|
|
189
|
+
_url(base_url, path),
|
|
190
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
191
|
+
)
|
|
192
|
+
except httpx.HTTPError as exc:
|
|
193
|
+
raise ClientError(
|
|
194
|
+
"is the server running? sediment server or docker compose up"
|
|
195
|
+
) from exc
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _post(base_url: str, token: str, path: str, body: Any) -> httpx.Response:
|
|
199
|
+
base_url = validate_server_url(base_url)
|
|
200
|
+
try:
|
|
201
|
+
return _http().post(
|
|
202
|
+
_url(base_url, path),
|
|
203
|
+
json=body,
|
|
204
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
205
|
+
)
|
|
206
|
+
except httpx.HTTPError as exc:
|
|
207
|
+
raise ClientError(
|
|
208
|
+
"is the server running? sediment server or docker compose up"
|
|
209
|
+
) from exc
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _error_detail(resp: httpx.Response) -> str:
|
|
213
|
+
# A non-2xx body is whatever the far end sent: valid JSON that is a list
|
|
214
|
+
# or a scalar has no ``.get``, and the module promises never a traceback.
|
|
215
|
+
try:
|
|
216
|
+
body = resp.json()
|
|
217
|
+
except ValueError:
|
|
218
|
+
body = None
|
|
219
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
220
|
+
if isinstance(detail, dict):
|
|
221
|
+
reason = detail.get("reason")
|
|
222
|
+
messages = {
|
|
223
|
+
"repository_selector_ambiguous": "repository_selector_ambiguous: provide repository provider, host, and ID",
|
|
224
|
+
"repository_evidence_limit": "repository_evidence_limit: complete repository evidence exceeds the read limit",
|
|
225
|
+
}
|
|
226
|
+
if isinstance(reason, str) and reason in messages:
|
|
227
|
+
return messages[reason]
|
|
228
|
+
return detail if isinstance(detail, str) else f"server error ({resp.status_code})"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def probe_me(base_url: str, token: str) -> dict[str, Any]:
|
|
232
|
+
"""GET /v1/me with explicit credentials — the ``sediment login``
|
|
233
|
+
validation call. A 401 here is a wrong token ("that's not a valid
|
|
234
|
+
token"), distinct from the verb path's rejected-token message."""
|
|
235
|
+
resp = _get(base_url, token, "/v1/me")
|
|
236
|
+
if resp.status_code == 401:
|
|
237
|
+
raise ClientError("that's not a valid token")
|
|
238
|
+
if resp.status_code >= 300:
|
|
239
|
+
raise ClientError(_error_detail(resp))
|
|
240
|
+
try:
|
|
241
|
+
identity = resp.json()
|
|
242
|
+
if (
|
|
243
|
+
not isinstance(identity, dict)
|
|
244
|
+
or identity.get("authority") not in {"operator", "ingest"}
|
|
245
|
+
or not isinstance(identity.get("client_id"), str)
|
|
246
|
+
or not identity["client_id"]
|
|
247
|
+
or not isinstance(identity.get("org_id"), str)
|
|
248
|
+
or not identity["org_id"]
|
|
249
|
+
):
|
|
250
|
+
raise ValueError
|
|
251
|
+
except (ValueError, TypeError):
|
|
252
|
+
raise ClientError(
|
|
253
|
+
"server did not confirm credential authority; upgrade the API before login"
|
|
254
|
+
) from None
|
|
255
|
+
return identity
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_json(path: str) -> Any:
|
|
259
|
+
"""GET ``path`` with resolved credentials; returns the parsed JSON body.
|
|
260
|
+
Raises ``ClientError`` with the actionable message on the two operator
|
|
261
|
+
failure modes (401, connection refused) and any other non-2xx."""
|
|
262
|
+
base_url, token = _resolve()
|
|
263
|
+
resp = _get(base_url, token, path)
|
|
264
|
+
if resp.status_code == 401:
|
|
265
|
+
raise ClientError("not logged in / token rejected — run sediment login")
|
|
266
|
+
if resp.status_code == 403:
|
|
267
|
+
raise ClientError(
|
|
268
|
+
"operator authority required — run sediment login with an operator token"
|
|
269
|
+
)
|
|
270
|
+
if resp.status_code >= 300:
|
|
271
|
+
raise ClientError(_error_detail(resp))
|
|
272
|
+
return resp.json()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def post_json(path: str, body: Any) -> Any:
|
|
276
|
+
"""POST ``body`` to ``path`` with resolved credentials; returns the parsed
|
|
277
|
+
JSON body. Same failure mapping as :func:`get_json` — the ingest doors
|
|
278
|
+
answer 200 with a skip reason rather than an error, so a non-2xx here is
|
|
279
|
+
a real problem."""
|
|
280
|
+
base_url, token = _resolve()
|
|
281
|
+
resp = _post(base_url, token, path, body)
|
|
282
|
+
if resp.status_code == 401:
|
|
283
|
+
raise ClientError("not logged in / token rejected — run sediment login")
|
|
284
|
+
if resp.status_code == 403:
|
|
285
|
+
raise ClientError(
|
|
286
|
+
"operator authority required — run sediment login with an operator token"
|
|
287
|
+
)
|
|
288
|
+
if resp.status_code >= 300:
|
|
289
|
+
raise ClientError(_error_detail(resp))
|
|
290
|
+
return resp.json()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def current_url() -> str:
|
|
294
|
+
"""The resolved server URL, for a verb that has to reason about *which*
|
|
295
|
+
server it is about to write to."""
|
|
296
|
+
base_url, _ = _resolve()
|
|
297
|
+
return base_url
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
_version_checked = False
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def maybe_warn_version_skew() -> None:
|
|
304
|
+
"""Warn once per process when the server's version differs from this
|
|
305
|
+
client's, printing the exact upgrade command. A failed probe is
|
|
306
|
+
silent — the verb's own request will surface the real error."""
|
|
307
|
+
global _version_checked
|
|
308
|
+
if _version_checked:
|
|
309
|
+
return
|
|
310
|
+
_version_checked = True
|
|
311
|
+
try:
|
|
312
|
+
me = get_json("/v1/me")
|
|
313
|
+
except ClientError:
|
|
314
|
+
return
|
|
315
|
+
server_version = me.get("version")
|
|
316
|
+
if server_version and server_version != __version__:
|
|
317
|
+
print(
|
|
318
|
+
ui.warn_line(
|
|
319
|
+
f"server version {server_version} differs from client "
|
|
320
|
+
f"{__version__}; run `uv tool upgrade sediment-cli`"
|
|
321
|
+
),
|
|
322
|
+
file=sys.stderr,
|
|
323
|
+
)
|