lighthouse-cli 0.16.0__tar.gz → 0.17.0__tar.gz

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 (71) hide show
  1. {lighthouse_cli-0.16.0/lighthouse_cli.egg-info → lighthouse_cli-0.17.0}/PKG-INFO +1 -3
  2. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/README.md +15 -17
  3. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/__init__.py +1 -1
  4. lighthouse_cli-0.17.0/lighthouse/commands/offline.py +176 -0
  5. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/runs.py +44 -0
  6. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/http.py +23 -3
  7. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/locales/ar.json +23 -1
  8. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/locales/de.json +23 -1
  9. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/locales/en.json +23 -1
  10. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/locales/es.json +23 -1
  11. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/locales/fr.json +23 -1
  12. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/locales/ga.json +23 -1
  13. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/locales/it.json +23 -1
  14. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/locales/ja.json +23 -1
  15. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/locales/nl.json +23 -1
  16. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/locales/pt.json +23 -1
  17. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/locales/zh-Hans.json +23 -1
  18. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/locales/zh-Hant.json +23 -1
  19. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/main.py +2 -1
  20. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0/lighthouse_cli.egg-info}/PKG-INFO +1 -3
  21. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse_cli.egg-info/SOURCES.txt +2 -0
  22. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse_cli.egg-info/requires.txt +0 -3
  23. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/pyproject.toml +2 -4
  24. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_commands.py +52 -0
  25. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_golden.py +4 -0
  26. lighthouse_cli-0.17.0/tests/test_offline.py +125 -0
  27. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/LICENSE +0 -0
  28. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/NOTICE +0 -0
  29. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/__main__.py +0 -0
  30. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/__init__.py +0 -0
  31. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/agents.py +0 -0
  32. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/builds.py +0 -0
  33. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/collectors.py +0 -0
  34. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/connect.py +0 -0
  35. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/connections.py +0 -0
  36. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/evals.py +0 -0
  37. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/kb.py +0 -0
  38. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/language.py +0 -0
  39. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/limits.py +0 -0
  40. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/login.py +0 -0
  41. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/models.py +0 -0
  42. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/spend.py +0 -0
  43. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/stack.py +0 -0
  44. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/terms.py +0 -0
  45. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/commands/whoami.py +0 -0
  46. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/config.py +0 -0
  47. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/credentials.py +0 -0
  48. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/device.py +0 -0
  49. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/exits.py +0 -0
  50. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/flags.py +0 -0
  51. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/i18n.py +0 -0
  52. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/out.py +0 -0
  53. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/py.typed +0 -0
  54. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse/sealing.py +0 -0
  55. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse_cli.egg-info/dependency_links.txt +0 -0
  56. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse_cli.egg-info/entry_points.txt +0 -0
  57. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/lighthouse_cli.egg-info/top_level.txt +0 -0
  58. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/setup.cfg +0 -0
  59. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_ci_credential.py +0 -0
  60. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_collectors.py +0 -0
  61. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_connect_erp_hr.py +0 -0
  62. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_connect_finav.py +0 -0
  63. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_connect_library.py +0 -0
  64. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_dev_surface.py +0 -0
  65. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_fixtures.py +0 -0
  66. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_i18n.py +0 -0
  67. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_installers.py +0 -0
  68. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_plumbing.py +0 -0
  69. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_sealing.py +0 -0
  70. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_stack.py +0 -0
  71. {lighthouse_cli-0.16.0 → lighthouse_cli-0.17.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lighthouse-cli
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: The LightHouse command line: sign in, connect your estate, deploy agents, watch runs
5
5
  License-Expression: Apache-2.0
6
6
  Classifier: Programming Language :: Python :: 3
@@ -18,6 +18,4 @@ Requires-Dist: ruff==0.16.10; extra == "dev"
18
18
  Requires-Dist: mypy==2.4.0; extra == "dev"
19
19
  Requires-Dist: bandit[toml]==1.9.4; extra == "dev"
20
20
  Requires-Dist: jsonschema==4.25.1; extra == "dev"
21
- Provides-Extra: build
22
- Requires-Dist: pyinstaller==6.22.3; extra == "build"
23
21
  Dynamic: license-file
@@ -23,25 +23,20 @@ evidence:
23
23
  | **The control plane is Python.** The service, its OpenAPI generator and its MCP server are all `aal_control`. The CLI's integration tests therefore run **in process against the very application that is deployed** (`service/tests/test_cli_integration.py`) — the real routes, the real OAuth, the real row-level security, no stack to boot and no stub to drift. No other choice gets us that. | decisive |
24
24
  | **No new toolchain in any pipeline.** Every job already runs `python:3.13-slim`; a Go CLI would add a compiler, a cross-build matrix and a second dependency-audit path to all 21 repositories. | heavy |
25
25
  | **`keyring` gives the OS keychain on all three platforms** — macOS Keychain, Secret Service, Windows Credential Locker — with no native code to sign or notarise. | heavy |
26
- | Against: `npm` and `curl` want a runtime-free binary. | the cost |
26
+ | Against: a runtime-free binary. | the cost |
27
27
 
28
- That last line is the real cost, and it is paid rather than hidden: `pip` and
29
- `brew` install from source, while the **npm package and the curl script serve a
30
- single-file build** made with PyInstaller (`installers/`), which embeds the
31
- interpreter. A person installing by `curl | sh` needs no Python; a person
32
- installing by `pip` has one already.
33
-
34
- If the npm and curl routes ever become the main way people install this, the
35
- decision is worth revisiting — the commands, their output and their tests are
36
- the valuable part and none of it is language-specific.
28
+ That last line is the real cost, and it is paid rather than hidden: the CLI
29
+ installs from source as a package, so it needs Python (`pipx` brings its own
30
+ isolated environment; Homebrew brings its own virtualenv). There is no
31
+ stand-alone binary: the stand-alone installer was retired on 11 November 2026
32
+ and the CLI ships as packages only.
37
33
 
38
34
  ## Install
39
35
 
40
- | Route | Command | What it installs |
36
+ | Route | Command | Platforms |
41
37
  |---|---|---|
42
- | Homebrew | `brew install atlantic-agentic-labs/tap/lighthouse` | from source, into its own virtualenv (`installers/lighthouse.rb`) |
43
- | pip | `pip install lighthouse-cli` | from source, as a normal console script |
44
- | curl | `curl -fsSL https://get.atlanticagenticlabs.com/lighthouse \| sh` | the single-file build (`installers/install.sh`) |
38
+ | pipx or pip | `pipx install lighthouse-cli` (or `pip install lighthouse-cli`) | Linux, macOS, Windows |
39
+ | Homebrew | `brew install atlantic-agentic-labs/tap/lighthouse` | macOS, Linux (`installers/lighthouse.rb`) |
45
40
 
46
41
  **Nothing is published yet.** From a clone:
47
42
 
@@ -49,9 +44,9 @@ the valuable part and none of it is language-specific.
49
44
  cd cli && pip install -e ".[dev]"
50
45
  ```
51
46
 
52
- What has to exist before any of the three routes work is in
53
- `installers/README.md`: a Homebrew tap, a PyPI project and one DNS name. No
54
- registry credential is stored for any of them.
47
+ What has to exist before either route works is in `installers/README.md`: a
48
+ PyPI project with a trusted publisher and a Homebrew tap. No registry
49
+ credential is stored for PyPI.
55
50
 
56
51
  ## Signing in
57
52
 
@@ -158,6 +153,9 @@ a terminal, and when `CI`, `NO_COLOR` or `TERM=dumb` is set.
158
153
  | `evals list`, `evals list --fail-below N` | evaluation results, as a release gate on one named metric (`--metric`, default `f1`); fails closed when a row has no such number |
159
154
  | `evals run <pack:card> --cap N [--set S] [--wait] [--fail-below N]` | run the agent's scheduled evaluation once under a spend cap (the `deploy` scope, or a person); with `--wait`, gate on the new results |
160
155
  | `runs`, `runs show`, `runs tail` | what ran, and what is running now |
156
+ | `runs export <run> [--format json\|pdf] [-o file]` | one run's content-free export, as JSON or a branded PDF (contract 1.32); audited |
157
+ | `telemetry import <bundle>`, `telemetry status --deployment D` | upload a telemetry bundle from a deployment with no network path, and see the batches held and missing (contract 1.33; client_admin or the `offline` scope) |
158
+ | `commands export --deployment D --out <file>` | the waiting commands as a signed command file for the deployment's `apply-file` (contract 1.33); audited |
161
159
  | `spend` | spend by day, model, pack, agent or connection; the forecast; the estimated savings |
162
160
  | `limits` | the limits in force, and whether the engines applied them |
163
161
  | `terms` | the commercial terms in force |
@@ -3,4 +3,4 @@
3
3
  """The `lighthouse` command line for LightHouse."""
4
4
  from __future__ import annotations
5
5
 
6
- __version__ = "0.16.0"
6
+ __version__ = "0.17.0"
@@ -0,0 +1,176 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse telemetry` and `lighthouse commands`: a deployment with no
4
+ network path (contract 1.33).
5
+
6
+ lighthouse telemetry import <bundle.tar.gz> [--deployment D]
7
+ lighthouse telemetry status --deployment D
8
+ lighthouse commands export --deployment D --out commands.json
9
+
10
+ A deployment that cannot reach LightHouse writes its telemetry into signed
11
+ bundle files (`python -m aal_engine.telemetry export` on the deployment's
12
+ side). `telemetry import` uploads one, as it was carried: LightHouse checks
13
+ its signatures, its manifest and its sequence, and stores what is new
14
+ through the same intake as live telemetry. The deployment is read from the
15
+ bundle's own manifest unless `--deployment` says otherwise; the service
16
+ refuses a bundle that names another. `telemetry status` shows the last batch
17
+ number held for each epoch, which is the `--since` for the next export, and
18
+ the numbers missing.
19
+
20
+ `commands export` writes every command waiting for the deployment into a
21
+ file of signed frames, for `python -m aal_engine.handler apply-file` on the
22
+ other side. The commands are marked delivered when the file is written, so
23
+ the same command is never exported twice.
24
+
25
+ Each needs a client administrator: at the keyboard, or through a token with
26
+ the `offline` scope. Each is audited by the service.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import io
32
+ import json
33
+ import os
34
+ import tarfile
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ from lighthouse.exits import FAILED, USAGE, CliError
39
+ from lighthouse.flags import inherited
40
+ from lighthouse.main import Command, Context
41
+ from lighthouse.out import Out
42
+
43
+ # The largest upload the service takes (telemetry_bundles.UPLOAD_MAX).
44
+ UPLOAD_MAX = 48 * 1024 * 1024
45
+
46
+
47
+ def _telemetry_configure(ap: argparse.ArgumentParser) -> None:
48
+ sub = ap.add_subparsers(dest="telemetry_command", metavar="<subcommand>")
49
+ imp = sub.add_parser("import", parents=[inherited()],
50
+ help="upload a telemetry bundle from an offline deployment")
51
+ imp.add_argument("file", help="the bundle (.tar.gz), as the deployment wrote it")
52
+ imp.add_argument("--deployment", help="the deployment (default: the one the bundle names)")
53
+ status = sub.add_parser("status", parents=[inherited()],
54
+ help="the batches held for a deployment, and any missing")
55
+ status.add_argument("--deployment", required=True, help="the deployment")
56
+
57
+
58
+ def _commands_configure(ap: argparse.ArgumentParser) -> None:
59
+ sub = ap.add_subparsers(dest="commands_command", metavar="<subcommand>")
60
+ ex = sub.add_parser("export", parents=[inherited()],
61
+ help="write the waiting commands into a signed command file")
62
+ ex.add_argument("--deployment", required=True, help="the deployment")
63
+ ex.add_argument("--out", required=True, help="the file to write (- for standard output)")
64
+ ex.add_argument("--force", action="store_true", help="overwrite the file if it exists")
65
+
66
+
67
+ def bundle_deployment(data: bytes) -> str | None:
68
+ """The deployment a bundle's manifest names (its part after the tenant),
69
+ read locally so the right route is called. None if it cannot be read: the
70
+ service, which verifies the whole bundle, has the last word either way."""
71
+ try:
72
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
73
+ member = tar.getmember("manifest.json")
74
+ if not member.isfile() or member.size > 4 * 1024 * 1024:
75
+ return None
76
+ fh = tar.extractfile(member)
77
+ manifest = json.loads(fh.read() if fh is not None else b"")
78
+ except (tarfile.TarError, OSError, EOFError, KeyError, ValueError):
79
+ return None
80
+ named = manifest.get("deployment") if isinstance(manifest, dict) else None
81
+ if not isinstance(named, str) or "/" not in named:
82
+ return None
83
+ return named.split("/", 1)[1] or None
84
+
85
+
86
+ def telemetry(ctx: Context) -> dict[str, Any]:
87
+ which = getattr(ctx.args, "telemetry_command", None)
88
+ if which == "status":
89
+ body = ctx.client().get(f"/api/v1/deployments/{ctx.args.deployment}/telemetry-bundles")
90
+ return {"ok": True, **body}
91
+ if which != "import":
92
+ raise CliError("say what to do: `lighthouse telemetry import <file>` or "
93
+ "`lighthouse telemetry status --deployment <name>`", code=USAGE,
94
+ error="usage")
95
+ path = Path(ctx.args.file).expanduser()
96
+ try:
97
+ size = path.stat().st_size
98
+ if size > UPLOAD_MAX:
99
+ raise CliError(f"{path} is over the 48 MB a bundle may be", error="too_large",
100
+ hint="export again with a smaller --max-bytes")
101
+ data = path.read_bytes()
102
+ except OSError as e:
103
+ raise CliError(f"{path} could not be read: {e.strerror or e}", code=USAGE,
104
+ error="unreadable") from None
105
+ deployment = ctx.args.deployment or bundle_deployment(data)
106
+ if not deployment:
107
+ raise CliError(f"{path} names no deployment that can be read", code=USAGE,
108
+ error="usage", hint="pass --deployment <name>")
109
+ body = ctx.client().post_bytes(f"/api/v1/deployments/{deployment}/telemetry-bundles",
110
+ data, "application/gzip")
111
+ return {"ok": True, "file": str(path), **body}
112
+
113
+
114
+ def commands_export(ctx: Context) -> dict[str, Any]:
115
+ if getattr(ctx.args, "commands_command", None) != "export":
116
+ raise CliError("say what to do: `lighthouse commands export --deployment <name> "
117
+ "--out <file>`", code=USAGE, error="usage")
118
+ out = Path(ctx.args.out).expanduser()
119
+ if ctx.args.out != "-" and out.exists() and not ctx.args.force:
120
+ raise CliError(f"{out} already exists", code=USAGE, error="exists",
121
+ hint="pass --force to overwrite it, or choose another --out")
122
+ doc = ctx.client().post(f"/api/v1/deployments/{ctx.args.deployment}/command-file")
123
+ if not isinstance(doc, dict) or not isinstance(doc.get("commands"), list):
124
+ raise CliError("the service did not answer with a command file", code=FAILED,
125
+ error="bad_response")
126
+ if ctx.args.out == "-":
127
+ ctx.out.streamed = True
128
+ ctx.out.stdout.write(json.dumps(doc, indent=2, sort_keys=True) + "\n")
129
+ return {"ok": True, "streamed": True}
130
+ fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
131
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
132
+ fh.write(json.dumps(doc, indent=2, sort_keys=True) + "\n")
133
+ return {"ok": True, "deployment": doc.get("deployment"), "path": str(out),
134
+ "commands": len(doc["commands"]),
135
+ "ids": [c.get("id") for c in doc["commands"] if isinstance(c, dict)]}
136
+
137
+
138
+ def _telemetry_show(out: Out, body: dict[str, Any]) -> None:
139
+ if "imports" in body:
140
+ seq = body.get("sequence") or []
141
+ if not seq:
142
+ out.say("no bundle imported yet: export with --since 0")
143
+ return
144
+ out.table(["epoch", "held through", "highest", "missing"],
145
+ [[s["epoch"][:8], s["held_through"], s["highest_seq"], s["missing"]]
146
+ for s in seq])
147
+ return
148
+ b, r, s = body["batches"], body["records"], body["sequence"]
149
+ out.done(f"imported batches {body['first_seq']} to {body['last_seq']} for "
150
+ f"{body['deployment']}: {b['imported']} new, {b['duplicate']} already held")
151
+ out.pairs([("records stored", r["stored"]), ("records already held", r["duplicate"]),
152
+ ("spans", body["spans"]), ("held through", s["held_through"])])
153
+ for item in body.get("refused") or []:
154
+ out.warn(f"batch {item['seq']} not imported: {item['error']}")
155
+ if s.get("gaps"):
156
+ out.warn("missing batches: " + ", ".join(
157
+ str(a) if a == z else f"{a}-{z}" for a, z in s["gaps"]))
158
+ out.note(f"next export: --since {s['held_through']}")
159
+
160
+
161
+ def _commands_show(out: Out, body: dict[str, Any]) -> None:
162
+ if body.get("streamed"):
163
+ return
164
+ n = body["commands"]
165
+ out.done(f"{n} command{'' if n == 1 else 's'} written to {body['path']}")
166
+ if n:
167
+ out.note("apply it on the deployment: python -m aal_engine.handler apply-file "
168
+ f"{Path(body['path']).name}")
169
+
170
+
171
+ COMMANDS = (
172
+ Command("telemetry", "telemetry bundles from a deployment with no network path",
173
+ _telemetry_configure, telemetry, _telemetry_show),
174
+ Command("commands", "command files for a deployment with no network path",
175
+ _commands_configure, commands_export, _commands_show),
176
+ )
@@ -6,6 +6,7 @@
6
6
  lighthouse runs tail # live, one line per change
7
7
  lighthouse runs tail --once --json # one snapshot, for a pipeline
8
8
  lighthouse runs show <run_id_hash>
9
+ lighthouse runs export <run_id_hash> [--format json|pdf] [-o file]
9
10
 
10
11
  `tail` follows `GET /api/v1/runs/active` (contract 1.12): the live runs, each
11
12
  with its current step, its tokens so far and its estimated cost. It polls at
@@ -22,12 +23,21 @@ step wants. Both are covered by the golden test.
22
23
  `--fail-on-stall` makes it exit 1 if a run has not been seen for
23
24
  `stall_after_s`, which is the useful thing to put in a smoke job after a
24
25
  deploy.
26
+
27
+ `export` (contract 1.32) writes one run's content-free export, the run map as
28
+ JSON or as a PDF carrying the organisation's branding, to `-o` (by default
29
+ `run-<first 12 of the hash>.<format>` here; `-o -` is standard output). The
30
+ file is written exactly as the service sent it, and the service records the
31
+ export in the organisation's audit log. A machine key may export, so a
32
+ pipeline can attach a run's record to its own report.
25
33
  """
26
34
  from __future__ import annotations
27
35
 
28
36
  import argparse
29
37
  import json
38
+ import re
30
39
  import time
40
+ from pathlib import Path
31
41
  from typing import Any
32
42
 
33
43
  from lighthouse.exits import FAILED, CliError
@@ -46,6 +56,13 @@ def _configure(ap: argparse.ArgumentParser) -> None:
46
56
  show.add_argument("run", help="its run_id_hash")
47
57
  show.add_argument("--map", action="store_true", dest="as_map",
48
58
  help="the run map: every step, model call and retrieval")
59
+ export = sub.add_parser("export", parents=[inherited()],
60
+ help="one run's content-free export, as JSON or PDF")
61
+ export.add_argument("run", help="its run_id_hash")
62
+ export.add_argument("--format", choices=("json", "pdf"), default="json", dest="export_format",
63
+ help="json (the default) or pdf")
64
+ export.add_argument("-o", "--output", dest="output", default=None,
65
+ help="the file to write (default run-<hash>.<format>; - for standard output)")
49
66
  tail = sub.add_parser("tail", parents=[inherited()], help="follow the live runs")
50
67
  tail.add_argument("--interval", type=float, default=5.0, help="seconds between polls")
51
68
  tail.add_argument("--once", action="store_true", help="one snapshot, then stop")
@@ -119,10 +136,37 @@ def tail(ctx: Context) -> dict[str, Any]:
119
136
  "live": last.get("live"), "runs": last.get("runs") or [], "stalled": stalled}
120
137
 
121
138
 
139
+ def export(ctx: Context) -> dict[str, Any]:
140
+ """Fetch the export and write it where asked. The bytes are the service's
141
+ own: nothing is re-encoded, so the file is the one the audit log names."""
142
+ run, fmt = str(ctx.args.run), ctx.args.export_format
143
+ body = ctx.client().get_bytes(f"/api/v1/runs/{run}/export", format=fmt)
144
+ target = ctx.args.output or f"run-{re.sub(r'[^A-Za-z0-9_-]', '', run)[:12]}.{fmt}"
145
+ if target == "-":
146
+ ctx.out.streamed = True
147
+ if fmt == "json":
148
+ ctx.out.stdout.write(body.decode("utf-8"))
149
+ else:
150
+ buffer = getattr(ctx.out.stdout, "buffer", None)
151
+ if buffer is None:
152
+ raise CliError("standard output cannot take a PDF here; use -o <file>",
153
+ error="not_binary")
154
+ buffer.write(body)
155
+ buffer.flush()
156
+ return {"ok": True, "streamed": True}
157
+ path = Path(target).expanduser()
158
+ if path.is_dir():
159
+ path = path / f"run-{re.sub(r'[^A-Za-z0-9_-]', '', run)[:12]}.{fmt}"
160
+ path.write_bytes(body)
161
+ return {"ok": True, "run_id_hash": run, "format": fmt, "path": str(path), "bytes": len(body)}
162
+
163
+
122
164
  def runs(ctx: Context) -> dict[str, Any]:
123
165
  which = getattr(ctx.args, "runs_command", None) or "list"
124
166
  if which == "tail":
125
167
  return tail(ctx)
168
+ if which == "export":
169
+ return export(ctx)
126
170
  client = ctx.client()
127
171
  if which == "show":
128
172
  path = f"/api/v1/runs/{ctx.args.run}"
@@ -234,8 +234,12 @@ class Client:
234
234
  # -- requests ---------------------------------------------------------------------
235
235
 
236
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."""
237
+ json_body: Any = None, idempotency: str | None = None, raw: bool = False,
238
+ content: bytes | None = None, content_type: str | None = None) -> Any:
239
+ """One logical request, retried where retrying is safe. With `raw`, a
240
+ success is returned as the body's bytes, as sent (a file: `runs export`).
241
+ `content` is a body sent as it is, with its `content_type` (a file:
242
+ `telemetry import`), in place of a JSON body."""
239
243
  method = method.upper()
