simulo 0.26.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.
Files changed (55) hide show
  1. simulo/__init__.py +433 -0
  2. simulo/_client/__init__.py +6 -0
  3. simulo/_client/_entrypoint.py +313 -0
  4. simulo/_client/_mounts.py +25 -0
  5. simulo/_client/_runner.py +186 -0
  6. simulo/_client/_secure_downloads.py +1181 -0
  7. simulo/_client/app.py +1308 -0
  8. simulo/_client/asset.py +331 -0
  9. simulo/_client/asset_api.py +517 -0
  10. simulo/_client/asset_package.py +1103 -0
  11. simulo/_client/asset_pins.py +187 -0
  12. simulo/_client/builtin_aliases.py +107 -0
  13. simulo/_client/bundle.py +254 -0
  14. simulo/_client/cancel_api.py +104 -0
  15. simulo/_client/cli.py +9063 -0
  16. simulo/_client/config.py +186 -0
  17. simulo/_client/credentials.py +210 -0
  18. simulo/_client/discovery.py +214 -0
  19. simulo/_client/export_api.py +212 -0
  20. simulo/_client/export_bundle.py +296 -0
  21. simulo/_client/facades.py +581 -0
  22. simulo/_client/http.py +414 -0
  23. simulo/_client/identity_api.py +117 -0
  24. simulo/_client/install_samples.py +267 -0
  25. simulo/_client/jobs_api.py +224 -0
  26. simulo/_client/learning.py +393 -0
  27. simulo/_client/login.py +319 -0
  28. simulo/_client/mode.py +29 -0
  29. simulo/_client/outputs.py +116 -0
  30. simulo/_client/packaging.py +445 -0
  31. simulo/_client/preflight_api.py +186 -0
  32. simulo/_client/preflight_render.py +200 -0
  33. simulo/_client/registry.py +98 -0
  34. simulo/_client/runtime.py +185 -0
  35. simulo/_client/runtime_display.py +90 -0
  36. simulo/_client/seed_ref.py +76 -0
  37. simulo/_client/stub.py +41 -0
  38. simulo/_client/submit_api.py +1057 -0
  39. simulo/_client/templates/__init__.py +21 -0
  40. simulo/_client/templates/inference/app.py.tmpl +316 -0
  41. simulo/_client/templates/inference/simuloignore.tmpl +30 -0
  42. simulo/_client/templates/scenario/app.py.tmpl +93 -0
  43. simulo/_client/templates/scenario/simuloignore.tmpl +27 -0
  44. simulo/_client/templates/training/app.py.tmpl +235 -0
  45. simulo/_client/templates/training/simuloignore.tmpl +29 -0
  46. simulo/_client/view_fragment.py +21 -0
  47. simulo/_client/view_session_api.py +122 -0
  48. simulo/_client/volume.py +71 -0
  49. simulo/callbacks.py +274 -0
  50. simulo/py.typed +0 -0
  51. simulo-0.26.0.dist-info/METADATA +130 -0
  52. simulo-0.26.0.dist-info/RECORD +55 -0
  53. simulo-0.26.0.dist-info/WHEEL +5 -0
  54. simulo-0.26.0.dist-info/entry_points.txt +2 -0
  55. simulo-0.26.0.dist-info/top_level.txt +1 -0
