blobhub-cli 0.5.0__tar.gz → 0.7.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 (66) hide show
  1. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/PKG-INFO +45 -4
  2. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/README.md +44 -3
  3. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/__init__.py +1 -1
  4. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/api/client.py +28 -8
  5. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/api/commands.py +48 -16
  6. blobhub_cli-0.7.0/src/blobhub_cli/api/errors.py +90 -0
  7. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/blob/reference.py +10 -1
  8. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/cli/app.py +78 -7
  9. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/codes.py +6 -1
  10. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/commands/auth.py +2 -3
  11. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/commands/blob.py +14 -25
  12. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/commands/doctor.py +3 -3
  13. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/commands/eject.py +5 -1
  14. blobhub_cli-0.7.0/src/blobhub_cli/commands/events.py +326 -0
  15. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/commands/execute.py +30 -18
  16. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/commands/scheduler.py +70 -55
  17. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/commands/workflow.py +115 -22
  18. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/compiler/allowlist.py +23 -18
  19. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/compiler/sandbox.py +3 -3
  20. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/config/settings.py +16 -7
  21. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/console.py +13 -3
  22. blobhub_cli-0.7.0/src/blobhub_cli/definition/listener.py +325 -0
  23. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/definition/model.py +2 -1
  24. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/exit.py +4 -2
  25. blobhub_cli-0.7.0/src/blobhub_cli/limits.py +51 -0
  26. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/manifest/scheduler.py +55 -11
  27. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/scheduler/references.py +29 -19
  28. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/workflow/definitions.py +23 -7
  29. blobhub_cli-0.7.0/src/blobhub_cli/workflow/event_filter.py +140 -0
  30. blobhub_cli-0.7.0/src/blobhub_cli/workflow/events.py +97 -0
  31. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/workflow/executions.py +37 -25
  32. blobhub_cli-0.7.0/src/blobhub_cli/workflow/follow.py +100 -0
  33. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/workflow/reference.py +21 -10
  34. blobhub_cli-0.7.0/src/blobhub_cli/workflow/session_reference.py +89 -0
  35. blobhub_cli-0.5.0/src/blobhub_cli/api/errors.py +0 -51
  36. blobhub_cli-0.5.0/src/blobhub_cli/limits.py +0 -32
  37. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/.gitignore +0 -0
  38. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/LICENSE +0 -0
  39. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/pyproject.toml +0 -0
  40. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/__main__.py +0 -0
  41. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/api/__init__.py +0 -0
  42. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/blob/__init__.py +0 -0
  43. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/cli/__init__.py +0 -0
  44. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/commands/__init__.py +0 -0
  45. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/commands/completion.py +0 -0
  46. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/compiler/__init__.py +0 -0
  47. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/compiler/emit.py +0 -0
  48. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/compiler/graph.py +0 -0
  49. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/config/__init__.py +0 -0
  50. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/config/credentials.py +0 -0
  51. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/config/sources.py +0 -0
  52. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/definition/__init__.py +0 -0
  53. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/definition/drift.py +0 -0
  54. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/definition/eject.py +0 -0
  55. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/definition/hashing.py +0 -0
  56. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/formats/__init__.py +0 -0
  57. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/formats/io.py +0 -0
  58. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/ids.py +0 -0
  59. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/manifest/__init__.py +0 -0
  60. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/manifest/base.py +0 -0
  61. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/manifest/resolver.py +0 -0
  62. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/manifest/workflow.py +0 -0
  63. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/scheduler/__init__.py +0 -0
  64. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/scheduler/schedules.py +0 -0
  65. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/statestore.py +0 -0
  66. {blobhub_cli-0.5.0 → blobhub_cli-0.7.0}/src/blobhub_cli/workflow/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: blobhub-cli
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: BlobHub command line interface — work with blobs, revisions, and their contents from the terminal
5
5
  Project-URL: Homepage, https://blobhub.io/
6
6
  Project-URL: Documentation, https://docs.blobhub.io/
@@ -106,6 +106,9 @@ For CI, skip the credentials file entirely — set `BLOBHUB_API_KEY` (and option
106
106
  `BLOBHUB_PROFILE`); the environment always wins over a stored profile, and a key from the environment alone
107
107
  works with no credentials file present at all.
108
108
 
109
+ The root `--profile` selects a stored profile for any command (`blobhub --profile staging workflow events …`),
110
+ as `BLOBHUB_PROFILE` does. An exported `BLOBHUB_API_KEY` or `BLOBHUB_API_URL` still wins over the profile it names.
111
+
109
112
  `login` itself can also run non-interactively and still store a profile, which the environment-only approach
110
113
  above does not:
111
114
 
@@ -178,9 +181,10 @@ blobhub workflow deploy -f manifest.yaml
178
181
  `deploy` builds every bound component locally (inlining local imports from `pkg/`, stripping sandbox-pre-bound
179
182
  and allowlisted imports, rejecting anything else), uploads only the definitions whose serialized document
180
183
  actually changed, and runs the platform's `check_definition` against each workflow definition it uploaded.