240
244
  if idempotency is None and method in WRITES:
241
245
  idempotency = str(uuid.uuid4())
@@ -245,9 +249,13 @@ class Client:
245
249
  last: CliError | None = None
246
250
  for attempt in range(self.attempts):
247
251
  try:
252
+ headers = self._headers(idempotency)
253
+ if content is not None:
254
+ headers["Content-Type"] = content_type or "application/octet-stream"
248
255
  reply = self._http.request(
249
256
  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))
257
+ json=json_body if content is None else None, content=content,
258
+ headers=headers)
251
259
  except httpx.TimeoutException as e:
252
260
  last = CliError(t("err.timeout", method=method, path=path), code=RETRY, error="timeout")
253
261
  log.debug("timeout: %s", e)
@@ -262,6 +270,8 @@ class Client:
262
270
  refreshed = True
263
271
  continue
264
272
  if 200 <= reply.status_code < 300:
273
+ if raw:
274
+ return reply.content
265
275
  if not reply.content:
266
276
  return {}
267
277
  try:
@@ -303,9 +313,19 @@ class Client:
303
313
  def get(self, path: str, **params: Any) -> Any:
304
314
  return self.request("GET", path, params=params)
305
315
 
316
+ def get_bytes(self, path: str, **params: Any) -> bytes:
317
+ """A GET whose answer is a file, returned exactly as the service sent it."""
318
+ body: bytes = self.request("GET", path, params=params, raw=True)
319
+ return body
320
+
306
321
  def post(self, path: str, body: Any = None, **params: Any) -> Any:
307
322
  return self.request("POST", path, params=params, json_body=body)
308
323
 
324
+ def post_bytes(self, path: str, content: bytes, content_type: str, **params: Any) -> Any:
325
+ """A POST whose body is a file, sent exactly as read."""
326
+ return self.request("POST", path, params=params, content=content,
327
+ content_type=content_type)
328
+
309
329
  def delete(self, path: str, **params: Any) -> Any:
310
330
  return self.request("DELETE", path, params=params)
311
331
 
@@ -209,6 +209,10 @@
209
209
  "help.runs.tail.duration": "توقف بعد هذا العدد من الثواني",
210
210
  "help.runs.tail.deployment": "تشغيلات عملية النشر هذه فقط",
211
211
  "help.runs.tail.fail_on_stall": "اخرج بالرمز 1 إذا تعثّر تشغيل",
212
+ "help.runs.export._help": "تصدير خالٍ من المحتوى لتشغيل واحد، بصيغة JSON أو PDF",
213
+ "help.runs.export.run": "قيمة run_id_hash الخاصة به",
214
+ "help.runs.export.export_format": "json (الافتراضي) أو pdf",
215
+ "help.runs.export.output": "الملف المراد كتابته (الافتراضي run-<hash>.<format>؛ - للإخراج القياسي)",
212
216
  "help.spend.by": "كيفية التجميع",
