blobhub-cli 0.4.0__tar.gz → 0.6.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 (62) hide show
  1. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/PKG-INFO +1 -1
  2. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/__init__.py +1 -1
  3. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/api/client.py +28 -8
  4. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/api/commands.py +5 -27
  5. blobhub_cli-0.6.0/src/blobhub_cli/api/errors.py +90 -0
  6. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/cli/app.py +17 -13
  7. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/codes.py +4 -1
  8. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/blob.py +14 -25
  9. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/execute.py +77 -35
  10. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/scheduler.py +165 -196
  11. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/workflow.py +2 -5
  12. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/compiler/allowlist.py +23 -18
  13. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/compiler/sandbox.py +3 -3
  14. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/console.py +2 -0
  15. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/exit.py +4 -2
  16. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/ids.py +3 -1
  17. blobhub_cli-0.6.0/src/blobhub_cli/limits.py +47 -0
  18. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/manifest/base.py +6 -9
  19. blobhub_cli-0.6.0/src/blobhub_cli/manifest/scheduler.py +369 -0
  20. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/manifest/workflow.py +10 -1
  21. blobhub_cli-0.6.0/src/blobhub_cli/scheduler/references.py +237 -0
  22. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/workflow/definitions.py +4 -4
  23. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/workflow/executions.py +19 -3
  24. blobhub_cli-0.6.0/src/blobhub_cli/workflow/reference.py +102 -0
  25. blobhub_cli-0.4.0/src/blobhub_cli/api/errors.py +0 -51
  26. blobhub_cli-0.4.0/src/blobhub_cli/limits.py +0 -32
  27. blobhub_cli-0.4.0/src/blobhub_cli/manifest/scheduler.py +0 -269
  28. blobhub_cli-0.4.0/src/blobhub_cli/scheduler/references.py +0 -273
  29. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/.gitignore +0 -0
  30. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/LICENSE +0 -0
  31. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/README.md +0 -0
  32. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/pyproject.toml +0 -0
  33. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/__main__.py +0 -0
  34. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/api/__init__.py +0 -0
  35. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/blob/__init__.py +0 -0
  36. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/blob/reference.py +0 -0
  37. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/cli/__init__.py +0 -0
  38. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/__init__.py +0 -0
  39. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/auth.py +0 -0
  40. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/completion.py +0 -0
  41. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/doctor.py +0 -0
  42. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/eject.py +0 -0
  43. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/compiler/__init__.py +0 -0
  44. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/compiler/emit.py +0 -0
  45. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/compiler/graph.py +0 -0
  46. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/config/__init__.py +0 -0
  47. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/config/credentials.py +0 -0
  48. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/config/settings.py +0 -0
  49. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/config/sources.py +0 -0
  50. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/definition/__init__.py +0 -0
  51. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/definition/drift.py +0 -0
  52. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/definition/eject.py +0 -0
  53. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/definition/hashing.py +0 -0
  54. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/definition/model.py +0 -0
  55. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/formats/__init__.py +0 -0
  56. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/formats/io.py +0 -0
  57. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/manifest/__init__.py +0 -0
  58. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/manifest/resolver.py +0 -0
  59. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/scheduler/__init__.py +0 -0
  60. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/scheduler/schedules.py +0 -0
  61. {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/statestore.py +0 -0
  62. {blobhub_cli-0.4.0 → blobhub_cli-0.6.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.4.0
3
+ Version: 0.6.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/
@@ -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.4.0"
8
+ __version__ = "0.6.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"]
@@ -76,20 +76,11 @@ class Api:
76
76
  return self._client.command(revision_id, "create_session", **args)["session"]
77
77
 
78
78
  def get_session(self, revision_id: str, session: str) -> dict:
79
- # Accepts an id OR an alias -- `get_session_by_session_id_or_alias` server-side. Unlike
80
- # `create_execution`, which resolves the id only (spec §1.1).
79
+ # Accepts an id OR an alias -- get_session_by_session_id_or_alias server-side.
81
80
  return self._client.query(revision_id, "get_session", session_id=session)["session"]
82
81
 
83
- def create_execution(self, revision_id: str, session_id: str, *, definition_id: str) -> dict:
84
- # Addressed by id, not alias: `CREATE_EXECUTION_SCHEMA` accepts either, but the server
85
- # resolves `alias` via a `(revision_id, alias)` query with no category filter, taking
86
- # whichever definition comes back first. Alias uniqueness is scoped per `(category, alias)`
87
- # (F1 spec §1.3), so the same alias can legally name both a workflow and a playground
88
- # definition -- an alias-addressed execution could resolve to either, unpredictably. The
89
- # caller already has the id from `load_set`, so there is no reason to take that risk.
90
- return self._client.command(
91
- revision_id, "create_execution", session_id=session_id, definition_id=definition_id
92
- )["execution"]
82
+ def create_execution(self, revision_id: str, *, target: dict, workflow: dict) -> dict:
83
+ return self._client.command(revision_id, "create_execution", target=target, workflow=workflow)["execution"]
93
84
 
94
85
  def get_execution(self, revision_id: str, execution_id: str) -> dict:
95
86
  return self._client.query(revision_id, "get_execution", execution_id=execution_id)["execution"]
@@ -112,19 +103,6 @@ class Api:
112
103
  args["limit"] = limit
113
104
  return self._client.query(revision_id, "list_execution_events", **args)["execution_events"]
114
105
 
115
- def list_executions(
116
- self, revision_id: str, session_id: str, *, start_execution_id: str | None = None
117
- ) -> tuple[list[dict], str | None]:
118
- # start_execution_id is omitted rather than sent as null, same reason list_schedules omits
119
- # start_schedule_id: LIST_EXECUTIONS_SCHEMA is additionalProperties: false. One page and the
120
- # next cursor -- this method deliberately does not loop; the loop belongs to the caller that
121
- # knows why it's paging (deploy's saturation count stops early on purpose).
122
- args: dict[str, object] = {"session_id": session_id}
123
- if start_execution_id is not None:
124
- args["start_execution_id"] = start_execution_id
125
- body = self._client.query(revision_id, "list_executions", **args)
126
- return body["executions"], body["last_execution_id"]
127
-
128
106
  def create_schedule(self, revision_id: str, **fields: object) -> dict:
129
107
  return self._client.command(revision_id, "create_schedule", **fields)["schedule"]
130
108
 
@@ -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)
@@ -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
@@ -175,7 +175,7 @@ def build_app() -> typer.Typer:
175
175
  blob: str = typer.Argument(..., help="Blob as '<org>/<name>', or a bare name with --org."),