181
- Re-running `deploy` with nothing changed uploads nothing and says so. `--dry-run` on every mutating command
182
- prints the exact intended calls without performing them; `--json` emits a single machine-readable object instead
183
- of the rendered output.
184
+ Re-running `deploy` with nothing changed uploads nothing and says so. `--dry-run` (on `workflow pull|push|build|deploy|
185
+ prune` and `scheduler deploy`) prints the exact intended calls without performing them; `--json` emits a single
186
+ machine-readable object instead of the rendered output. The exception is `workflow events --follow --json`, which
187
+ writes JSON Lines, one `{"event", "schema_version"}` object per event and no final object.
184
188
 
185
189
  Other commands in the `workflow` group:
186
190
 
@@ -196,6 +200,43 @@ it with `REMOTE_EDIT` (or `UNMANAGED_CODE` for hand-typed code the CLI never bui
196
200
  or reconcile by hand first. See `docs/reference/error-codes.md` for the full catalog of error and advisory codes,
197
201
  what each one means, and how to resolve it.
198
202
 
203
+ ### Listeners and session events
204
+
205
+ A **listener** is a definition with `category: listener` that acts on a session's event stream: it filters events,
206
+ batches them, and invokes a workflow, a thread item or an object write. It lives in the manifest beside the
207
+ workflows and deploys, diffs, pulls and checks like any other definition. The source file holds the whole body
208
+ (`type: listener_definition`, `filter`, `batch`, `concurrency`, `invocation_target_type`, `invocation_target`,
209
+ `receipt`); a listener entry takes no `components`.
210
+
211
+ ```yaml
212
+ definitions:
213
+ - alias: orchestrate
214
+ category: listener # or inferred from `type: listener_definition` in the file
215
+ source: listeners/orchestrate.yaml
216
+ ```
217
+
218
+ A session is named as `<session>` (with `-f manifest.yaml` naming its blob and revision),
219
+ `<blob-id>/<revision>/<session>` or `<org>/<blob>/<revision>/<session>`; a qualified reference takes no `-f`.
220
+
221
+ ```sh
222
+ blobhub workflow events my-session -f manifest.yaml # newest 20 events
223
+ blobhub workflow events my-session -f manifest.yaml --types object.written --aliases 'worker/**' --follow
224
+ blobhub workflow events my-session -f manifest.yaml --listener orchestrate --after 2026-11-09T17:00:00Z
225
+ blobhub workflow events my-session -f manifest.yaml --cursor <token> # continue the page a listing printed
226
+ blobhub workflow listeners my-session -f manifest.yaml [--listener orchestrate]
227
+ blobhub workflow listener disable my-session orchestrate -f manifest.yaml # enable | disable, for this session
228
+ ```
229
+
230
+ `--types`, `--aliases` and `--executions` repeat, and take globs where the platform does. A listing ends with
231
+ `more: --cursor <token>` when there is another page; the token carries its own filter and direction, so pass it alone.
232
+ `--follow` prints new events until interrupted without missing a late commit. `workflow execute --watch` follows the
233
+ run's log the same way and ends once the platform reports the execution finished.
234
+
235
+ With `--json`, the new commands print these keys: `workflow events` `{session, events, cursor}`; `workflow listeners`
236
+ `{session_id, listeners, connections}`, or with `--listener` `{session_id, listener, executions, last_execution_id}`;
237
+ `workflow listener enable|disable` `{session_id, listener}`. `workflow diff` adds `body_changed` and `validation_error`
238
+ to a listener's report, and `scheduler ls` adds `invocation_target_type` and `target_alias` to each schedule.
239
+
199
240
  ## License
200
241
 
201
242
  MIT. See `LICENSE`.
@@ -53,6 +53,9 @@ For CI, skip the credentials file entirely — set `BLOBHUB_API_KEY` (and option
53
53
  `BLOBHUB_PROFILE`); the environment always wins over a stored profile, and a key from the environment alone
54
54
  works with no credentials file present at all.
55
55
 
56
+ The root `--profile` selects a stored profile for any command (`blobhub --profile staging workflow events …`),
57
+ as `BLOBHUB_PROFILE` does. An exported `BLOBHUB_API_KEY` or `BLOBHUB_API_URL` still wins over the profile it names.
58
+
56
59
  `login` itself can also run non-interactively and still store a profile, which the environment-only approach
57
60
  above does not:
58
61
 
@@ -125,9 +128,10 @@ blobhub workflow deploy -f manifest.yaml
125
128
  `deploy` builds every bound component locally (inlining local imports from `pkg/`, stripping sandbox-pre-bound
126
129
  and allowlisted imports, rejecting anything else), uploads only the definitions whose serialized document
127
130
  actually changed, and runs the platform's `check_definition` against each workflow definition it uploaded.
128
- Re-running `deploy` with nothing changed uploads nothing and says so. `--dry-run` on every mutating command
129
- prints the exact intended calls without performing them; `--json` emits a single machine-readable object instead
130
- of the rendered output.
131
+ Re-running `deploy` with nothing changed uploads nothing and says so. `--dry-run` (on `workflow pull|push|build|deploy|
132
+ prune` and `scheduler deploy`) prints the exact intended calls without performing them; `--json` emits a single
133
+ machine-readable object instead of the rendered output. The exception is `workflow events --follow --json`, which
134
+ writes JSON Lines, one `{"event", "schema_version"}` object per event and no final object.
131
135
 
132
136
  Other commands in the `workflow` group:
133
137
 
@@ -143,6 +147,43 @@ it with `REMOTE_EDIT` (or `UNMANAGED_CODE` for hand-typed code the CLI never bui
143
147
  or reconcile by hand first. See `docs/reference/error-codes.md` for the full catalog of error and advisory codes,
144
148
  what each one means, and how to resolve it.
145
149
 
150
+ ### Listeners and session events
151
+
152
+ A **listener** is a definition with `category: listener` that acts on a session's event stream: it filters events,
153
+ batches them, and invokes a workflow, a thread item or an object write. It lives in the manifest beside the
154
+ workflows and deploys, diffs, pulls and checks like any other definition. The source file holds the whole body
155
+ (`type: listener_definition`, `filter`, `batch`, `concurrency`, `invocation_target_type`, `invocation_target`,
156
+ `receipt`); a listener entry takes no `components`.
157
+
158
+ ```yaml
159
+ definitions:
160
+ - alias: orchestrate
161
+ category: listener # or inferred from `type: listener_definition` in the file
162
+ source: listeners/orchestrate.yaml
163
+ ```
164
+
165
+ A session is named as `<session>` (with `-f manifest.yaml` naming its blob and revision),
166
+ `<blob-id>/<revision>/<session>` or `<org>/<blob>/<revision>/<session>`; a qualified reference takes no `-f`.
167
+
168
+ ```sh
169
+ blobhub workflow events my-session -f manifest.yaml # newest 20 events
170
+ blobhub workflow events my-session -f manifest.yaml --types object.written --aliases 'worker/**' --follow
171
+ blobhub workflow events my-session -f manifest.yaml --listener orchestrate --after 2026-11-09T17:00:00Z
172
+ blobhub workflow events my-session -f manifest.yaml --cursor <token> # continue the page a listing printed
173
+ blobhub workflow listeners my-session -f manifest.yaml [--listener orchestrate]
174
+ blobhub workflow listener disable my-session orchestrate -f manifest.yaml # enable | disable, for this session
175
+ ```
176
+
177
+ `--types`, `--aliases` and `--executions` repeat, and take globs where the platform does. A listing ends with
178
+ `more: --cursor <token>` when there is another page; the token carries its own filter and direction, so pass it alone.
179
+ `--follow` prints new events until interrupted without missing a late commit. `workflow execute --watch` follows the
180
+ run's log the same way and ends once the platform reports the execution finished.
181
+
182
+ With `--json`, the new commands print these keys: `workflow events` `{session, events, cursor}`; `workflow listeners`
183
+ `{session_id, listeners, connections}`, or with `--listener` `{session_id, listener, executions, last_execution_id}`;
184
+ `workflow listener enable|disable` `{session_id, listener}`. `workflow diff` adds `body_changed` and `validation_error`
185
+ to a listener's report, and `scheduler ls` adds `invocation_target_type` and `target_alias` to each schedule.
186
+
146
187
  ## License
147
188
 
148
189
  MIT. See `LICENSE`.
@@ -5,4 +5,4 @@ Contract: this module is the sole source of the package version — hatchling re
5
5
  Invariant: no other file in this package defines `__version__`.
6
6
  """
7
7
 
8
- __version__ = "0.5.0"
8
+ __version__ = "0.7.0"
@@ -3,6 +3,8 @@
3
3
  Contract: `get`, `query`, `command`, and `raw_query` route every response through `_handle`,
4
4
  which maps HTTP status and the body's `status` field onto the `api.errors` hierarchy;
5
5
  `raw_query` alone skips the status gate, and `probe` skips both the gate and the retry loop.
6
+ `get`, `query` and `raw_query` retry every transient failure; `command` retries only
7
+ `errors.NEVER_RAN`, because any other failure can follow a write the platform already committed.
6
8
  Neither `query`, `command`, nor `raw_query` names which component serves a data-channel call --
7
9
  the target revision's blob type decides that server-side, so there is nothing for a caller to
8
10
  supply here at all, even for a scheduler `deploy` that legitimately writes schedules against one
@@ -20,19 +22,29 @@ import time
20
22
  import httpx
21
23
 
22
24
  from blobhub_cli.api.errors import (
25
+ NEVER_RAN,
26
+ TRANSIENT,
23
27
  ApiAuthError,
24
28
  ApiCommandError,
29
+ ApiConnectError,
30
+ ApiLimitExceeded,
25
31
  ApiNetworkError,
26
32
  ApiRateLimited,
27
33
  ApiTransientError,
28
34
  )
29
35
 
30
- _TRANSIENT = (ApiRateLimited, ApiTransientError, ApiNetworkError)
31
36
  _ATTEMPTS = 3
32
37
  _BASE_DELAY = 0.5
33
38
  _MAX_DELAY = 8.0
34
39
 
35
40
 
41
+ def _network_error(exc: httpx.TransportError) -> ApiNetworkError:
42
+ """The transport failure as an `ApiNetworkError`, or an `ApiConnectError` when nothing was sent."""
43
+ error_type = ApiConnectError if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)) else ApiNetworkError
44
+ # str() of an httpx transport error is often empty (ReadError("")); the class name is then the only cause.
45
+ return error_type(str(exc) or type(exc).__name__)
46
+
47
+
36
48
  class Client:
37
49
  def __init__(
38
50
  self,
@@ -62,7 +74,7 @@ class Client:
62
74
  # parameters instead of ignoring them, so `?limit=` or `?cursor=None` is a 400.
63
75
  if params is not None:
64
76
  params = {key: value for key, value in params.items() if value is not None}
65
- return self._send("GET", path, gate_status=True, params=params or None)
77
+ return self._send("GET", path, gate_status=True, idempotent=True, params=params or None)
66
78
 
67
79
  def probe(self, path: str = "/users/me") -> tuple[int, float]:
68
80
  """One unretried request, returning (status_code, elapsed_ms) without gating the body.
@@ -75,7 +87,7 @@ class Client:
75
87
  try:
76
88
  resp = self._http.request("GET", path)
77
89
  except httpx.TransportError as exc:
78
- raise ApiNetworkError(str(exc)) from exc
90
+ raise _network_error(exc) from exc
79
91
  return resp.status_code, (time.monotonic() - start) * 1000
80
92
 
81
93
  def query(self, revision_id: str, command: str, **args: object) -> dict:
@@ -92,20 +104,26 @@ class Client:
92
104
  ) -> dict:
93
105
  path = f"/revisions/{revision_id}/data/{channel}"
94
106
  body = {"command": command, **args}
95
- return self._send("POST", path, gate_status=gate_status, json=body)
107
+ return self._send("POST", path, gate_status=gate_status, idempotent=(channel == "query"), json=body)
96
108
 
97
- def _send(self, method: str, path: str, *, gate_status: bool, **kwargs: object) -> dict:
109
+ def _send(
110
+ self, method: str, path: str, *, gate_status: bool, idempotent: bool, **kwargs: object
111
+ ) -> dict:
112
+ # A write retries only what proves it never ran (docs/specs/2026-10-06-safe-retries.md).
113
+ retryable = TRANSIENT if idempotent else NEVER_RAN
98
114
  last_error: Exception
99
115
  for attempt in range(_ATTEMPTS):
100
116
  try:
101
117
  resp = self._http.request(method, path, **kwargs)
102
118
  except httpx.TransportError as exc:
103
- last_error = ApiNetworkError(str(exc))
119
+ last_error = _network_error(exc)
104
120
  else:
105
121
  try:
106
122
  return self._handle(resp, gate_status=gate_status)
107
- except _TRANSIENT as exc:
123
+ except TRANSIENT as exc:
108
124
  last_error = exc
125
+ if not isinstance(last_error, retryable):
126
+ break
109
127
  if attempt < _ATTEMPTS - 1:
110
128
  self._sleep(random.random() * min(_MAX_DELAY, _BASE_DELAY * 2**attempt))
111
129
  raise last_error
@@ -122,5 +140,7 @@ class Client:
122
140
  except ValueError as exc:
123
141
  raise ApiTransientError(f"non-JSON response: HTTP {resp.status_code}") from exc
124
142
  if gate_status and data.get("status") != "success":
125
- raise ApiCommandError(data.get("error", "unknown"), data.get("message", ""))
143
+ if data.get("error") == "limit_exceeded":
144
+ raise ApiLimitExceeded(data.get("message", ""), data.get("limit"))
145
+ raise ApiCommandError(data.get("error", "unknown"), data.get("message", ""), data.get("limit"))
126
146
  return data
@@ -41,8 +41,8 @@ class Api:
41
41
  f"/blobs/{org}/{blob}/revisions", {"limit": limit, "cursor": cursor})