213
217
  "help.spend.month": "YYYY-MM؛ الشهر الحالي افتراضياً",
214
218
  "help.spend.forecast": "وإسقاط نهاية الشهر مقابل الحدّ الأقصى أيضاً",
@@ -339,5 +343,23 @@
339
343
  "label.evals": "التقييمات",
340
344
  "label.calls_month": "الاستدعاءات (الشهر)",
341
345
  "label.input_m": "الإدخال /M",
342
- "label.output_m": "الإخراج /M"
346
+ "label.output_m": "الإخراج /M",
347
+ "help.telemetry._help": "حزم القياس عن بُعد من نشر بلا مسار شبكي",
348
+ "help.commands._help": "ملفات الأوامر لنشر بلا مسار شبكي",
349
+ "help.telemetry.import._help": "رفع حزمة قياس عن بُعد من نشر غير متصل",
350
+ "help.telemetry.status._help": "الدفعات المحفوظة لنشر ما، والدفعات المفقودة",
351
+ "help.telemetry.import.file": "الحزمة (.tar.gz) كما كتبها النشر",
352
+ "help.telemetry.import.deployment": "النشر (الافتراضي: النشر المذكور في الحزمة)",
353
+ "help.telemetry.status.deployment": "النشر",
354
+ "help.commands.export._help": "كتابة الأوامر المنتظرة في ملف أوامر موقّع",
355
+ "help.commands.export.deployment": "النشر",
356
+ "help.commands.export.out": "الملف المراد كتابته (- للإخراج القياسي)",
357
+ "help.commands.export.force": "استبدال الملف إن وُجد",
358
+ "label.epoch": "الحقبة",
359
+ "label.held_through": "مكتمل حتى",
360
+ "label.highest": "الأعلى",
361
+ "label.missing": "المفقود",
362
+ "label.records_stored": "السجلات المخزنة",
363
+ "label.records_already_held": "السجلات المحفوظة مسبقًا",
364
+ "label.spans": "الامتدادات"
343
365
  }
