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.
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/PKG-INFO +1 -1
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/__init__.py +1 -1
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/api/client.py +28 -8
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/api/commands.py +5 -27
- blobhub_cli-0.6.0/src/blobhub_cli/api/errors.py +90 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/cli/app.py +17 -13
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/codes.py +4 -1
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/blob.py +14 -25
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/execute.py +77 -35
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/scheduler.py +165 -196
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/workflow.py +2 -5
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/compiler/allowlist.py +23 -18
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/compiler/sandbox.py +3 -3
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/console.py +2 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/exit.py +4 -2
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/ids.py +3 -1
- blobhub_cli-0.6.0/src/blobhub_cli/limits.py +47 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/manifest/base.py +6 -9
- blobhub_cli-0.6.0/src/blobhub_cli/manifest/scheduler.py +369 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/manifest/workflow.py +10 -1
- blobhub_cli-0.6.0/src/blobhub_cli/scheduler/references.py +237 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/workflow/definitions.py +4 -4
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/workflow/executions.py +19 -3
- blobhub_cli-0.6.0/src/blobhub_cli/workflow/reference.py +102 -0
- blobhub_cli-0.4.0/src/blobhub_cli/api/errors.py +0 -51
- blobhub_cli-0.4.0/src/blobhub_cli/limits.py +0 -32
- blobhub_cli-0.4.0/src/blobhub_cli/manifest/scheduler.py +0 -269
- blobhub_cli-0.4.0/src/blobhub_cli/scheduler/references.py +0 -273
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/.gitignore +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/LICENSE +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/README.md +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/pyproject.toml +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/__main__.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/api/__init__.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/blob/__init__.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/blob/reference.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/cli/__init__.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/__init__.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/auth.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/completion.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/doctor.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/commands/eject.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/compiler/__init__.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/compiler/emit.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/compiler/graph.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/config/__init__.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/config/credentials.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/config/settings.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/config/sources.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/definition/__init__.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/definition/drift.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/definition/eject.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/definition/hashing.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/definition/model.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/formats/__init__.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/formats/io.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/manifest/__init__.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/manifest/resolver.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/scheduler/__init__.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/scheduler/schedules.py +0 -0
- {blobhub_cli-0.4.0 → blobhub_cli-0.6.0}/src/blobhub_cli/statestore.py +0 -0
- {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.
|
|
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/
|
|
@@ -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
|
|
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(
|
|
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 =
|
|
119
|
+
last_error = _network_error(exc)
|
|
104
120
|
else:
|
|
105
121
|
try:
|
|
106
122
|
return self._handle(resp, gate_status=gate_status)
|
|
107
|
-
except
|
|
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
|
-
|
|
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")["
|
|
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 --
|
|
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,
|
|
84
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
"""
|
|
320
|
+
"""Rewrite the manifest's schedules as the platform stores them, field for field.
|
|
319
321
|
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
print(f"
|
|
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
|
-
|
|
154
|
+
groups = api.get_blob_limits(ref.org, ref.name)
|
|
157
155
|
except ApiAuthError as exc:
|
|
158
|
-
|
|
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 =
|
|
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(), "
|
|
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`
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
api, target
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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 {
|
|
169
|
+
f"blobhub workflow execute -f {manifest.base.path} --definition {definition} --open",
|
|
128
170
|
)
|
|
129
171
|
|
|
130
172
|
console.result(payload, _render_execute)
|