lighthouse-cli 0.16.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 (49) hide show
  1. lighthouse/__init__.py +6 -0
  2. lighthouse/__main__.py +9 -0
  3. lighthouse/commands/__init__.py +0 -0
  4. lighthouse/commands/agents.py +310 -0
  5. lighthouse/commands/builds.py +85 -0
  6. lighthouse/commands/collectors.py +147 -0
  7. lighthouse/commands/connect.py +717 -0
  8. lighthouse/commands/connections.py +201 -0
  9. lighthouse/commands/evals.py +198 -0
  10. lighthouse/commands/kb.py +224 -0
  11. lighthouse/commands/language.py +56 -0
  12. lighthouse/commands/limits.py +61 -0
  13. lighthouse/commands/login.py +207 -0
  14. lighthouse/commands/models.py +61 -0
  15. lighthouse/commands/runs.py +157 -0
  16. lighthouse/commands/spend.py +84 -0
  17. lighthouse/commands/stack.py +103 -0
  18. lighthouse/commands/terms.py +50 -0
  19. lighthouse/commands/whoami.py +80 -0
  20. lighthouse/config.py +141 -0
  21. lighthouse/credentials.py +233 -0
  22. lighthouse/device.py +124 -0
  23. lighthouse/exits.py +78 -0
  24. lighthouse/flags.py +61 -0
  25. lighthouse/http.py +355 -0
  26. lighthouse/i18n.py +250 -0
  27. lighthouse/locales/ar.json +343 -0
  28. lighthouse/locales/de.json +343 -0
  29. lighthouse/locales/en.json +343 -0
  30. lighthouse/locales/es.json +343 -0
  31. lighthouse/locales/fr.json +343 -0
  32. lighthouse/locales/ga.json +343 -0
  33. lighthouse/locales/it.json +343 -0
  34. lighthouse/locales/ja.json +343 -0
  35. lighthouse/locales/nl.json +343 -0
  36. lighthouse/locales/pt.json +343 -0
  37. lighthouse/locales/zh-Hans.json +343 -0
  38. lighthouse/locales/zh-Hant.json +343 -0
  39. lighthouse/main.py +229 -0
  40. lighthouse/out.py +169 -0
  41. lighthouse/py.typed +0 -0
  42. lighthouse/sealing.py +448 -0
  43. lighthouse_cli-0.16.0.dist-info/METADATA +23 -0
  44. lighthouse_cli-0.16.0.dist-info/RECORD +49 -0
  45. lighthouse_cli-0.16.0.dist-info/WHEEL +5 -0
  46. lighthouse_cli-0.16.0.dist-info/entry_points.txt +2 -0
  47. lighthouse_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
  48. lighthouse_cli-0.16.0.dist-info/licenses/NOTICE +2 -0
  49. lighthouse_cli-0.16.0.dist-info/top_level.txt +1 -0