@@ -209,6 +209,10 @@
209
209
  "help.runs.tail.duration": "nach so vielen Sekunden beenden",
210
210
  "help.runs.tail.deployment": "nur die Läufe dieser Bereitstellung",
211
211
  "help.runs.tail.fail_on_stall": "mit Exit 1 beenden, wenn ein Lauf festhängt",
212
+ "help.runs.export._help": "der inhaltsfreie Export eines Laufs, als JSON oder PDF",
213
+ "help.runs.export.run": "sein run_id_hash",
214
+ "help.runs.export.export_format": "json (Standard) oder pdf",
215
+ "help.runs.export.output": "die zu schreibende Datei (Standard run-<hash>.<format>; - für die Standardausgabe)",
212
216
  "help.spend.by": "wie gruppiert wird",
213
217
  "help.spend.month": "JJJJ-MM; standardmäßig der aktuelle Monat",
214
218
  "help.spend.forecast": "auch die Monatsend-Prognose gegenüber dem Limit",
@@ -339,5 +343,23 @@
339
343
  "label.evals": "Evaluierungen",
340
344
  "label.calls_month": "Aufrufe (Monat)",
341
345
  "label.input_m": "Eingabe /M",
342
- "label.output_m": "Ausgabe /M"
346
+ "label.output_m": "Ausgabe /M",
347
+ "help.telemetry._help": "Telemetriepakete einer Bereitstellung ohne Netzwerkpfad",
348
+ "help.commands._help": "Befehlsdateien für eine Bereitstellung ohne Netzwerkpfad",
349
+ "help.telemetry.import._help": "ein Telemetriepaket einer Offline-Bereitstellung hochladen",
350
+ "help.telemetry.status._help": "die für eine Bereitstellung gehaltenen Pakete und fehlende",
351
+ "help.telemetry.import.file": "das Paket (.tar.gz), wie die Bereitstellung es geschrieben hat",
352
+ "help.telemetry.import.deployment": "die Bereitstellung (Standard: die im Paket genannte)",
353
+ "help.telemetry.status.deployment": "die Bereitstellung",
354
+ "help.commands.export._help": "die wartenden Befehle in eine signierte Befehlsdatei schreiben",
355
+ "help.commands.export.deployment": "die Bereitstellung",
356
+ "help.commands.export.out": "die zu schreibende Datei (- für die Standardausgabe)",
357
+ "help.commands.export.force": "die Datei überschreiben, falls vorhanden",
358
+ "label.epoch": "Epoche",
359
+ "label.held_through": "lückenlos bis",
360
+ "label.highest": "höchste",
361
+ "label.missing": "fehlend",
362
+ "label.records_stored": "gespeicherte Datensätze",
363
+ "label.records_already_held": "bereits gehaltene Datensätze",
364
+ "label.spans": "Spans"
343
365
  }