42
42
  return body["revisions"], body["cursor"]
43
43
 
44
- def get_blob_limits(self, org: str, blob: str) -> dict:
45
- return self._client.get(f"/blobs/{org}/{blob}/limits")["limits"]
44
+ def get_blob_limits(self, org: str, blob: str) -> list[dict]:
45
+ return self._client.get(f"/blobs/{org}/{blob}/limits")["groups"]
46
46
 
47
47
  def list_definitions(self, revision_id: str, category: str) -> list[dict]:
48
48
  return self._client.query(revision_id, "list_definitions", category=category)["definitions"]
@@ -85,32 +85,64 @@ class Api:
85
85
  def get_execution(self, revision_id: str, execution_id: str) -> dict:
86
86
  return self._client.query(revision_id, "get_execution", execution_id=execution_id)["execution"]
87
87
 
88
+ def list_session_events(
89
+ self,
90
+ revision_id: str,
91
+ session: str,
92
+ *,
93
+ filter: dict | None = None,
94
+ after: str | None = None,
95
+ ascending: bool | None = None,
96
+ limit: int | None = None,
97
+ cursor: str | None = None,
98
+ ) -> tuple[list[dict], str | None]:
99
+ args = self._page_args(filter, after, ascending, limit, cursor)
100
+ body = self._client.query(revision_id, "list_session_events", session=session, **args)
101
+ return body["events"], body["cursor"]
102
+
88
103
  def list_execution_events(
89
104
  self,
90
105
  revision_id: str,
91
106
  execution_id: str,
92
107
  *,
93
- created_since: str | None = None,
94
- ascending: bool = True,
108
+ filter: dict | None = None,
109
+ after: str | None = None,
110
+ ascending: bool | None = None,
95
111
  limit: int | None = None,
96
- ) -> list[dict]:
112
+ cursor: str | None = None,
113
+ ) -> tuple[list[dict], str | None]:
114
+ args = self._page_args(filter, after, ascending, limit, cursor)
115
+ body = self._client.query(revision_id, "list_execution_events", execution_id=execution_id, **args)
116
+ return body["events"], body["cursor"]
117
+
118
+ @staticmethod
119
+ def _page_args(
120
+ filter: dict | None, after: str | None, ascending: bool | None, limit: int | None, cursor: str | None
121
+ ) -> dict[str, object]:
97
122
  # Keys are omitted rather than sent as null: every command schema is