176
176
  refresh: bool = typer.Option(False, "--refresh", help="Also refresh the compiler's local allowlist cache."),
177
177
  ) -> None:
178
- """Show a blob's limits, including the import allowlist. Requires an admin-scoped key."""
178
+ """Show a blob's limits by group: id, holding, source and value."""
179
179
  from blobhub_cli.commands import blob as blob_cmd
180
180
 
181
181
  blob_cmd.limits(blob, refresh)
@@ -234,13 +234,12 @@ def build_app() -> typer.Typer:
234
234
  manifest: Path = typer.Option(..., "-f", "--file", help="Path to the workflow manifest."), # noqa: B008
235
235
  definition: list[str] = typer.Option([], "--definition", help="Limit to this alias, repeatable."), # noqa: B008
236
236
  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
237
  dry_run: bool = typer.Option(False, "--dry-run", help="Report the intended calls and perform none."),
239
238
  ) -> None:
240
239
  """Build, upload only what changed, and check the workflow definitions that were uploaded."""
241
240
  from blobhub_cli.commands import workflow
242
241
 
243
- workflow.deploy(manifest, tuple(definition), force, strict_imports, dry_run)
242
+ workflow.deploy(manifest, tuple(definition), force, dry_run)
244
243
 
245
244
  @workflow_app.command("eject")