@@ -209,6 +209,10 @@
209
209
  "help.runs.tail.duration": "stop after this many seconds",
210
210
  "help.runs.tail.deployment": "only this deployment's runs",
211
211
  "help.runs.tail.fail_on_stall": "exit 1 if a run has stalled",
212
+ "help.runs.export._help": "one run's content-free export, as JSON or PDF",
213
+ "help.runs.export.run": "its run_id_hash",
214
+ "help.runs.export.export_format": "json (the default) or pdf",
215
+ "help.runs.export.output": "the file to write (default run-<hash>.<format>; - for standard output)",
212
216
  "help.spend.by": "how to group",
213
217
  "help.spend.month": "YYYY-MM; the current month by default",
214
218
  "help.spend.forecast": "the month-end projection against the cap as well",
@@ -339,5 +343,23 @@
339
343
  "label.evals": "evals",
340
344
  "label.calls_month": "calls (month)",
341
345
  "label.input_m": "input /M",
342
- "label.output_m": "output /M"
346
+ "label.output_m": "output /M",
347
+ "help.telemetry._help": "telemetry bundles from a deployment with no network path",
348
+ "help.commands._help": "command files for a deployment with no network path",
349
+ "help.telemetry.import._help": "upload a telemetry bundle from an offline deployment",
350
+ "help.telemetry.status._help": "the batches held for a deployment, and any missing",
351
+ "help.telemetry.import.file": "the bundle (.tar.gz), as the deployment wrote it",
352
+ "help.telemetry.import.deployment": "the deployment (default: the one the bundle names)",
353
+ "help.telemetry.status.deployment": "the deployment",
354
+ "help.commands.export._help": "write the waiting commands into a signed command file",
355
+ "help.commands.export.deployment": "the deployment",
356
+ "help.commands.export.out": "the file to write (- for standard output)",
357
+ "help.commands.export.force": "overwrite the file if it exists",
358
+ "label.epoch": "epoch",
359
+ "label.held_through": "held through",
360
+ "label.highest": "highest",
361
+ "label.missing": "missing",
362
+ "label.records_stored": "records stored",
363
+ "label.records_already_held": "records already held",
364
+ "label.spans": "spans"
343
365
  }