98
123
  # additionalProperties: false, so one stray key is a 400, not an ignored field.
99
- args: dict[str, object] = {"execution_id": execution_id, "ascending": ascending}
100
- if created_since is not None:
101
- args["created_since"] = created_since
102
- if limit is not None:
103
- args["limit"] = limit
104
- return self._client.query(revision_id, "list_execution_events", **args)["execution_events"]
124
+ given = {"filter": filter or None, "after": after, "ascending": ascending, "limit": limit, "cursor": cursor}
125
+ return {key: value for key, value in given.items() if value is not None}
126
+
127
+ def list_session_listeners(self, revision_id: str, session: str) -> list[dict]:
128
+ return self._client.query(revision_id, "list_session_listeners", session=session)["listeners"]
129
+
130
+ def set_session_listener(self, revision_id: str, session: str, listener: str, enabled: bool) -> dict:
131
+ return self._client.command(
132
+ revision_id, "set_session_listener", session=session, listener=listener, enabled=enabled
133
+ )["listener"]
105
134
 
106
135
  def list_executions(
107
- self, revision_id: str, session_id: str, *, start_execution_id: str | None = None
136
+ self,
137
+ revision_id: str,
138
+ session_id: str,
139
+ *,
140
+ listener: str | None = None,
141
+ start_execution_id: str | None = None,
108
142
  ) -> tuple[list[dict], str | None]:
109
- # start_execution_id is omitted rather than sent as null, same reason list_schedules omits
110
- # start_schedule_id: LIST_EXECUTIONS_SCHEMA is additionalProperties: false. One page and the
111
- # next cursor -- this method deliberately does not loop; the loop belongs to the caller that
112
- # knows why it's paging (deploy's saturation count stops early on purpose).
113
143
  args: dict[str, object] = {"session_id": session_id}
144
+ if listener is not None:
145
+ args["listener"] = listener
114
146
  if start_execution_id is not None:
115
147
  args["start_execution_id"] = start_execution_id
116
148
  body = self._client.query(revision_id, "list_executions", **args)
@@ -0,0 +1,90 @@
1
+ """Error hierarchy raised by `client.Client`.
2
+
3
+ Contract: every failure raised is one of these classes, each carrying a `code` class attribute
4
+ from `blobhub_cli.codes`; `ApiCommandError` additionally carries `.error`, the server's
5
+ machine-readable failure string, kept separate from the human-readable `.message`, and `.limit`,
6
+ the served limit object any refusal may carry -- `ApiLimitExceeded` is the `limit_exceeded` one.
7
+ `outcome_unknown` says which failures leave a write's outcome unknown (the SDK-modernization
8
+ epic's D5, `docs/specs/2026-10-06-safe-retries.md`).
9
+ Invariant: no instance of any of these classes ever carries the API key — the client that
10
+ raises them holds the only copy, and it is never included in a message here.
11
+ """
12
+
13
+ import json
14
+
15
+ from blobhub_cli import codes
16
+
17
+
18
+ class ApiError(Exception):
19
+ """Base class for every error `Client` raises."""
20
+
21
+ code = "API_ERROR"
22
+
23
+
24
+ class ApiAuthError(ApiError):
25
+ """The API key was rejected or the resource is forbidden (HTTP 401/403)."""
26
+
27
+ code = codes.AUTH_INVALID
28
+
29
+
30
+ class ApiRateLimited(ApiError):
31
+ """API Gateway throttled the request (HTTP 429) before anything ran. Retried for every request."""
32
+
33
+ code = codes.API_RATE_LIMITED
34
+
35
+
36
+ class ApiTransientError(ApiError):
37
+ """A server-side or malformed-response failure (HTTP 5xx, non-JSON body). Retried for reads only."""
38
+
39
+ code = codes.API_TRANSIENT_ERROR
40
+
41
+
42
+ class ApiNetworkError(ApiError):
43
+ """A transport failure: no usable response arrived. Retried for reads only."""
44
+
45
+ code = codes.API_NETWORK_ERROR
46
+
47
+
48
+ class ApiConnectError(ApiNetworkError):
49
+ """The connection was never established, so the request was never sent. Retried for every request."""
50
+
51
+
52
+ class ApiCommandError(ApiError):
53
+ """A successful HTTP response whose body reports `status` other than `success`."""
54
+
55
+ code = codes.API_COMMAND_FAILED
56
+
57
+ def __init__(self, error: str, message: str = "", limit: dict | None = None) -> None:
58
+ message = with_limit(message, limit)
59
+ super().__init__(message or error)
60
+ self.error = error
61
+ self.message = message or error
62
+ self.limit = limit
63
+
64
+
65
+ class ApiLimitExceeded(ApiCommandError):
66
+ """A `limit_exceeded` refusal: a quota or a configurable ceiling has been reached."""
67
+
68
+ code = codes.LIMIT_EXCEEDED
69
+
70
+ def __init__(self, message: str = "", limit: dict | None = None) -> None:
71
+ super().__init__("limit_exceeded", message, limit)
72
+
73
+
74
+ def with_limit(message: str, limit: dict | None) -> str:
75
+ """`message` with the served limit appended, as `(limit <id>: value <v>[, held <h>])`."""
76
+ if not limit:
77
+ return message
78
+ held = f", held {json.dumps(limit['held'])}" if "held" in limit else ""
79
+ named = f"limit {limit.get('id')}: value {json.dumps(limit.get('value'))}{held}"
80
+ return f"{message} ({named})" if message else named
81
+
82
+
83
+ TRANSIENT = (ApiRateLimited, ApiTransientError, ApiNetworkError)
84
+ # A write may retry only these: a 429 is refused before any handler runs, and a failed connect sent nothing.
85
+ NEVER_RAN = (ApiRateLimited, ApiConnectError)
86
+
87
+
88
+ def outcome_unknown(error: BaseException) -> bool:
89
+ """Whether a failed request may still have been carried out by the platform."""
90
+ return isinstance(error, TRANSIENT) and not isinstance(error, NEVER_RAN)
@@ -40,12 +40,21 @@ def not_accessible(ref: BlobRef) -> CliExit:
40
40
  can add: an org-scoped key must address its org by UUID and gets exactly this 403 against an