246
245
  def workflow_eject(
@@ -258,7 +257,10 @@ def build_app() -> typer.Typer:
258
257
  @workflow_app.command("execute")
259
258
  def workflow_execute(
260
259
  manifest: Path = typer.Option(..., "-f", "--file", help="Path to the workflow manifest."), # noqa: B008
261
- definition: str = typer.Option(..., "--definition", help="Alias of the workflow definition to execute."),
260
+ definition: str = typer.Option(
261
+ ..., "--definition",
262
+ help="A manifest alias, or <blob-id>/<revision>/<definition> or <org>/<blob>/<revision>/<definition>.",
263
+ ),
262
264
  session: str = typer.Option(None, "--session", help="Session alias or id; created if the alias is new."),
263
265
  watch: bool = typer.Option(False, "--watch", help="Follow execution events until the run finishes."),
264
266
  open_browser: bool = typer.Option(False, "--open", help="Open the execution in the web debugger."),
@@ -315,10 +317,11 @@ def build_app() -> typer.Typer:
315
317
  def scheduler_pull(
316
318
  manifest: Path = typer.Option(..., "-f", "--file", help="Path to the scheduler manifest."), # noqa: B008
317
319
  ) -> None:
318
- """Reverse each deployed schedule's stored UUIDs back into a symbolic manifest.
320
+ """Rewrite the manifest's schedules as the platform stores them, field for field.
319
321
 
320
- Not the inverse of deploy: deploy resolves symbols into UUIDs and may create a session, a
321
- side effect pull cannot undo."""
322
+ Replaces every entry in the file, local edits included, and rewrites a manifest in the retired
323
+ layout. Ids stay ids: nothing is turned back into an alias. Not the inverse of deploy: deploy
324
+ may create a session, a side effect pull cannot undo."""
322
325
  from blobhub_cli.commands import scheduler
323
326
 
324
327
  scheduler.pull(manifest)
@@ -333,10 +336,10 @@ def build_app() -> typer.Typer:
333
336
  ) -> None:
334
337
  """Make the manifest real: create schedules that don't exist, update those that do, by alias.
335
338
 
336
- Refuses before writing anything if any target fails validation -- a half-deployed schedule
337
- set is worse than a refused one, since the half that landed is already firing. Not pull's
338
- inverse: deploy resolves symbols into UUIDs and may create a session, a side effect pull
339
- cannot undo, even under --dry-run."""
339
+ Validates every entry before writing anything -- a half-deployed schedule set is worse than a
340
+ refused one, since the half that landed is already firing -- then sends each as written, a
341
+ pinned target's session as its resolved id. Not pull's inverse: deploy may create a session, a
342
+ side effect pull cannot undo, even under --dry-run."""
340
343
  from blobhub_cli.commands import scheduler
341
344
 
342
345
  scheduler.deploy(manifest, dry_run)
@@ -377,7 +380,8 @@ def main(argv: list[str] | None = None) -> int:
377
380
  # so wrap it in a CliExit rather than let a raw traceback reach the user. Only AUTH_INVALID
378
381
  # has a hint that's genuinely a runnable command; inventing one for the rest would be prose.
379
382
  hint = "blobhub login" if exc.code == codes.AUTH_INVALID else ""
380
- console.error(CliExit(exc.code, str(exc), hint))
383
+ limit = exc.limit if isinstance(exc, ApiCommandError) else None
384
+ console.error(CliExit(exc.code, str(exc), hint, limit=limit))
381
385
  return 1
382
386
  except Exception as exc: # usage errors carry exit_code; anything else is a real bug
383
387
  # 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
 
@@ -43,6 +42,9 @@ ALIAS_IN_USE = "ALIAS_IN_USE"
43
42
  LIMIT_EXCEEDED = "LIMIT_EXCEEDED"
44
43
  PRUNE_NOT_CONFIRMED = "PRUNE_NOT_CONFIRMED"
45
44
 
45
+ # Workflow references
46
+ WORKFLOW_REFERENCE_INVALID = "WORKFLOW_REFERENCE_INVALID"
47
+
46
48
  # Format
47
49
  FORMAT_UNSUPPORTED = "FORMAT_UNSUPPORTED"
48
50
  FORMAT_PARSE_ERROR = "FORMAT_PARSE_ERROR"
@@ -79,6 +81,7 @@ SESSION_NOT_ACCESSIBLE = "SESSION_NOT_ACCESSIBLE"
79
81
  SESSION_NOT_OPEN = "SESSION_NOT_OPEN"
80
82
  DEFINITION_NOT_EXECUTABLE = "DEFINITION_NOT_EXECUTABLE"
81
83
  EXECUTION_FAILED = "EXECUTION_FAILED"
84
+ EXECUTION_OUTCOME_UNKNOWN = "EXECUTION_OUTCOME_UNKNOWN"
82
85
 
83
86
  # Schedules
84
87
  SCHEDULE_ALIAS_REQUIRED = "SCHEDULE_ALIAS_REQUIRED"
@@ -7,10 +7,11 @@ Those are right for a command about to drive a workflow blob and wrong for an in
7
7
  in an unusable phase is precisely what someone runs `blob show` to diagnose.
8
8
  """
9
9
 
10
+ import json
10
11
  from collections.abc import Callable
11
12
  from datetime import UTC, datetime
12
13
 
13
- from blobhub_cli import codes, console
14
+ from blobhub_cli import console
14
15
  from blobhub_cli.api.client import Client
15
16
  from blobhub_cli.api.commands import Api
16
17
  from blobhub_cli.api.errors import ApiAuthError, ApiError
@@ -19,7 +20,6 @@ from blobhub_cli.blob.reference import BlobRef
19
20
  from blobhub_cli.compiler import allowlist
20
21
  from blobhub_cli.config import settings
21
22
  from blobhub_cli.config.settings import Settings
22
- from blobhub_cli.exit import CliExit
23
23
 
24
24
 
25
25
  def _default_api_factory(resolved: Settings) -> Api:
@@ -135,11 +135,14 @@ def revisions(ref_text: str, *, api_factory: Callable[[Settings], Api] = _defaul
135
135
 
136
136
  def _render_limits(payload: dict) -> None:
137
137
  print(f"Limits for {payload['blob']}")
138
- for name, entry in sorted(payload["limits"].items()):
139
- fields = (
140
- ", ".join(f"{key}={entry[key]}" for key in sorted(entry)) if isinstance(entry, dict) else str(entry)
141
- )
142
- print(f" {name:<40} {fields}")
138
+ ids = [entry["id"] for group in payload["groups"] for entry in group["limits"]]
139
+ width = max(map(len, ids), default=2)
140
+ print(f" {'ID':<{width}} {'HELD':<8} {'SOURCE':<8} VALUE")
141
+ for group in payload["groups"]:
142
+ print(f"{group['label']} ({group['group']})")
143
+ for entry in group["limits"]:
144
+ print(f" {entry['id']:<{width}} {entry.get('held', '')!s:<8} {entry['source']:<8} "
145
+ f"{json.dumps(entry['value'])}")
143
146
  if payload["refreshed"]:
144
147
  print(f"Allowlist cache written to {payload['cache_path']}")
145
148
 
@@ -147,28 +150,14 @@ def _render_limits(payload: dict) -> None:
147
150
  def limits(ref_text: str, refresh: bool, *, api_factory: Callable[[Settings], Api] = _default_api_factory) -> dict:
148
151
  ref, resolved = _resolve_ref(ref_text, "blob limits")
149
152
  api = api_factory(resolved)
150
-
151
- # A hard failure, not a degradation to cache -- deliberately unlike `allowlist.fetch`, which
152
- # swallows this same 403. The difference is what the caller asked for: `build` wants imports
153
- # validated and a cached answer serves that; this command wants the limits, and a partial
154
- # answer silently sourced from a week-old file is worse than a clear refusal.
155
153
  try:
156
- record = api.get_blob_limits(ref.org, ref.name)
154
+ groups = api.get_blob_limits(ref.org, ref.name)
157
155
  except ApiAuthError as exc:
158
- # The same handler 403s an unknown blob (spec §1.1), so the message must not assert a cause
159
- # it cannot know -- sending someone off to mint an admin key over a typo'd blob name is a
160
- # worse failure than saying plainly that the platform conflates the two.
161
- raise CliExit(
162
- codes.LIMITS_NOT_ACCESSIBLE,
163
- f"limits for {ref.label()} are not readable: reading limits needs an admin-scoped key "
164
- f"(the read/write keys recommended for automation cannot), or the blob is absent -- "
165
- f"the platform returns the same 403 for both",
166
- "blobhub whoami",
167
- ) from exc
156
+ raise reference.not_accessible(ref) from exc
168
157
 
169
158
  cache_path = None
170
159
  if refresh:
171
- modules = set(record.get(allowlist.LIMIT_KEY, {}).get("value", []))
160
+ modules = allowlist.modules_from(groups)
172
161
  # Keyed on the raw positional text, exactly as `build`/`diff`/`deploy` key on the manifest's
173
162
  # `blob:` field as written: `normalize_cache_key` org-qualifies precisely when its input
174
163
  # does, so keying on `ref.label()` here would write `acme/flow.yaml` for the bare reference
@@ -176,6 +165,6 @@ def limits(ref_text: str, refresh: bool, *, api_factory: Callable[[Settings], Ap
176
165
  cache_key = allowlist.normalize_cache_key(ref_text)
177
166
  cache_path = str(allowlist.store(cache_key, modules, datetime.now(UTC).isoformat(), ref.org, ref.name))
178
167
 
179
- payload = {"blob": ref.label(), "limits": record, "refreshed": refresh, "cache_path": cache_path}
168
+ payload = {"blob": ref.label(), "groups": groups, "refreshed": refresh, "cache_path": cache_path}
180
169
  console.result(payload, _render_limits)
181
170
  return payload
@@ -1,11 +1,11 @@
1
1
  """`workflow execute`: create an execution of a workflow definition, optionally following it.
2
2
 
3
- Contract: `--definition` must name a `workflow`-category manifest entry -- a `playground` alias is
4
- refused with `DEFINITION_NOT_EXECUTABLE` before anything is created, and an alias absent from the
5
- manifest surfaces F1's `DEFINITION_NOT_ACCESSIBLE` straight out of `load_set`. The definition is
6
- addressed by id, never by `alias=`, in the `create_execution` call: the server resolves a bare alias
7
- with no category filter, so an alias shared across categories could resolve to the wrong definition
8
- (spec §4.3, F1 spec §1.3).
3
+ Contract: `--definition` is a workflow reference in the path form (`workflow/reference.py`), sent to
4
+ `create_execution` as the `workflow` object exactly as typed, beside `target: {session: <id>}` -- the id of the
5
+ session `resolve_session` found or created. A bare alias must name a deployed
6
+ `workflow`-category manifest entry -- a `playground` one is refused with `DEFINITION_NOT_EXECUTABLE`
7
+ before anything is created -- while a qualified reference names a definition outside the local
8
+ package, so it skips the manifest and is resolved by the platform alone.
9
9
  Invariant: `console.result` always runs before an `EXECUTION_FAILED` raise, so a `--json` consumer
10
10
  still receives the payload -- including the execution id -- for a failed run. The debugger URL is
11
11
  always in the payload; `--open` launches a browser only when `console` is not in JSON mode
@@ -22,11 +22,13 @@ from pathlib import Path
22
22
  from blobhub_cli import codes, console
23
23
  from blobhub_cli.api.client import Client
24
24
  from blobhub_cli.api.commands import Api
25
+ from blobhub_cli.api.errors import ApiAuthError, ApiCommandError, ApiError, outcome_unknown
25
26
  from blobhub_cli.config import settings
26
27
  from blobhub_cli.config.settings import Settings
27
28
  from blobhub_cli.exit import CliExit
28
29
  from blobhub_cli.manifest import resolver, workflow
29
- from blobhub_cli.workflow import definitions, executions
30
+ from blobhub_cli.workflow import definitions, executions, reference
31
+ from blobhub_cli.workflow.reference import WorkflowRef
30
32
 
31
33
  # blobhub-web serves the routes `Navigation.workflowPath` builds (src/context/nav.js) from
32
34
  # blobhub.io; the path itself -- `/{org_id}/{blob_id}/debugger/{session_id}/{execution_id}` -- is
@@ -49,9 +51,69 @@ def _render_execute(payload: dict) -> None:
49
51
  print(f"debugger: {payload['url']}")
50
52
 
51
53
 
54
+ def _check_local_definition(api: Api, target: resolver.Target, manifest: workflow.WorkflowManifest, alias: str) -> None:
55
+ set_ = definitions.load_set(
56
+ api, target.revision_id, manifest, only=(alias,), org=target.org_id, blob=target.blob_id
57
+ )
58
+ # `load_set(only=...)` already raised DEFINITION_NOT_ACCESSIBLE for an alias absent from the manifest. An
59
+ # alias declared in both categories has no qualifier here, so the workflow-category entry is preferred.
60
+ entry = next((e for e in set_.entries if e.category == "workflow"), set_.entries[0])
61
+ if entry.category != "workflow":
62
+ raise CliExit(
63
+ codes.DEFINITION_NOT_EXECUTABLE,
64
+ f"{alias!r} is a {entry.category!r} definition, and the platform executes workflow definitions only",
65
+ f"blobhub workflow ls -f {manifest.base.path}",
66
+ )
67
+ if (entry.category, entry.alias) not in set_.remote:
68
+ raise CliExit(
69
+ codes.DEFINITION_NOT_ACCESSIBLE,
70
+ f"{alias!r} is in the manifest but not deployed on revision {target.revision_id}",
71
+ f"blobhub workflow deploy -f {manifest.base.path}",
72
+ )
73
+
74
+
75
+ def _debugger_url(target: resolver.Target, session_id: str, execution_id: str | None = None) -> str:
76
+ """The web debugger's page for a session, or for one of its executions."""
77
+ path = f"{DEBUGGER_BASE_URL}/{target.org_id}/{target.blob_id}/debugger/{session_id}"
78
+ if execution_id is not None:
79
+ path = f"{path}/{execution_id}"
80
+ # `?revision=` carries the RESOLVED id, never the manifest's symbol: an execution lives in one
81
+ # revision, and a link without it opens on whatever revision the reader last chose, where this
82
+ # session does not exist. blobhub-web drops the parameter itself once it names the default.
83
+ return f"{path}?revision={target.revision_id}"
84
+
85
+
86
+ def _create_execution(api: Api, revision_id: str, session_id: str, ref: WorkflowRef, session_url: str) -> dict:
87
+ try:
88
+ return api.create_execution(revision_id, target={"session": session_id}, workflow=ref.to_object())
89
+ except ApiAuthError as exc:
90
+ raise CliExit(
91
+ codes.DEFINITION_NOT_ACCESSIBLE,
92
+ f"workflow {ref.path()!r} is absent or not reachable with this key, or this key cannot write to the "
93
+ "session's blob -- the platform answers the same 403 for each",
94
+ "blobhub whoami",
95
+ ) from exc
96
+ except ApiCommandError as exc:
97
+ if exc.error != "invalid_definition_category":
98
+ raise
99
+ raise CliExit(
100
+ codes.DEFINITION_NOT_EXECUTABLE,
101
+ f"{ref.path()!r} is a playground definition, and the platform executes workflow definitions only",
102
+ ) from exc
103
+ except ApiError as exc:
104
+ if not outcome_unknown(exc):
105
+ raise
106
+ raise CliExit(
107
+ codes.EXECUTION_OUTCOME_UNKNOWN,
108
+ f"creating the execution of {ref.path()!r} failed after the request may have reached the platform "
109
+ f"({exc}), so a run may have started. Check session {session_id} for it before running the command "
110
+ f"again: {session_url}",
111
+ ) from exc
112
+
113
+
52
114
  def execute(
53
115
  manifest_path: Path,
54
- definition_alias: str,
116
+ definition: str,
55
117
  session: str | None,
56
118
  watch: bool,
57
119
  open_browser: bool,
@@ -59,40 +121,20 @@ def execute(
59
121
  api_factory: Callable[[Settings], Api] = _default_api_factory,
60
122
  browser: Callable[[str], object] = webbrowser.open,
61
123
  ) -> dict:
124
+ ref = reference.parse(definition, hint=f"blobhub workflow execute -f {manifest_path} --definition <reference>")
62
125
  resolved = settings.resolve()
63
126
  api = api_factory(resolved)
64
127
  manifest = workflow.load(manifest_path)
65
128
  target = resolver.resolve(
66
129
  api, manifest.base, require_writable=False, expect=resolver.WORKFLOW_BLOB, default_org=resolved.org
67
130
  )
68
- set_ = definitions.load_set(
69
- api, target.revision_id, manifest, only=(definition_alias,), org=target.org_id, blob=target.blob_id
70
- )
71
-
72
- # `load_set(only=...)` already raised DEFINITION_NOT_ACCESSIBLE if the alias isn't in the
73
- # manifest at all, so at least one entry is guaranteed here. A manifest may legally declare the
74
- # same alias in both categories (F1 spec §1.3); `--definition` has no category qualifier to
75
- # disambiguate that case, so this deliberately takes the first and does not detect the collision.
76
- entry = set_.entries[0]
77
- if entry.category != "workflow":
78
- raise CliExit(
79
- codes.DEFINITION_NOT_EXECUTABLE,
80
- f"{definition_alias!r} is a {entry.category!r} definition, and the platform executes "
81
- "workflow definitions only",
82
- f"blobhub workflow ls -f {manifest.base.path}",
83
- )
131
+ if not ref.qualified:
132
+ _check_local_definition(api, target, manifest, ref.definition)
84
133
 
85
134
  session_id, session_created = executions.resolve_session(api, target.revision_id, session)
86
- definition_id = set_.remote[(entry.category, entry.alias)].id
87
- execution = api.create_execution(target.revision_id, session_id, definition_id=definition_id)
135
+ execution = _create_execution(api, target.revision_id, session_id, ref, _debugger_url(target, session_id))
88
136
 
89
- # `?revision=` carries the RESOLVED id, never the manifest's symbol: an execution lives in one
90
- # revision, and a link without it opens on whatever revision the reader last chose, where this
91
- # session does not exist. blobhub-web drops the parameter itself once it names the default.
92
- url = (
93
- f"{DEBUGGER_BASE_URL}/{target.org_id}/{target.blob_id}/debugger/{session_id}/{execution['id']}"
94
- f"?revision={target.revision_id}"
95
- )
137
+ url = _debugger_url(target, session_id, execution["id"])
96
138
  # Launching a browser is a side effect, not output -- there is no `console.stream()` call this
97
139
  # could route through -- so it is the one place this command asks `console.json_mode()` directly.
98
140
  if open_browser and not console.json_mode():
@@ -102,7 +144,7 @@ def execute(
102
144
  "execution_id": execution["id"],
103
145
  "session_id": session_id,
104
146
  "session_created": session_created,
105
- "definition": definition_alias,
147
+ "definition": definition,
106
148
  "status": execution.get("status", ""),
107
149
  "url": url,
108
150
  }
@@ -124,7 +166,7 @@ def execute(
124
166
  raise CliExit(
125
167
  codes.EXECUTION_FAILED,
126
168
  f"execution {execution['id']} reached a terminal 'failed' status",
127
- f"blobhub workflow execute -f {manifest.base.path} --definition {definition_alias} --open",
169
+ f"blobhub workflow execute -f {manifest.base.path} --definition {definition} --open",
128
170
  )
129
171
 
130
172
  console.result(payload, _render_execute)