simulo/_client/http.py ADDED
@@ -0,0 +1,414 @@
1
+ """Shared HTTP request/auth logic for the cloud submit surface (stdlib ``urllib`` only).
2
+
3
+ Factored out of ``jobs_api.py`` so ``jobs_api.py``, ``submit_api.py``, and
4
+ ``login.py`` share exactly one request path, one structured-error parser, and
5
+ one bearer-attachment policy — a security-sensitive decision that must not be
6
+ reimplemented three times.
7
+
8
+ **https-when-token guard.** A bearer token must never be sent to a plain-http
9
+ URL unless that URL's host is the local loopback (developing against
10
+ ``simulo-backend serve`` on ``127.0.0.1`` is the one legitimate plaintext
11
+ case). Sending a token to a non-loopback ``http://`` URL is refused with a
12
+ clear error — it almost always means ``SIMULO_API_URL`` is misconfigured
13
+ (missing ``https://``), and silently sending the token anyway would leak it to
14
+ anyone on the network path.
15
+
16
+ **Foreign-host rule.** A caller resolving a presigned (or control-plane-served)
17
+ ``download_url``/``upload_url`` from a JSON response body must attach the API
18
+ bearer only when that URL's host equals the API host — see
19
+ ``simulo.interfaces.platform.submit`` module docstring ("Worker URL rule").
20
+ Presigned S3 URLs carry their own auth in their query string; sending the API
21
+ bearer to a third-party host is both unnecessary and a credential-leakage risk.
22
+ :func:`may_attach_bearer` implements this rule for any URL against a resolved
23
+ API base URL.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import ipaddress
29
+ import json
30
+ import urllib.error
31
+ import urllib.parse
32
+ import urllib.request
33
+ from typing import Any, Callable, Optional
34
+
35
+ #: Every outbound call has an explicit timeout (NFR — no open-ended waits).
36
+ DEFAULT_TIMEOUT_S = 10.0
37
+
38
+ #: Loopback hostnames exempt from the https-when-token guard. ``localhost``
39
+ #: is not an IP literal so it needs an explicit exact-match entry;
40
+ #: ``127.0.0.1``/``::1`` are listed too for defense-in-depth clarity even
41
+ #: though :func:`is_loopback_host` also recognizes them (and the rest of
42
+ #: ``127.0.0.0/8``) via ``ipaddress.ip_address(...).is_loopback``.
43
+ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
44
+
45
+
46
+ class HttpError(RuntimeError):
47
+ """Base error for HTTP communication problems against the Simulo platform."""
48
+
49
+
50
+ class HttpUnavailable(HttpError):
51
+ """The endpoint cannot be reached at all (nothing listening / DNS failure)."""
52
+
53
+
54
+ class HttpHTTPError(HttpError):
55
+ """A structured non-2xx response, parsed from the standard error envelope.
56
+
57
+ ``candidates`` (optional): some 409 responses carry a machine-readable
58
+ ``candidates`` list as a SIBLING of the ``error`` envelope — the ids an
59
+ ambiguous resource-id prefix matched (``job_ref_ambiguous``,
60
+ explicit-run-intent wave; the server's human ``message`` lists them too,
61
+ so callers that only print ``message`` still show them). ``None`` when
62
+ the response carried no such field — the normal case.
63
+
64
+ ``retry_after`` (optional): the parsed ``Retry-After`` header (seconds) that
65
+ the control plane sends on a 429 ``rate_limit_exceeded`` response (NFR:
66
+ "Never return 429 without ``Retry-After``"). ``None`` when the header was
67
+ absent or not an integer number of seconds. The CLI's observe loops sleep
68
+ this value (falling back to their own poll interval) and continue rather
69
+ than dying on a rate limit — see ``cli.py``'s 429 branches.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ status: int,
75
+ code: str,
76
+ message: str,
77
+ *,
78
+ candidates: Optional[list[str]] = None,
79
+ retry_after: Optional[int] = None,
80
+ ) -> None:
81
+ super().__init__(f"{code}: {message}")
82
+ self.status = status
83
+ self.code = code
84
+ self.message = message
85
+ self.candidates = candidates
86
+ self.retry_after = retry_after
87
+
88
+
89
+ class InsecureBearerError(HttpError):
90
+ """Refused to attach a bearer token to a plain-http, non-loopback URL."""
91
+
92
+
93
+ def is_loopback_host(hostname: Optional[str]) -> bool:
94
+ """``True`` for ``localhost`` / ``::1`` / any genuine loopback IP literal — the
95
+ local dev loopback.
96
+
97
+ Public (not ``_``-prefixed): shared by the bearer-attachment guard below
98
+ AND by any caller that needs the same "is this the local dev stand-in"
99
+ judgment for a DIFFERENT scheme pair (e.g. ``simulo view``'s https-or-
100
+ loopback-http / wss-or-loopback-ws checks on a server-supplied URL before
101
+ handing it to ``webbrowser.open`` — see ``cli._validate_mint_response_urls``).
102
+ This is also the function the console's ``isLoopbackHost`` port
103
+ (``simulo-ui/apps/simulo-console/src/api/jobs.ts``) names as its
104
+ "authority" — any behavioral drift there is a bug in the console, not a
105
+ reinterpretation, so the rule stated here is the canonical one.
106
+
107
+ **The rule.** ``hostname`` is loopback when it is EXACTLY ``localhost``
108
+ or ``::1`` (a case-SENSITIVE exact-string match against this function's
109
+ own input — ``localhost.evil.com`` and the trailing-dot ``localhost.``
110
+ are NOT loopback regardless of case. This does NOT mean an uppercase
111
+ URL is rejected end to end: BOTH real call sites pass this function
112
+ ``urlsplit(url).hostname``, which ``urlsplit`` already lowercases, so
113
+ ``http://LOCALHOST/`` — and ``http://LOCALHOST/`` piped through either
114
+ ``require_https_or_loopback_for_bearer`` or
115
+ ``cli._validate_mint_response_urls`` — is accepted; only a DIRECT call
116
+ to this function with a raw, still-uppercase string like
117
+ ``is_loopback_host("LOCALHOST")`` sees the case sensitivity), OR when
118
+ it parses as a
119
+ genuine IPv4/IPv6 literal whose address is loopback — i.e. the full
120
+ ``127.0.0.0/8`` range (``127.0.0.1``, ``127.5.5.5``,
121
+ ``127.255.255.255``, ...), not just ``127.0.0.1`` itself, plus the
122
+ IPv4-mapped IPv6 alias ``::ffff:127.0.0.1`` (a genuine loopback address
123
+ per RFC 4291, not an attacker-controllable string) — but ONLY the
124
+ IPv4-MAPPED form (``::ffff:a.b.c.d``, ``IPv6Address.ipv4_mapped`` set).
125
+ The deprecated IPv4-COMPATIBLE form (``::127.0.0.1``), a 6to4 address
126
+ embedding a loopback octet (``2002:7f00:1::``), and a NAT64
127
+ well-known-prefix address embedding one (``64:ff9b::127.0.0.1``) are
128
+ all REJECTED — each parses to a distinct IPv6 literal with
129
+ ``ipv4_mapped is None`` (verified directly), so they fall to
130
+ ``addr.is_loopback`` on the address AS WRITTEN, which is ``False`` for
131
+ all three; they are not silently folded into the mapped case. A
132
+ hostname is deliberately NOT loopback merely for STARTING WITH the
133
+ string
134
+ ``"127."`` — ``127.evil.com``, ``127.0.0.1.evil.com``, and
135
+ ``127.0.0.1.attacker.tld`` are ordinary attacker-registrable public
136
+ domains and are rejected — a naive ``hostname.startswith("127.")``
137
+ check would have wrongly accepted all three, which is exactly the
138
+ trap this function exists to avoid. The trailing-dot form
139
+ ``127.0.0.1.`` is ALSO now rejected (``ipaddress.ip_address`` refuses
140
+ it) — a narrowing versus the old prefix check, which used to accept
141
+ it; fail-closed, so not a security regression, but worth stating since
142
+ it is a behavior change. An IPv6 zone id (``::1%eth0``,
143
+ ``::1%25eth0``) is still accepted — ``ipaddress`` parses the zone and
144
+ the address portion is still exactly ``::1``; security-neutral (a zone
145
+ id narrows which local interface a scope-local address binds to, it
146
+ cannot be used to reach a remote host), documented here so it reads as
147
+ a deliberate decision rather than an oversight.
148
+
149
+ **Why this does not just call ``ipaddress.IPv6Address.is_loopback``
150
+ directly.** That property only special-cases an IPv4-mapped address
151
+ (delegating to the mapped ``IPv4Address``'s own ``is_loopback``) on
152
+ Python >= 3.11.9 / >= 3.12.4 (the CVE-2024-4032 backport) — on any
153
+ OLDER patch release within this package's own ``>=3.11`` floor (e.g.
154
+ 3.11.0-3.11.8, 3.12.0-3.12.3 — verified directly against a 3.12.3
155
+ interpreter), it falls back to a bare ``self._ip == 1`` check and
156
+ returns ``False`` for ``::ffff:127.0.0.1``. Relying on that delegation
157
+ would make this function's behavior depend on which patch release
158
+ happens to be installed — the exact same ``127.0.0.1`` URL would be
159
+ accepted on one machine and refused on another. This function reads
160
+ ``IPv6Address.ipv4_mapped`` (a plain attribute, unaffected by that CVE
161
+ fix, correct on every supported release) itself and checks
162
+ ``is_loopback`` on the UNWRAPPED address, so the result is identical
163
+ on every Python version this package supports.
164
+
165
+ **Deliberately REJECTED, not accidentally:** alternate/shorthand IPv4
166
+ encodings — ``127.1`` (BSD ``inet_aton`` shorthand), ``2130706433``
167
+ (decimal 32-bit form), ``0x7f000001`` (hex form), and ``0177.0.0.1``
168
+ (leading-octal first octet) — all raise ``ValueError`` out of
169
+ ``ipaddress.ip_address`` and so are treated as NOT loopback here,
170
+ fail-closed. (The console's JS port's OWN ``isLoopbackHost`` function
171
+ rejects these same four strings too — ``isLoopbackHost('127.1')`` is
172
+ ``False`` there as well. What differs is the console's URL-LEVEL
173
+ check: the browser's ``URL()`` parser canonicalizes a ``viewerUrl``/
174
+ ``gatewayUrl`` string like ``http://127.1/session`` to
175
+ ``http://127.0.0.1/session`` *before* ``isLoopbackHost`` ever sees the
176
+ hostname, so the console's end-to-end URL validation accepts those
177
+ URLs even though its own ``isLoopbackHost`` would reject the bare
178
+ string ``"127.1"``. Python's ``urllib.parse.urlsplit()`` performs no
179
+ such canonicalization — the hostname reaching THIS function is still
180
+ the literal, unresolved string — so accepting these here would mean
181
+ reimplementing that canonicalization ourselves for a dev-only
182
+ affordance that real dev servers never report their bind address as.)
183
+ Out-of-range octets (``127.256.0.1``) and ambiguous leading-zero
184
+ octets (``127.001.0.1`` — the same octal-vs-decimal ambiguity
185
+ CVE-2021-29921 hardened ``ipaddress`` against) are rejected the same
186
+ way.
187
+
188
+ A THIRD, independent copy of this "is this the local dev loopback"
189
+ judgment lives in ``simulo-backend``'s parked CLI —
190
+ ``sdk/src/simulo/cli/api/client.py``'s module-level ``_LOOPBACK_HOSTS``
191
+ frozenset, tested inline inside ``SimuloClient._assert_token_transport_safe``
192
+ (no separate function). Not vulnerable to the bug this function's
193
+ docstring describes above (it was always exact-set membership, no
194
+ prefix test), but it does not recognize the full ``127.0.0.0/8``
195
+ range or IPv4-mapped IPv6 the way this function now does, so it is
196
+ STRICTER, not just independently correct. Not unified with this
197
+ module here (`simulo-backend` is a separate, GPU-only package that
198
+ does not depend on the ``simulo`` thin client) — flagged so a future
199
+ reader of either copy knows the other exists.
200
+ """
201
+ if hostname is None or not isinstance(hostname, str):
202
+ return False
203
+ if hostname in _LOOPBACK_HOSTS:
204
+ return True
205
+ try:
206
+ addr = ipaddress.ip_address(hostname)
207
+ except ValueError:
208
+ return False
209
+ # Read `ipv4_mapped` ourselves rather than trusting `addr.is_loopback`
210
+ # to do it — see the docstring's CVE-2024-4032 note above.
211
+ mapped = getattr(addr, "ipv4_mapped", None)
212
+ return (mapped or addr).is_loopback
213
+
214
+
215
+ def require_https_or_loopback_for_bearer(url: str) -> None:
216
+ """Raise :class:`InsecureBearerError` if *url* would carry a bearer over plaintext.
217
+
218
+ Call this immediately before attaching an ``Authorization: Bearer`` header to
219
+ any request. ``https://`` is always fine; ``http://`` is fine only to the
220
+ local loopback (127.0.0.1 / localhost / ::1) — the local dev stand-in.
221
+ """
222
+ parsed = urllib.parse.urlsplit(url)
223
+ if parsed.scheme == "https":
224
+ return
225
+ if parsed.scheme == "http" and is_loopback_host(parsed.hostname):
226
+ return
227
+ raise InsecureBearerError(
228
+ f"Refusing to send an Authorization bearer token to {url!r}: only https:// URLs "
229
+ "(or plain http:// to the local loopback) may carry a bearer token. Check "
230
+ "SIMULO_API_URL / SIMULO_ENV for a missing 'https://'."
231
+ )
232
+
233
+
234
+ def may_attach_bearer(url: str, *, api_base_url: str) -> bool:
235
+ """``True`` when *url*'s host matches *api_base_url*'s host.
236
+
237
+ Implements the "worker URL rule" from ``simulo.interfaces.platform.submit``
238
+ for client-side downloads too: a presigned/foreign-host URL returned inside
239
+ a JSON response (e.g. a recording's ``download_url``) must never receive the
240
+ API bearer — only requests to the API host itself may carry it.
241
+ """
242
+ return urllib.parse.urlsplit(url).netloc == urllib.parse.urlsplit(api_base_url).netloc
243
+
244
+
245
+ #: Ceiling on a parsed ``Retry-After`` (seconds) — mirrors the control plane's
246
+ #: own rate-limit windows (all well under an hour). Security clamp (LOW,
247
+ #: PR #398 review): without this, an untrusted/misbehaving server value flows
248
+ #: straight into ``time.sleep`` in the CLI's observe loops, which has TWO
249
+ #: failure modes — an indefinite-looking wedge on a huge-but-sleepable value,
250
+ #: and an *uncaught* ``OverflowError`` (``time.sleep`` rejects a duration that
251
+ #: doesn't fit its underlying timer) on a value beyond roughly 1e9 seconds,
252
+ #: crashing the CLI with a traceback instead of backing off. Clamping here —
253
+ #: the single parse site — closes both: every caller only ever sees a value
254
+ #: in ``[0, _RETRY_AFTER_MAX_S]``.
255
+ _RETRY_AFTER_MAX_S = 3600
256
+
257
+
258
+ def _parse_retry_after(headers: Any) -> Optional[int]:
259
+ """Parse a ``Retry-After`` response header (seconds) tolerantly, clamped.
260
+
261
+ ``headers`` is the error response's ``http.client.HTTPMessage`` (a
262
+ case-insensitive ``email.message.Message``); ``.get`` returns ``None`` when
263
+ the header is absent. The control plane always sends ``Retry-After`` as an
264
+ integer number of seconds on a 429. Per RFC 7231 the header MAY instead be
265
+ an HTTP-date — the platform never emits that form, so a non-integer (or
266
+ negative) value simply yields ``None`` and the caller falls back to its own
267
+ poll interval rather than failing the whole error parse. A value larger
268
+ than ``_RETRY_AFTER_MAX_S`` is clamped down to it, never rejected to
269
+ ``None`` — a large-but-honest backoff request should still be honored
270
+ (bounded), not silently downgraded to the caller's (likely much shorter)
271
+ default poll interval.
272
+ """
273
+ if headers is None:
274
+ return None
275
+ raw = headers.get("Retry-After")
276
+ if raw is None:
277
+ return None
278
+ try:
279
+ seconds = int(str(raw).strip())
280
+ except (TypeError, ValueError):
281
+ return None
282
+ if seconds < 0:
283
+ return None
284
+ return min(seconds, _RETRY_AFTER_MAX_S)
285
+
286
+
287
+ def _structured_http_error(exc: urllib.error.HTTPError) -> HttpHTTPError:
288
+ """Parse the standard ``{"error": {code, message, ...}}`` envelope, tolerantly."""
289
+ code, message = "http_error", f"HTTP {exc.code}"
290
+ candidates: Optional[list[str]] = None
291
+ # Read the `Retry-After` header (429 rate-limit backoff) BEFORE consuming
292
+ # the body — headers are always present even when the body is empty/non-JSON.
293
+ retry_after = _parse_retry_after(getattr(exc, "headers", None))
294
+ try:
295
+ body = json.loads(exc.read().decode("utf-8"))
296
+ if isinstance(body, dict) and isinstance(body.get("error"), dict):
297
+ error = body["error"]
298
+ code = str(error.get("code", code))
299
+ message = str(error.get("message", message))
300
+ # `candidates` rides as a SIBLING of the error envelope (see
301
+ # HttpHTTPError docstring) — parsed just as tolerantly: anything
302
+ # that isn't a list stays None rather than failing the parse.
303
+ raw_candidates = body.get("candidates")
304
+ if isinstance(raw_candidates, list):
305
+ candidates = [str(candidate) for candidate in raw_candidates]
306
+ except (ValueError, OSError):
307
+ pass # non-JSON error body — keep the generic code/message
308
+ return HttpHTTPError(exc.code, code, message, candidates=candidates, retry_after=retry_after)
309
+
310
+
311
+ def _redact_query(url: str) -> str:
312
+ """Strip *url*'s query string (security LOW, PR-7 trio review).
313
+
314
+ A presigned S3 PUT/GET URL's query string carries the SigV4 signature
315
+ and credential scope — printing it whole into an error message (which
316
+ can land in a terminal, a CI log, or a bug report) would leak a live,
317
+ unexpired credential for the duration of the presign's TTL. The host and
318
+ path stay (useful for diagnosis: which endpoint failed); only the query
319
+ is dropped. Applied narrowly at the one error-text call site
320
+ (:func:`_unavailable_message`) — additive, no behavior change for a
321
+ caller that never sees an unreachable-host error.
322
+ """
323
+ parsed = urllib.parse.urlsplit(url)
324
+ return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", parsed.fragment))
325
+
326
+
327
+ def _unavailable_message(url: str, unavailable_hint: str, reason: object) -> str:
328
+ hint = f"\n{unavailable_hint}" if unavailable_hint else ""
329
+ return f"Cannot reach the Simulo platform at {_redact_query(url)} ({reason}).{hint}"
330
+
331
+
332
+ def request_bytes(
333
+ method: str,
334
+ url: str,
335
+ *,
336
+ token: Optional[str] = None,
337
+ api_base_url: Optional[str] = None,
338
+ body: Optional[bytes] = None,
339
+ content_type: Optional[str] = None,
340
+ extra_headers: Optional[dict[str, str]] = None,
341
+ timeout: float = DEFAULT_TIMEOUT_S,
342
+ unavailable_hint: str = "",
343
+ on_response_headers: Optional[Callable[[Any], None]] = None,
344
+ ) -> bytes:
345
+ """Issue one HTTP request; return the raw response body bytes.
346
+
347
+ When *token* is given, it is attached as ``Authorization: Bearer`` UNLESS
348
+ *api_base_url* is given and *url* resolves to a different host (the
349
+ foreign-host rule) — in which case the header is silently omitted (the
350
+ normal, expected case for a presigned download URL). When the header WOULD
351
+ be attached, :func:`require_https_or_loopback_for_bearer` is enforced first.
352
+
353
+ ``on_response_headers`` (optional) is called with the SUCCESS response's
354
+ header mapping (``http.client.HTTPMessage``) before the body is read —
355
+ the additive seam the asset multipart-upload client uses to capture a
356
+ presigned part PUT's ``ETag`` header (S3's convention) without a second
357
+ request path. Never called on an error response.
358
+ """
359
+ headers: dict[str, str] = dict(extra_headers or {})
360
+ if token is not None and (api_base_url is None or may_attach_bearer(url, api_base_url=api_base_url)):
361
+ require_https_or_loopback_for_bearer(url)
362
+ headers["Authorization"] = f"Bearer {token}"
363
+ if content_type is not None:
364
+ headers["Content-Type"] = content_type
365
+ request = urllib.request.Request(url, data=body, headers=headers, method=method)
366
+ try:
367
+ with urllib.request.urlopen(request, timeout=timeout) as response:
368
+ if on_response_headers is not None:
369
+ on_response_headers(response.headers)
370
+ result: bytes = response.read()
371
+ return result
372
+ except urllib.error.HTTPError as exc:
373
+ raise _structured_http_error(exc) from exc
374
+ except urllib.error.URLError as exc:
375
+ raise HttpUnavailable(_unavailable_message(url, unavailable_hint, exc.reason)) from exc
376
+
377
+
378
+ def request_json(
379
+ method: str,
380
+ url: str,
381
+ *,
382
+ token: Optional[str] = None,
383
+ api_base_url: Optional[str] = None,
384
+ json_body: Optional[Any] = None,
385
+ extra_headers: Optional[dict[str, str]] = None,
386
+ timeout: float = DEFAULT_TIMEOUT_S,
387
+ unavailable_hint: str = "",
388
+ ) -> Any:
389
+ """Issue one JSON-in/JSON-out request; return the parsed response body."""
390
+ headers = {"Accept": "application/json", **(extra_headers or {})}
391
+ body: Optional[bytes] = None
392
+ content_type: Optional[str] = None
393
+ if json_body is not None:
394
+ body = json.dumps(json_body).encode("utf-8")
395
+ content_type = "application/json"
396
+ raw = request_bytes(
397
+ method,
398
+ url,
399
+ token=token,
400
+ api_base_url=api_base_url,
401
+ body=body,
402
+ content_type=content_type,
403
+ extra_headers=headers,
404
+ timeout=timeout,
405
+ unavailable_hint=unavailable_hint,
406
+ )
407
+ if not raw:
408
+ return None
409
+ return json.loads(raw.decode("utf-8"))
410
+
411
+
412
+ def quote_path_segment(segment: str) -> str:
413
+ """Percent-encode one URL path segment (rejects ``/`` so traversal cannot survive)."""
414
+ return urllib.parse.quote(segment, safe="")
@@ -0,0 +1,117 @@
1
+ """Authenticated identity lookup for the thin CLI.
2
+
3
+ Organization names and slugs are live server-owned profile data. They do not
4
+ belong in the credential file, whose UUID fields remain the authorization
5
+ contract. This client therefore reads ``GET /users/me`` through the shared
6
+ stdlib HTTP boundary without persisting any response field.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any, Optional
13
+ from uuid import UUID
14
+
15
+ from simulo._client import http
16
+
17
+ _REQUEST_TIMEOUT_S = 10.0
18
+ _UNAVAILABLE_HINT = "Cannot load your current identity — check your network connection and try again."
19
+
20
+
21
+ class IdentityApiError(http.HttpError):
22
+ """A malformed or unavailable current-user response."""
23
+
24
+
25
+ def _require_canonical_uuid(value: Any, *, context: str) -> str:
26
+ if not isinstance(value, str):
27
+ raise IdentityApiError(f"Malformed {context} (organization ID is not a string).")
28
+ try:
29
+ canonical = str(UUID(value))
30
+ except ValueError as exc:
31
+ raise IdentityApiError(f"Malformed {context} (organization ID is not a UUID).") from exc
32
+ if canonical != value:
33
+ raise IdentityApiError(f"Malformed {context} (organization ID is not canonical).")
34
+ return canonical
35
+
36
+
37
+ class IdentityApiClient:
38
+ """Client for ``GET /users/me``."""
39
+
40
+ def __init__(self, base_url: str, *, token: Optional[str] = None) -> None:
41
+ if not base_url.startswith(("http://", "https://")):
42
+ raise IdentityApiError(f"API base URL must be an http(s) URL, got {base_url!r}.")
43
+ self._base_url = base_url.rstrip("/")
44
+ self._token = token
45
+
46
+ def get_me(self) -> dict[str, Any]:
47
+ try:
48
+ payload = http.request_json(
49
+ "GET",
50
+ self._base_url + "/users/me",
51
+ token=self._token,
52
+ api_base_url=self._base_url,
53
+ timeout=_REQUEST_TIMEOUT_S,
54
+ unavailable_hint=_UNAVAILABLE_HINT,
55
+ )
56
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
57
+ raise IdentityApiError("Malformed current-user response (expected UTF-8 JSON).") from exc
58
+ if not isinstance(payload, dict) or not isinstance(payload.get("email"), str):
59
+ raise IdentityApiError("Malformed current-user response (missing email).")
60
+ active = payload.get("active_organization")
61
+ if active is not None:
62
+ if not isinstance(active, dict):
63
+ raise IdentityApiError("Malformed current-user response (active organization is not an object).")
64
+ missing = [
65
+ field
66
+ for field in ("organization_id", "name", "slug")
67
+ if not isinstance(active.get(field), str) or not active[field]
68
+ ]
69
+ if missing:
70
+ raise IdentityApiError(
71
+ f"Malformed current-user response (active organization missing field(s) {missing})."
72
+ )
73
+ _require_canonical_uuid(active["organization_id"], context="current-user response")
74
+ memberships = payload.get("memberships")
75
+ if not isinstance(memberships, list):
76
+ raise IdentityApiError("Malformed current-user response (memberships is not an array).")
77
+ for membership in memberships:
78
+ if not isinstance(membership, dict):
79
+ raise IdentityApiError("Malformed current-user response (membership is not an object).")
80
+ missing = [
81
+ field
82
+ for field in ("organization_id", "name", "slug", "role", "status")
83
+ if not isinstance(membership.get(field), str) or not membership[field]
84
+ ]
85
+ if missing:
86
+ raise IdentityApiError(f"Malformed current-user response (membership missing field(s) {missing}).")
87
+ _require_canonical_uuid(membership["organization_id"], context="current-user response")
88
+ return payload
89
+
90
+ def switch_organization(self, organization_id: str) -> dict[str, Any]:
91
+ """Switch to an already-resolved active membership UUID."""
92
+ organization_id = _require_canonical_uuid(organization_id, context="organization switch request")
93
+ try:
94
+ payload = http.request_json(
95
+ "POST",
96
+ self._base_url + f"/orgs/{http.quote_path_segment(organization_id)}/switch",
97
+ token=self._token,
98
+ api_base_url=self._base_url,
99
+ timeout=_REQUEST_TIMEOUT_S,
100
+ unavailable_hint=_UNAVAILABLE_HINT,
101
+ )
102
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
103
+ raise IdentityApiError("Malformed organization-switch response (expected UTF-8 JSON).") from exc
104
+ if not isinstance(payload, dict) or not isinstance(payload.get("active_organization"), dict):
105
+ raise IdentityApiError("Malformed organization-switch response (missing active organization).")
106
+ active = payload["active_organization"]
107
+ missing = [
108
+ field
109
+ for field in ("organization_id", "name", "slug", "role")
110
+ if not isinstance(active.get(field), str) or not active[field]
111
+ ]
112
+ if missing:
113
+ raise IdentityApiError(f"Malformed organization-switch response (missing field(s) {missing}).")
114
+ _require_canonical_uuid(active["organization_id"], context="organization-switch response")
115
+ if str(active["organization_id"]) != organization_id:
116
+ raise IdentityApiError("Malformed organization-switch response (organization does not match request).")
117
+ return dict(active)