41
41
  alias -- which is why it belongs to the message rather than to one of the two raise sites.
42
42
  """
43
- message = f"blob {ref.label()} is absent or not accessible with this key"
43
+ message = _refused(ref.label())
44
44
  if not ids.looks_like_uuid(ref.org):
45
45
  message += f"; if this API key is org-scoped, it must address the org by UUID, not the alias {ref.org!r}"
46
46
  return CliExit(codes.BLOB_NOT_ACCESSIBLE, message, "blobhub whoami")
47
47
 
48
48
 
49
+ def id_not_accessible(blob_id: str) -> CliExit:
50
+ """BLOB_NOT_ACCESSIBLE for a blob named by id alone, so no org clause applies."""
51
+ return CliExit(codes.BLOB_NOT_ACCESSIBLE, _refused(blob_id), "blobhub whoami")
52
+
53
+
54
+ def _refused(label: str) -> str:
55
+ return f"blob {label} is absent or not accessible with this key"
56
+
57
+
49
58
  def parse(text: str, default_org: str | None, *, command: str = DEFAULT_COMMAND) -> BlobRef:
50
59
  # `command` is the sub-command the caller is running ("blob revisions", "blob limits", ...),
51
60
  # threaded in only so that every hint below is a command the user can actually paste. It is no
@@ -12,7 +12,7 @@ import typer
12
12
  from typer.completion import completion_init
13
13
 
14
14
  from blobhub_cli import __version__, codes, console
15
- from blobhub_cli.api.errors import ApiError
15
+ from blobhub_cli.api.errors import ApiCommandError, ApiError
16
16
  from blobhub_cli.commands.completion import Shell as CompletionShell
17
17
  from blobhub_cli.config import settings
18
18
  from blobhub_cli.exit import CliExit
@@ -42,9 +42,10 @@ def build_app() -> typer.Typer:
42
42
  verbose: bool = typer.Option(False, "-v", "--verbose", help="Print extra diagnostic detail."),
43
43
  quiet: bool = typer.Option(False, "-q", "--quiet", help="Suppress advisory warnings."),
44
44
  org: str = typer.Option(None, "--org", help="Org a manifest's bare 'blob:' name belongs to."),
45
+ profile: str = typer.Option(None, "--profile", help="Profile to use for this command."),
45
46
  ) -> None:
46
47
  console.configure(json_output=json_output, verbose=verbose, quiet=quiet)
47
- settings.configure(org=org)
48
+ settings.configure(org=org, profile=profile)
48
49
  if version:
49
50
  typer.echo(__version__)
50
51
  raise typer.Exit(0)
@@ -134,7 +135,10 @@ def build_app() -> typer.Typer:
134
135
 
135
136
  completion_cmd.emit(shell)
136
137
 
137
- profile_app = typer.Typer(add_completion=False, help="Manage stored profiles.")
138
+ profile_app = typer.Typer(
139
+ add_completion=False,
140
+ help="Manage stored profiles. Takes names as arguments; ignores the root --profile.",
141
+ )
138
142
 
139
143
  @profile_app.command("ls")
140
144
  def profile_ls() -> None:
@@ -175,7 +179,7 @@ def build_app() -> typer.Typer:
175
179
  blob: str = typer.Argument(..., help="Blob as '<org>/<name>', or a bare name with --org."),
176
180
  refresh: bool = typer.Option(False, "--refresh", help="Also refresh the compiler's local allowlist cache."),
177
181
  ) -> None:
178
- """Show a blob's limits, including the import allowlist. Requires an admin-scoped key."""
182
+ """Show a blob's limits by group: id, holding, source and value."""
179
183
  from blobhub_cli.commands import blob as blob_cmd
180
184
 
181
185
  blob_cmd.limits(blob, refresh)