lighthouse/device.py ADDED
@@ -0,0 +1,124 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Signing in through the browser with a device code, as `stripe login` does.
4
+
5
+ $ lighthouse login --host t7m2kqx9vbn.atlanticagenticlabs.com
6
+
7
+ Your pairing code is: WXKM-7T4P
8
+ Press Enter to open the browser, or visit
9
+ https://t7m2kqx9vbn.atlanticagenticlabs.com/device
10
+ Waiting for approval…
11
+ Signed in as ops@example.test (viewer). Scopes: telemetry:read agents:read
12
+
13
+ The person approves in their own portal, behind their own identity provider and
14
+ their own second factor. Nothing is typed into this terminal: no password, no
15
+ token, no copy and paste. The CLI then holds a short-lived delegated token in
16
+ the OS keychain (credentials.py).
17
+
18
+ **The code is shown before the browser opens**, and the browser is opened only
19
+ on a keypress unless `--yes` is given. A command that silently launches a
20
+ browser is a command nobody can run over SSH, and a code the person has already
21
+ seen is a code they can compare with the one on the page — which is the whole
22
+ protection against being walked through approving somebody else's sign-in.
23
+
24
+ `--no-browser` prints the URL and nothing else, for a remote shell.
25
+
26
+ Polling obeys the interval the service asks for, and backs off further when it
27
+ answers `slow_down` (RFC 8628 §3.5). It stops at `expires_in` with a message
28
+ that says to run the command again, rather than looping.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import time
33
+ import webbrowser
34
+ from dataclasses import dataclass
35
+ from typing import Any
36
+
37
+ from lighthouse.exits import NEEDS_A_PERSON, CliError
38
+ from lighthouse.http import Client, Refused
39
+ from lighthouse.i18n import t
40
+ from lighthouse.out import Out
41
+
42
+ DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
43
+ PENDING = ("authorization_pending", "slow_down")
44
+
45
+
46
+ @dataclass
47
+ class Started:
48
+ user_code: str
49
+ verification_uri: str
50
+ verification_uri_complete: str
51
+ device_code: str
52
+ interval: int
53
+ expires_in: int
54
+ scope: str
55
+
56
+ @classmethod
57
+ def of(cls, body: dict[str, Any]) -> Started:
58
+ return cls(str(body["user_code"]), str(body["verification_uri"]),
59
+ str(body.get("verification_uri_complete") or body["verification_uri"]),
60
+ str(body["device_code"]), int(body.get("interval") or 5),
61
+ int(body.get("expires_in") or 600), str(body.get("scope") or ""))
62
+
63
+
64
+ def start(client: Client, *, scope: str | None, client_name: str) -> Started:
65
+ body = client.post("/api/v1/oauth/device/code",
66
+ {"scope": scope, "client_name": client_name})
67
+ return Started.of(body)
68
+
69
+
70
+ def open_browser(url: str, *, opener: Any = None) -> bool:
71
+ """True if a browser was opened. Never raises: a machine with no browser is
72
+ a normal thing, and the URL has already been printed."""
73
+ try:
74
+ return bool((opener or webbrowser.open)(url))
75
+ except Exception: # pragma: no cover - platform dependent
76
+ return False
77
+
78
+
79
+ def poll(client: Client, started: Started, *, out: Out, sleep: Any = None,
80
+ now: Any = None) -> dict[str, Any]:
81
+ """Wait for the person, then return the token the service issued."""
82
+ rest = sleep or time.sleep
83
+ clock = now or time.monotonic
84
+ deadline = clock() + started.expires_in
85
+ interval = float(started.interval)
86
+ while True:
87
+ if clock() >= deadline:
88
+ raise CliError(t("err.pairing_expired"), error="expired", hint=t("hint.login_again"))
89
+ rest(interval)
90
+ try:
91
+ return dict(client.post("/api/v1/oauth/token", {
92
+ "grant_type": DEVICE_GRANT, "device_code": started.device_code}))
93
+ except Refused as e:
94
+ if e.error == "slow_down":
95
+ interval += 5
96
+ continue
97
+ if e.error == "authorization_pending":
98
+ continue
99
+ if e.error == "access_denied":
100
+ raise CliError(t("err.sign_in_declined"), error="declined") from None
101
+ raise
102
+
103
+
104
+ def sign_in(client: Client, *, out: Out, scope: str | None, client_name: str,
105
+ no_browser: bool = False, assume_yes: bool = False, no_input: bool = False,
106
+ opener: Any = None, sleep: Any = None, reader: Any = None) -> dict[str, Any]:
107
+ """The whole flow. Returns the token document; the caller stores it."""
108
+ started = start(client, scope=scope, client_name=client_name)
109
+ out.say(t("login.pairing_code", code=out.paint(started.user_code, "bold")))
110
+ out.say(t("login.approve_at", url=started.verification_uri))
111
+ if started.scope:
112
+ out.note(t("login.asking_for", scope=started.scope))
113
+ if not no_browser:
114
+ if assume_yes or no_input:
115
+ open_browser(started.verification_uri_complete, opener=opener)
116
+ else:
117
+ try:
118
+ (reader or input)(t("prompt.open_browser") + " ")
119
+ except EOFError:
120
+ raise CliError(t("err.no_terminal"), code=NEEDS_A_PERSON,
121
+ error="needs_a_person", hint=t("hint.no_terminal")) from None
122
+ open_browser(started.verification_uri_complete, opener=opener)
123
+ out.note(t("note.waiting_for_approval"))
124
+ return poll(client, started, out=out, sleep=sleep)
lighthouse/exits.py ADDED
@@ -0,0 +1,78 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Exit codes, and the one error type that carries them.
4
+
5
+ A script in someone's CI has to be able to tell the four cases apart without
6
+ reading English, so the codes are few, fixed and documented:
7
+
8
+ | Code | Meaning | What a pipeline should do |
9
+ |---|---|---|
10
+ | 0 | it worked | carry on |
11
+ | 1 | the command failed | stop; the message says why |
12
+ | 2 | the command line was wrong | stop; a human must fix the invocation |
13
+ | 3 | not signed in, or the credential has expired | run `lighthouse login` |
14
+ | 4 | signed in, but the role or scope does not reach this | ask an administrator |
15
+ | 5 | the service refused for now: rate limit, or a retryable 5xx | retry later |
16
+ | 7 | the command needed a person and none is there (`--no-input`, or no tty) | stop |
17
+
18
+ Nothing exits on a signal with a code of its own: SIGINT leaves 130 as the
19
+ shell expects.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ from lighthouse.i18n import t
26
+
27
+ OK = 0
28
+ FAILED = 1
29
+ USAGE = 2
30
+ UNAUTHENTICATED = 3
31
+ FORBIDDEN = 4
32
+ RETRY = 5
33
+ NEEDS_A_PERSON = 7
34
+
35
+ CODES = {
36
+ OK: "ok",
37
+ FAILED: "failed",
38
+ USAGE: "usage",
39
+ UNAUTHENTICATED: "unauthenticated",
40
+ FORBIDDEN: "forbidden",
41
+ RETRY: "retry",
42
+ NEEDS_A_PERSON: "needs_a_person",
43
+ }
44
+
45
+
46
+ class CliError(Exception):
47
+ """A failure to report and exit with. `error` is a stable short code, so
48
+ `--json` output can be matched on and the English can be improved without
49
+ breaking anyone's script."""
50
+
51
+ def __init__(self, message: str, *, code: int = FAILED, error: str = "failed",
52
+ hint: str | None = None, **extra: Any) -> None:
53
+ super().__init__(message)
54
+ self.message = message
55
+ self.code = code
56
+ self.error = error
57
+ self.hint = hint
58
+ self.extra = extra
59
+
60
+ def body(self) -> dict[str, Any]:
61
+ out: dict[str, Any] = {"ok": False, "error": self.error, "message": self.message}
62
+ if self.hint:
63
+ out["hint"] = self.hint
64
+ out.update(self.extra)
65
+ return out
66
+
67
+
68
+ class NotSignedIn(CliError):
69
+ def __init__(self, host: str | None = None) -> None:
70
+ message = t("err.not_signed_in_for", host=host) if host else t("err.not_signed_in")
71
+ super().__init__(message, code=UNAUTHENTICATED, error="unauthenticated",
72
+ hint=t("hint.login"))
73
+
74
+
75
+ class NeedsAPerson(CliError):
76
+ def __init__(self, what: str) -> None:
77
+ super().__init__(t("err.needs_a_person", what=what), code=NEEDS_A_PERSON,
78
+ error="needs_a_person", hint=t("hint.needs_a_person"))
lighthouse/flags.py ADDED
@@ -0,0 +1,61 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The global options, declared once and attached everywhere.
4
+
5
+ `--host`, `--json`, `--quiet`, `--no-input`, `--yes` and `--timeout` must work
6
+ at every depth: `lighthouse --json runs tail`, `lighthouse runs --json tail` and
7
+ `lighthouse runs tail --json` all mean the same thing, because people type all
8
+ three and a tool that accepts only one of them is a tool people swear at.
9
+
10
+ That is done by declaring them on the root parser with real defaults, and on a
11
+ shared parent — `inherited()` — with `default=SUPPRESS` for every subparser at
12
+ every level. Without `SUPPRESS`, parsing a subcommand would write the flag's
13
+ default over whatever an outer parser had already read, which is the single
14
+ commonest bug in nested `argparse` command lines.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ from typing import Any
20
+
21
+ HELP = {
22
+ "host": "the tenant's hostname or id",
23
+ "json": "one JSON document on standard output, nothing else",
24
+ "quiet": "no prose",
25
+ "no_input": "never ask a person anything; exit 7 instead",
26
+ "yes": "answer yes to every confirmation",
27
+ "timeout": "seconds for one request",
28
+ "lang": "the language to speak: en, fr, de, es, it, pt, nl, ga, ja, zh-Hans, zh-Hant or ar (default: your setting, then LANG)",
29
+ }
30
+
31
+
32
+ def add(target: argparse.ArgumentParser, *, suppress: bool) -> None:
33
+ """Attach the global options. `suppress` for anything but the root."""
34
+ def opts(default: Any) -> dict[str, Any]:
35
+ return {"default": argparse.SUPPRESS} if suppress else {"default": default}
36
+
37
+ hide = argparse.SUPPRESS
38
+ target.add_argument("--host", help=hide if suppress else HELP["host"], **opts(None))
39
+ target.add_argument("--json", action="store_true", dest="as_json",
40
+ help=hide if suppress else HELP["json"], **opts(False))
41
+ target.add_argument("--quiet", action="store_true",
42
+ help=hide if suppress else HELP["quiet"], **opts(False))
43
+ target.add_argument("--no-input", action="store_true", dest="no_input",
44
+ help=hide if suppress else HELP["no_input"], **opts(False))
45
+ target.add_argument("--yes", action="store_true",
46
+ help=hide if suppress else HELP["yes"], **opts(False))
47
+ target.add_argument("--timeout", type=float,
48
+ help=hide if suppress else HELP["timeout"], **opts(None))
49
+ target.add_argument("--lang", metavar="TAG",
50
+ help=hide if suppress else HELP["lang"], **opts(None))
51
+
52
+
53
+ def inherited() -> argparse.ArgumentParser:
54
+ """A parent parser carrying the global options, for `parents=[...]`.
55
+
56
+ A fresh one each time: `argparse` refuses to share a parent between two
57
+ parsers that both add the same option, and a sub-subcommand needs its own.
58
+ """
59
+ parent = argparse.ArgumentParser(add_help=False)
60
+ add(parent, suppress=True)
61
+ return parent
lighthouse/http.py ADDED
@@ -0,0 +1,355 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Talking to the control plane: retries, pagination, idempotency, refusals.
4
+
5
+ One class, `Client`, used by every command. It holds four behaviours that would
6
+ otherwise be written slightly differently in each of them:
7
+
8
+ **Retries.** A 429 or a 5xx is retried with exponential back-off and full
9
+ jitter, honouring `Retry-After` when the service sends one. A 4xx that is not
10
+ 429 is never retried: the request was wrong and sending it again will not make
11
+ it right. Nor is a write retried without an idempotency key, because a POST
12
+ that timed out may well have been applied.
13
+
14
+ **Idempotency.** Every write carries `Idempotency-Key`, a fresh UUID per
15
+ logical operation (not per attempt), so a retried deploy is the same deploy and
16
+ not a second one.
17
+
18
+ **Pagination.** The read API answers `{<rows>: [...], "next": "<cursor>"}` and
19
+ takes that cursor back as `?cursor=`. `each()` follows it and yields rows, and
20
+ `get_all()` does the same but returns one document with every row in it, so no
21
+ command has a `while True` of its own.
22
+
23
+ **Cloudflare Access.** Every tenant host is behind Cloudflare Access except the
24
+ OAuth endpoints. When a credential carries the tenant's Access service token,
25
+ `CF-Access-Client-Id` and `CF-Access-Client-Secret` go on every request (it is
26
+ harmless on the bypassed paths). If Access answers instead of the service, with
27
+ a redirect to `*.cloudflareaccess.com` or a login page, the CLI says the service
28
+ token is missing or wrong and names the two variables.
29
+
30
+ **Refusals.** The control plane answers `{error, message}` with a fixed code
31
+ (`docs/CONTRACT.md`). Those codes are mapped onto the CLI's exit codes
32
+ (exits.py) once, here: `role_required` becomes 4, an expired credential becomes
33
+ 3 after one attempt to refresh it, a rate limit becomes 5.
34
+
35
+ **Refresh.** A delegated token is short-lived (eight hours). The client
36
+ refreshes it before it lapses and once on a 401, then stores the new one in the
37
+ keychain. A credential that came from `LIGHTHOUSE_TOKEN` is never refreshed: in
38
+ a pipeline there is nowhere to put the new one, and silently holding one in
39
+ memory would hide that the variable needs rotating. A machine key (`aalcp-`)
40
+ does not expire by time, so it is the credential for a pipeline.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import logging
45
+ import random
46
+ import time
47
+ import uuid
48
+ from collections.abc import Iterator
49
+ from typing import Any
50
+
51
+ import httpx
52
+
53
+ from lighthouse import __version__
54
+ from lighthouse.credentials import ENV_ACCESS_ID, ENV_ACCESS_SECRET, ENV_TOKEN, Credential, Store
55
+ from lighthouse.exits import FORBIDDEN, RETRY, UNAUTHENTICATED, CliError, NotSignedIn
56
+ from lighthouse.i18n import t
57
+
58
+ log = logging.getLogger(__name__)
59
+
60
+ TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=60.0, pool=10.0)
61
+ # Pages walked at most, so a service that never stops sending a cursor cannot
62
+ # keep a pipeline busy for ever.
63
+ MAX_PAGES = 500
64
+ ATTEMPTS = 4
65
+ BACKOFF = 0.5
66
+ MAX_BACKOFF = 8.0
67
+ WRITES = ("POST", "PUT", "PATCH", "DELETE")
68
+
69
+ # A refusal code (docs/CONTRACT.md) -> the exit code it becomes. Anything not
70
+ # here is an ordinary failure, exit 1.
71
+ EXIT_FOR = {
72
+ "unauthenticated": UNAUTHENTICATED,
73
+ "invalid_token": UNAUTHENTICATED,
74
+ "mfa_required": UNAUTHENTICATED,
75
+ "role_required": FORBIDDEN,
76
+ "not_authorised": FORBIDDEN,
77
+ "scope_required": FORBIDDEN,
78
+ "rate_limited": RETRY,
79
+ "tier_b_unavailable": RETRY,
80
+ "tenant_key_missing": RETRY,
81
+ "records_deferred": RETRY,
82
+ "identity_unavailable": RETRY,
83
+ }
84
+
85
+
86
+ class Refused(CliError):
87
+ """The service refused. Carries the service's own code, so a script can
88
+ match on `error` and a person reads `message`."""
89
+
90
+ def __init__(self, status: int, body: dict[str, Any]) -> None:
91
+ code = str(body.get("error") or f"http_{status}")
92
+ hint = None
93
+ needed = body.get("required_scope")
94
+ if code == "scope_required" and isinstance(needed, str) and needed:
95
+ # The token was approved without the scope this command needs:
96
+ # say which, and how to get it, rather than leave a bare 403.
97
+ hint = t("hint.scope_required", scope=needed)
98
+ super().__init__(str(body.get("message") or t("err.service_answered", status=status)),
99
+ code=EXIT_FOR.get(code, 1), error=code, hint=hint, status=status)
100
+
101
+
102
+ def access_wall(reply: httpx.Response) -> bool:
103
+ """Whether Cloudflare Access, not the service, answered.
104
+
105
+ Access sends an unauthenticated API call to its login: a 302/303 to
106
+ `<team>.cloudflareaccess.com`, or a login page as HTML. The service answers
107
+ JSON, always, so an HTML 200, 401 or 403 is Access (or something that is not
108
+ LightHouse), never the control plane. A 5xx page from a proxy is left
109
+ alone: that is an outage, not a missing token.
110
+ """
111
+ if reply.status_code in (301, 302, 303, 307, 308):
112
+ return "cloudflareaccess.com" in reply.headers.get("Location", "").lower()
113
+ kind = reply.headers.get("Content-Type", "").lower()
114
+ if "json" in kind:
115
+ return False
116
+ if "html" in kind and reply.status_code in (200, 401, 403):
117
+ return True
118
+ head = reply.content[:2000].lower()
119
+ return b"cloudflareaccess.com" in head or b"cloudflare access" in head
120
+
121
+
122
+ class TokenRefused(NotSignedIn):
123
+ """A 401 on a credential from `LIGHTHOUSE_TOKEN`, said in its own terms."""
124
+
125
+ def __init__(self, message: str, hint: str) -> None:
126
+ CliError.__init__(self, message, code=UNAUTHENTICATED, error="unauthenticated",
127
+ hint=hint)
128
+
129
+
130
+ class AccessRefused(CliError):
131
+ def __init__(self, host: str, *, configured: bool) -> None:
132
+ state = t("err.access_state_wrong") if configured else t("err.access_state_missing")
133
+ super().__init__(
134
+ t("err.access_stopped", state=state),
135
+ code=UNAUTHENTICATED, error="access_service_token", host=host,
136
+ hint=t("hint.access_stopped", id=ENV_ACCESS_ID, secret=ENV_ACCESS_SECRET))
137
+
138
+
139
+ def sleep_for(attempt: int, retry_after: str | None) -> float:
140
+ """How long before the next attempt. `Retry-After` wins where the service
141
+ sent one; otherwise exponential with full jitter, so a fleet of runners
142
+ does not retry in step."""
143
+ if retry_after:
144
+ try:
145
+ return max(0.0, min(float(retry_after), 60.0))
146
+ except ValueError:
147
+ pass
148
+ # Full jitter, so a fleet of runners does not retry in step. Not a
149
+ # cryptographic choice: nothing here is a secret or a credential.
150
+ return random.uniform(0, min(MAX_BACKOFF, BACKOFF * (2 ** attempt))) # noqa: S311 # nosec B311
151
+
152
+
153
+ def retry_hint(status: int, headers: Any) -> str | None:
154
+ """The service's own word on when to send again: `Retry-After`, or on a
155
+ 429 that carries none, `RateLimit-Reset` (the seconds until this minute's
156
+ allowance is renewed)."""
157
+ hint = headers.get("Retry-After")
158
+ if not hint and status == 429:
159
+ hint = headers.get("RateLimit-Reset")
160
+ return str(hint) if hint else None
161
+
162
+
163
+ class Client:
164
+ def __init__(self, host: str, credential: Credential | None, *, store: Store | None = None,
165
+ transport: Any = None, sleep: Any = None, attempts: int = ATTEMPTS,
166
+ user_agent: str | None = None, timeout: float | None = None,
167
+ access: tuple[str, str] | None = None, session: str | None = None) -> None:
168
+ self.host = host
169
+ self.credential = credential
170
+ # A person's own Cloudflare Access session for this host, from
171
+ # `cloudflared access token` (`connect --credential-stdin`). Sent as
172
+ # `cf-access-token`, the header Access itself documents for a CLI, and
173
+ # never beside a delegated token: the request is the person's.
174
+ self._session = session
175
+ self.store = store
176
+ self.attempts = attempts
177
+ self._access = access
178
+ self._sleep = sleep or time.sleep
179
+ # `--timeout N` is seconds for one request: connect, read, write and
180
+ # pool alike. Unset, the defaults above stand.
181
+ self.timeout = httpx.Timeout(timeout) if timeout and timeout > 0 else TIMEOUT
182
+ self._http = httpx.Client(
183
+ base_url=f"https://{host}", timeout=self.timeout, transport=transport,
184
+ follow_redirects=False,
185
+ headers={"User-Agent": user_agent or f"lighthouse/{__version__}",
186
+ "Accept": "application/json"})
187
+
188
+ def close(self) -> None:
189
+ self._http.close()
190
+
191
+ def __enter__(self) -> Client:
192
+ return self
193
+
194
+ def __exit__(self, *_: Any) -> None:
195
+ self.close()
196
+
197
+ # -- credentials ----------------------------------------------------------------
198
+
199
+ def _access_pair(self) -> tuple[str, str] | None:
200
+ if self._access:
201
+ return self._access
202
+ return self.credential.access() if self.credential else None
203
+
204
+ def _headers(self, idempotency: str | None) -> dict[str, str]:
205
+ out = {}
206
+ pair = self._access_pair()
207
+ if pair:
208
+ out["CF-Access-Client-Id"], out["CF-Access-Client-Secret"] = pair
209
+ if self._session:
210
+ out["cf-access-token"] = self._session
211
+ elif self.credential and self.credential.access_token:
212
+ out["Authorization"] = f"Bearer {self.credential.access_token}"
213
+ if idempotency:
214
+ out["Idempotency-Key"] = idempotency
215
+ return out
216
+
217
+ def refresh(self) -> bool:
218
+ """Exchange the refresh token for a new pair. False if there is nothing
219
+ to refresh with, which is the normal case in CI."""
220
+ cred = self.credential
221
+ if cred is None or not cred.refresh_token or cred.source != "keychain":
222
+ return False
223
+ reply = self._http.post("/api/v1/oauth/token", headers=self._headers(None), json={
224
+ "grant_type": "refresh_token", "refresh_token": cred.refresh_token})
225
+ if reply.status_code != 200:
226
+ return False
227
+ token = reply.json()
228
+ self.credential = self.store.save(
229
+ self.host, token, email=cred.email, access_client_id=cred.access_client_id,
230
+ access_client_secret=cred.access_client_secret) \
231
+ if self.store is not None else cred
232
+ return True
233
+
234
+ # -- requests ---------------------------------------------------------------------
235
+
236
+ def request(self, method: str, path: str, *, params: dict[str, Any] | None = None,
237
+ json_body: Any = None, idempotency: str | None = None) -> Any:
238
+ """One logical request, retried where retrying is safe."""
239
+ method = method.upper()
240
+ if idempotency is None and method in WRITES:
241
+ idempotency = str(uuid.uuid4())
242
+ if self.credential is not None and self.credential.stale():
243
+ self.refresh()
244
+ refreshed = False
245
+ last: CliError | None = None
246
+ for attempt in range(self.attempts):
247
+ try:
248
+ reply = self._http.request(
249
+ method, path, params={k: v for k, v in (params or {}).items() if v is not None},
250
+ json=json_body, headers=self._headers(idempotency))
251
+ except httpx.TimeoutException as e:
252
+ last = CliError(t("err.timeout", method=method, path=path), code=RETRY, error="timeout")
253
+ log.debug("timeout: %s", e)
254
+ self._sleep(sleep_for(attempt, None))
255
+ continue
256
+ except httpx.TransportError as e:
257
+ raise CliError(t("err.unreachable", host=self.host, error=e), code=RETRY,
258
+ error="unreachable", hint=t("hint.unreachable")) from None
259
+ if access_wall(reply):
260
+ raise AccessRefused(self.host, configured=self._access_pair() is not None)
261
+ if reply.status_code == 401 and not refreshed and self.refresh():
262
+ refreshed = True
263
+ continue
264
+ if 200 <= reply.status_code < 300:
265
+ if not reply.content:
266
+ return {}
267
+ try:
268
+ return reply.json()
269
+ except ValueError:
270
+ raise CliError(t("err.not_json", method=method, path=path),
271
+ error="bad_response") from None
272
+ body = self._body(reply)
273
+ if reply.status_code == 429 or reply.status_code >= 500:
274
+ last = Refused(reply.status_code, body)
275
+ if attempt + 1 < self.attempts:
276
+ self._sleep(sleep_for(attempt, retry_hint(reply.status_code, reply.headers)))
277
+ continue
278
+ if reply.status_code == 401:
279
+ raise self._unauthenticated()
280
+ raise Refused(reply.status_code, body)
281
+ raise last or CliError(t("err.exhausted", method=method, path=path), code=RETRY,
282
+ error="exhausted")
283
+
284
+ def _unauthenticated(self) -> CliError:
285
+ """A 401, said in the terms of where the credential came from."""
286
+ cred = self.credential
287
+ if cred is None or cred.source != "environment":
288
+ return NotSignedIn(self.host)
289
+ if cred.kind == "machine":
290
+ return TokenRefused(t("err.machine_key_refused", env=ENV_TOKEN),
291
+ t("hint.machine_key_refused"))
292
+ return TokenRefused(t("err.delegated_token_refused", env=ENV_TOKEN),
293
+ t("hint.delegated_token_refused", env=ENV_TOKEN))
294
+
295
+ @staticmethod
296
+ def _body(reply: httpx.Response) -> dict[str, Any]:
297
+ try:
298
+ out = reply.json()
299
+ return out if isinstance(out, dict) else {"message": str(out)[:200]}
300
+ except ValueError:
301
+ return {"message": t("err.service_answered", status=reply.status_code)}
302
+
303
+ def get(self, path: str, **params: Any) -> Any:
304
+ return self.request("GET", path, params=params)
305
+
306
+ def post(self, path: str, body: Any = None, **params: Any) -> Any:
307
+ return self.request("POST", path, params=params, json_body=body)
308
+
309
+ def delete(self, path: str, **params: Any) -> Any:
310
+ return self.request("DELETE", path, params=params)
311
+
312
+ # -- pagination ---------------------------------------------------------------------
313
+
314
+ def each(self, path: str, key: str, *, limit: int | None = None,
315
+ **params: Any) -> Iterator[dict[str, Any]]:
316
+ """Every row of a cursor-paginated read, newest page first.
317
+
318
+ The read API answers `{<key>: [...], "next": "..."}` and takes that
319
+ value back as `?cursor=`; a page whose `next` is null is the last one.
320
+ `limit` caps the rows yielded, so `runs tail --limit 10` does not walk
321
+ a year of history to show ten lines.
322
+ """
323
+ seen = 0
324
+ cursor = None
325
+ visited: set[str] = set()
326
+ for _ in range(MAX_PAGES):
327
+ page = self.get(path, cursor=cursor, **params)
328
+ rows = page.get(key) or []
329
+ for row in rows:
330
+ yield row
331
+ seen += 1
332
+ if limit is not None and seen >= limit:
333
+ return
334
+ cursor = page.get("next")
335
+ if not cursor or not rows or cursor in visited:
336
+ return
337
+ visited.add(str(cursor))
338
+
339
+ def get_all(self, path: str, key: str, **params: Any) -> dict[str, Any]:
340
+ """The whole of a paginated read as one document: the first page, with
341
+ `key` holding every page's rows and `next` null. A release gate that
342
+ read only page one would pass on what it never saw."""
343
+ first = self.get(path, **params)
344
+ rows = list(first.get(key) or [])
345
+ cursor = first.get("next")
346
+ visited: set[str] = set()
347
+ for _ in range(MAX_PAGES):
348
+ if not cursor or cursor in visited:
349
+ break
350
+ visited.add(str(cursor))
351
+ page = self.get(path, cursor=cursor, **params)
352
+ more = page.get(key) or []
353
+ rows.extend(more)
354
+ cursor = page.get("next") if more else None
355
+ return {**first, key: rows, "next": None}