@@ -209,6 +209,10 @@
209
209
  "help.runs.tail.duration": "parar tras este número de segundos",
210
210
  "help.runs.tail.deployment": "solo las ejecuciones de este despliegue",
211
211
  "help.runs.tail.fail_on_stall": "salir con 1 si una ejecución se ha atascado",
212
+ "help.runs.export._help": "la exportación sin contenido de una ejecución, en JSON o PDF",
213
+ "help.runs.export.run": "su run_id_hash",
214
+ "help.runs.export.export_format": "json (por defecto) o pdf",
215
+ "help.runs.export.output": "el archivo que se escribe (por defecto run-<hash>.<format>; - para la salida estándar)",
212
216
  "help.spend.by": "cómo agrupar",
213
217
  "help.spend.month": "AAAA-MM; por defecto, el mes actual",
214
218
  "help.spend.forecast": "también la proyección de fin de mes frente al límite",
@@ -339,5 +343,23 @@
339
343
  "label.evals": "evaluaciones",
340
344
  "label.calls_month": "llamadas (mes)",
341
345
  "label.input_m": "entrada /M",
342
- "label.output_m": "salida /M"
346
+ "label.output_m": "salida /M",
347
+ "help.telemetry._help": "paquetes de telemetría de un despliegue sin ruta de red",
348
+ "help.commands._help": "archivos de comandos para un despliegue sin ruta de red",
349
+ "help.telemetry.import._help": "subir un paquete de telemetría de un despliegue sin conexión",
350
+ "help.telemetry.status._help": "los lotes conservados para un despliegue, y los que faltan",
351
+ "help.telemetry.import.file": "el paquete (.tar.gz), tal como lo escribió el despliegue",
352
+ "help.telemetry.import.deployment": "el despliegue (por defecto: el que nombra el paquete)",
353
+ "help.telemetry.status.deployment": "el despliegue",
354
+ "help.commands.export._help": "escribir los comandos pendientes en un archivo de comandos firmado",
355
+ "help.commands.export.deployment": "el despliegue",
356
+ "help.commands.export.out": "el archivo que se escribe (- para la salida estándar)",
357
+ "help.commands.export.force": "sobrescribir el archivo si existe",
358
+ "label.epoch": "época",
359
+ "label.held_through": "completo hasta",
360
+ "label.highest": "más alto",
361
+ "label.missing": "faltan",
362
+ "label.records_stored": "registros almacenados",
363
+ "label.records_already_held": "registros ya conservados",
364
+ "label.spans": "spans"
343
365
  }
@@ -209,6 +209,10 @@
209
209
  "help.runs.tail.duration": "s'arrêter après ce nombre de secondes",
210
210
  "help.runs.tail.deployment": "uniquement les exécutions de ce déploiement",
211
211
  "help.runs.tail.fail_on_stall": "quitter avec le code 1 si une exécution est bloquée",
