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/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
+ )