@@ -234,13 +238,12 @@ def build_app() -> typer.Typer:
234
238
  manifest: Path = typer.Option(..., "-f", "--file", help="Path to the workflow manifest."), # noqa: B008
235
239
  definition: list[str] = typer.Option([], "--definition", help="Limit to this alias, repeatable."), # noqa: B008
236
240
  force: bool = typer.Option(False, "--force", help="Overwrite code edited outside the CLI."),
237
- strict_imports: bool = typer.Option(False, "--strict-imports", help="Treat an unverifiable import as fatal."),
238
241
  dry_run: bool = typer.Option(False, "--dry-run", help="Report the intended calls and perform none."),
239
242
  ) -> None:
240
243
  """Build, upload only what changed, and check the workflow definitions that were uploaded."""
241
244
  from blobhub_cli.commands import workflow
242
245
 
243
- workflow.deploy(manifest, tuple(definition), force, strict_imports, dry_run)
246
+ workflow.deploy(manifest, tuple(definition), force, dry_run)
244
247
 
245
248
  @workflow_app.command("eject")
246
249
  def workflow_eject(
@@ -271,6 +274,73 @@ def build_app() -> typer.Typer:
271
274
 
272
275
  execute_cmd.execute(manifest, definition, session, watch, open_browser)
273
276
 
277
+ @workflow_app.command("events")
278
+ def workflow_events(
279
+ session: str = typer.Argument(
280
+ ..., help="A session, or <blob-id>/<revision>/<session> or <org>/<blob>/<revision>/<session>."
281
+ ),
282
+ manifest: Path = typer.Option( # noqa: B008
283
+ None, "-f", "--file", help="Manifest naming the blob and revision of a bare session."
284
+ ),
285
+ types: list[str] = typer.Option([], "--types", help="Event types, globs allowed; repeatable."), # noqa: B008
286
+ aliases: list[str] = typer.Option([], "--aliases", help="Object aliases; repeatable."), # noqa: B008
287
+ executions: list[str] = typer.Option([], "--executions", help="Execution ids; repeatable."), # noqa: B008
288
+ listener: str = typer.Option(None, "--listener", help="Only events a listener caused: its alias or id."),
289
+ after: str = typer.Option(None, "--after", help="List events after this id, boundary or ISO 8601 time."),
290
+ cursor: str = typer.Option(None, "--cursor", help="Continue a listing from the token it printed."),
291
+ limit: int = typer.Option(20, "--limit", min=1, max=1000, help="Events per page."),
292
+ follow: bool = typer.Option(False, "--follow", help="Print new events as they arrive until interrupted."),
293
+ ) -> None:
294
+ """List a session's events, newest first unless --after is given, or follow them."""
295
+ from blobhub_cli.commands import events as events_cmd
296
+
297
+ events_cmd.events(
298
+ session, manifest, types=types, aliases=aliases, executions=executions, listener=listener,
299
+ after=after, cursor=cursor, limit=limit, follow=follow,
300
+ )
301
+
302
+ @workflow_app.command("listeners")
303
+ def workflow_listeners(
304
+ session: str = typer.Argument(
305
+ ..., help="A session, or <blob-id>/<revision>/<session> or <org>/<blob>/<revision>/<session>."
306
+ ),
307
+ manifest: Path = typer.Option( # noqa: B008
308
+ None, "-f", "--file", help="Manifest naming the blob and revision of a bare session."
309
+ ),
310
+ listener: str = typer.Option(None, "--listener", help="Show one declared listener: its alias or id."),
311
+ ) -> None:
312
+ """List a session's listeners with their switch, slots and outcomes, or show one in detail."""
313
+ from blobhub_cli.commands import events as events_cmd
314
+
315
+ events_cmd.listeners(session, manifest, listener=listener)
316
+
317
+ listener_app = typer.Typer(add_completion=False, help="Switch a declared listener for one session.")
318
+
319
+ def _listener_switch(session: str, listener: str, manifest: Path | None, enabled: bool) -> None:
320
+ from blobhub_cli.commands import events as events_cmd
321
+
322
+ events_cmd.switch(session, manifest, listener, enabled)
323
+
324
+ @listener_app.command("enable")
325
+ def listener_enable(
326
+ session: str = typer.Argument(..., help="A session reference, as for `workflow events`."),
327
+ listener: str = typer.Argument(..., help="A declared listener's alias or id."),
328
+ manifest: Path = typer.Option(None, "-f", "--file", help="Manifest naming a bare session's blob."), # noqa: B008
329
+ ) -> None:
330
+ """Enable a listener for this session, overriding its definition when it is off there."""
331
+ _listener_switch(session, listener, manifest, True)
332
+
333
+ @listener_app.command("disable")
334
+ def listener_disable(
335
+ session: str = typer.Argument(..., help="A session reference, as for `workflow events`."),
336
+ listener: str = typer.Argument(..., help="A declared listener's alias or id."),
337
+ manifest: Path = typer.Option(None, "-f", "--file", help="Manifest naming a bare session's blob."), # noqa: B008
338
+ ) -> None:
339
+ """Disable a listener for this session, overriding its definition when it is on there."""
340
+ _listener_switch(session, listener, manifest, False)
341
+
342
+ workflow_app.add_typer(listener_app, name="listener")
343
+
274
344
  @workflow_app.command("check")
275
345
  def workflow_check(
276
346
  manifest: Path = typer.Option(..., "-f", "--file", help="Path to the workflow manifest."), # noqa: B008
@@ -381,7 +451,8 @@ def main(argv: list[str] | None = None) -> int:
381
451
  # so wrap it in a CliExit rather than let a raw traceback reach the user. Only AUTH_INVALID
382
452
  # has a hint that's genuinely a runnable command; inventing one for the rest would be prose.
383
453
  hint = "blobhub login" if exc.code == codes.AUTH_INVALID else ""
384
- console.error(CliExit(exc.code, str(exc), hint))
454
+ limit = exc.limit if isinstance(exc, ApiCommandError) else None
455
+ console.error(CliExit(exc.code, str(exc), hint, limit=limit))
385
456
  return 1
386
457
  except Exception as exc: # usage errors carry exit_code; anything else is a real bug
387
458
  # Typer >=0.24 vendors its own click fork under `typer._click` rather than
@@ -31,7 +31,6 @@ MANIFEST_VERSION_UNSUPPORTED = "MANIFEST_VERSION_UNSUPPORTED"
31
31
  BLOB_REFERENCE_INVALID = "BLOB_REFERENCE_INVALID"
32
32
  BLOB_NOT_ACCESSIBLE = "BLOB_NOT_ACCESSIBLE"
33
33
  BLOB_NOT_WORKFLOW = "BLOB_NOT_WORKFLOW"
34
- LIMITS_NOT_ACCESSIBLE = "LIMITS_NOT_ACCESSIBLE"
35
34
  REVISION_NOT_ACCESSIBLE = "REVISION_NOT_ACCESSIBLE"
36
35
  REVISION_NOT_WRITABLE = "REVISION_NOT_WRITABLE"
37
36
 
@@ -40,11 +39,13 @@ DEFINITION_NOT_ACCESSIBLE = "DEFINITION_NOT_ACCESSIBLE"
40
39
  DEFINITION_ALIAS_INVALID = "DEFINITION_ALIAS_INVALID"
41
40
  DEFINITION_CATEGORY_UNKNOWN = "DEFINITION_CATEGORY_UNKNOWN"
42
41
  ALIAS_IN_USE = "ALIAS_IN_USE"
42
+ LISTENER_INVALID = "LISTENER_INVALID"
43
43
  LIMIT_EXCEEDED = "LIMIT_EXCEEDED"
44
44
  PRUNE_NOT_CONFIRMED = "PRUNE_NOT_CONFIRMED"
45
45
 
46
46
  # Workflow references
47
47
  WORKFLOW_REFERENCE_INVALID = "WORKFLOW_REFERENCE_INVALID"
48
+ SESSION_REFERENCE_INVALID = "SESSION_REFERENCE_INVALID"
48
49
 
49
50
  # Format
50
51
  FORMAT_UNSUPPORTED = "FORMAT_UNSUPPORTED"
@@ -82,6 +83,10 @@ SESSION_NOT_ACCESSIBLE = "SESSION_NOT_ACCESSIBLE"
82
83
  SESSION_NOT_OPEN = "SESSION_NOT_OPEN"
83
84
  DEFINITION_NOT_EXECUTABLE = "DEFINITION_NOT_EXECUTABLE"
84
85
  EXECUTION_FAILED = "EXECUTION_FAILED"
86
+ EXECUTION_OUTCOME_UNKNOWN = "EXECUTION_OUTCOME_UNKNOWN"
87
+ EVENT_FILTER_INVALID = "EVENT_FILTER_INVALID"
88
+ EVENT_CURSOR_INVALID = "EVENT_CURSOR_INVALID"
89
+ LISTENER_NOT_FOUND = "LISTENER_NOT_FOUND"
85
90
 
86
91
  # Schedules
87
92
  SCHEDULE_ALIAS_REQUIRED = "SCHEDULE_ALIAS_REQUIRED"
@@ -7,7 +7,6 @@ hint, or the returned payload — only the profile name, URL, and realtime URL a
7
7
  """
8
8
 
9
9
  import getpass
10
- import os
11
10
  import sys
12
11
  from collections.abc import Callable
13
12
  from typing import NamedTuple
@@ -86,7 +85,7 @@ def login(
86
85
  stdin_read: Callable[[], str] | None = None,
87
86
  api_factory: Callable[[Settings], Api] = _default_api_factory,
88
87
  ) -> dict:
89
- name = profile or os.environ.get("BLOBHUB_PROFILE") or "default"
88
+ name = settings.profile_name(profile, None) or "default"
90
89
 
91
90
  requests = [
92
91
  sources.Request("api-key", supplied.api_key, supplied.api_key_stdin, supplied.api_key_env),
@@ -179,7 +178,7 @@ def logout(profile: str | None, all_profiles: bool) -> dict:
179
178
  removed = sorted(profiles)
180
179
  credentials.save({}, None)
181
180
  else:
182
- name = profile or default
181
+ name = settings.profile_name(profile, default)
183
182
  if name is None or name not in profiles:
184
183
  raise CliExit(codes.PROFILE_NOT_FOUND, f"no profile named {name!r}", "blobhub profile ls")
185
184
  del profiles[name]