212
+ "help.runs.export._help": "l'export sans contenu d'une exécution, en JSON ou en PDF",
213
+ "help.runs.export.run": "son run_id_hash",
214
+ "help.runs.export.export_format": "json (par défaut) ou pdf",
215
+ "help.runs.export.output": "le fichier à écrire (par défaut run-<hash>.<format> ; - pour la sortie standard)",
212
216
  "help.spend.by": "comment regrouper",
213
217
  "help.spend.month": "AAAA-MM ; le mois en cours par défaut",
214
218
  "help.spend.forecast": "aussi la projection de fin de mois par rapport au plafond",
@@ -339,5 +343,23 @@
339
343
  "label.evals": "évaluations",
340
344
  "label.calls_month": "appels (mois)",
341
345
  "label.input_m": "entrée /M",
342
- "label.output_m": "sortie /M"
346
+ "label.output_m": "sortie /M",
347
+ "help.telemetry._help": "lots de télémétrie d'un déploiement sans accès réseau",
348
+ "help.commands._help": "fichiers de commandes pour un déploiement sans accès réseau",
349
+ "help.telemetry.import._help": "téléverser un lot de télémétrie d'un déploiement hors ligne",
350
+ "help.telemetry.status._help": "les lots détenus pour un déploiement, et ceux qui manquent",
351
+ "help.telemetry.import.file": "le lot (.tar.gz), tel que le déploiement l'a écrit",
352
+ "help.telemetry.import.deployment": "le déploiement (par défaut : celui que le lot nomme)",
353
+ "help.telemetry.status.deployment": "le déploiement",
354
+ "help.commands.export._help": "écrire les commandes en attente dans un fichier de commandes signé",
355
+ "help.commands.export.deployment": "le déploiement",
356
+ "help.commands.export.out": "le fichier à écrire (- pour la sortie standard)",
357
+ "help.commands.export.force": "écraser le fichier s'il existe",
358
+ "label.epoch": "époque",
359
+ "label.held_through": "détenu jusqu'à",
360
+ "label.highest": "le plus élevé",
361
+ "label.missing": "manquants",
362
+ "label.records_stored": "enregistrements stockés",
363
+ "label.records_already_held": "enregistrements déjà détenus",
364
+ "label.spans": "spans"
343
365
  }
@@ -209,6 +209,10 @@
209
209
  "help.runs.tail.duration": "stop tar éis an méid soicindí seo",
210
210
  "help.runs.tail.deployment": "ritheanna an imscartha seo amháin",
211
211
  "help.runs.tail.fail_on_stall": "scoir le 1 má tá rith greamaithe",
212
+ "help.runs.export._help": "easpórtáil gan ábhar de rith amháin, mar JSON nó PDF",
213
+ "help.runs.export.run": "a run_id_hash",
214
+ "help.runs.export.export_format": "json (an réamhshocrú) nó pdf",
215
+ "help.runs.export.output": "an comhad le scríobh (réamhshocrú run-<hash>.<format>; - don aschur caighdeánach)",
212
216
  "help.spend.by": "conas grúpáil",
213
217
  "help.spend.month": "BBBB-MM; an mhí reatha de réir réamhshocraithe",
214
218
  "help.spend.forecast": "réamh-mheastachán dheireadh na míosa i gcomparáid leis an uasteorainn freisin",
@@ -339,5 +343,23 @@
339
343
  "label.evals": "meastóireachtaí",
340
344
  "label.calls_month": "glaonna (mí)",
341
345
  "label.input_m": "ionchur /M",
342
- "label.output_m": "aschur /M"
346
+ "label.output_m": "aschur /M",
347
+ "help.telemetry._help": "beartáin teiliméadrachta ó imscaradh gan chonair líonra",
348
+ "help.commands._help": "comhaid ordaithe d'imscaradh gan chonair líonra",
349
+ "help.telemetry.import._help": "beart teiliméadrachta ó imscaradh as líne a uaslódáil",
350
+ "help.telemetry.status._help": "na baisceanna atá ar coimeád d'imscaradh, agus a bhfuil in easnamh",
351
+ "help.telemetry.import.file": "an beart (.tar.gz), mar a scríobh an t-imscaradh é",
352
+ "help.telemetry.import.deployment": "an t-imscaradh (réamhshocrú: an ceann a ainmníonn an beart)",
353
+ "help.telemetry.status.deployment": "an t-imscaradh",
354
+ "help.commands.export._help": "na horduithe atá ag fanacht a scríobh i gcomhad ordaithe sínithe",
355
+ "help.commands.export.deployment": "an t-imscaradh",
356
+ "help.commands.export.out": "an comhad le scríobh (- don aschur caighdeánach)",
357
+ "help.commands.export.force": "forscríobh an comhad má tá sé ann",
358
+ "label.epoch": "ré",
359
+ "label.held_through": "iomlán go dtí",
360
+ "label.highest": "is airde",
361
+ "label.missing": "in easnamh",
362
+ "label.records_stored": "taifid stóráilte",
363
+ "label.records_already_held": "taifid ar coimeád cheana",
364
+ "label.spans": "réisí"
343
365
  }