blobhub-cli 0.2.0__tar.gz → 0.4.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.2.0 → blobhub_cli-0.4.0}/PKG-INFO +2 -2
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/README.md +1 -1
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/__init__.py +1 -1
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/client.py +18 -18
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/commands.py +34 -38
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/cli/app.py +2 -2
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/codes.py +0 -1
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/blob.py +37 -43
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/execute.py +9 -2
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/scheduler.py +33 -25
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/workflow.py +1 -1
- blobhub_cli-0.4.0/src/blobhub_cli/ids.py +64 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/limits.py +0 -2
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/base.py +19 -3
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/resolver.py +35 -35
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/scheduler.py +39 -7
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/scheduler/references.py +41 -28
- blobhub_cli-0.2.0/src/blobhub_cli/ids.py +0 -18
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/.gitignore +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/LICENSE +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/pyproject.toml +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/__main__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/errors.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/blob/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/blob/reference.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/cli/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/auth.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/completion.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/doctor.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/eject.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/allowlist.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/emit.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/graph.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/sandbox.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/credentials.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/settings.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/sources.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/console.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/drift.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/eject.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/hashing.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/model.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/exit.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/formats/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/formats/io.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/workflow.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/scheduler/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/scheduler/schedules.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/statestore.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/workflow/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/workflow/definitions.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/workflow/executions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: blobhub-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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/
|
|
@@ -151,7 +151,7 @@ my-project/
|
|
|
151
151
|
type: workflow_blob_deployment
|
|
152
152
|
version: "1.0"
|
|
153
153
|
blob: acme-corp/checkout # <org>/<blob>, or a bare <blob> plus --org/BLOBHUB_ORG
|
|
154
|
-
revision:
|
|
154
|
+
revision: default # the blob's default revision, or a pinned revision UUID
|
|
155
155
|
definitions:
|
|
156
156
|
- alias: checkout_flow
|
|
157
157
|
source: definitions/checkout_flow.json
|
|
@@ -98,7 +98,7 @@ my-project/
|
|
|
98
98
|
type: workflow_blob_deployment
|
|
99
99
|
version: "1.0"
|
|
100
100
|
blob: acme-corp/checkout # <org>/<blob>, or a bare <blob> plus --org/BLOBHUB_ORG
|
|
101
|
-
revision:
|
|
101
|
+
revision: default # the blob's default revision, or a pinned revision UUID
|
|
102
102
|
definitions:
|
|
103
103
|
- alias: checkout_flow
|
|
104
104
|
source: definitions/checkout_flow.json
|
|
@@ -3,11 +3,10 @@
|
|
|
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
|
-
`query
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
validates its targets
|
|
10
|
-
fixed to one engine could not do that.
|
|
6
|
+
Neither `query`, `command`, nor `raw_query` names which component serves a data-channel call --
|
|
7
|
+
the target revision's blob type decides that server-side, so there is nothing for a caller to
|
|
8
|
+
supply here at all, even for a scheduler `deploy` that legitimately writes schedules against one
|
|
9
|
+
blob and validates its targets against another.
|
|
11
10
|
Invariant: `Content-Type: application/json` is sent on every request, and the API key is
|
|
12
11
|
never logged, echoed, or included in any exception message. This module is the only one that
|
|
13
12
|
performs I/O against the BlobHub API -- the single sanctioned exception elsewhere is `doctor`'s
|
|
@@ -28,9 +27,6 @@ from blobhub_cli.api.errors import (
|
|
|
28
27
|
ApiTransientError,
|
|
29
28
|
)
|
|
30
29
|
|
|
31
|
-
WORKFLOW_ENGINE = "workflow_blobhub"
|
|
32
|
-
SCHEDULER_ENGINE = "scheduler_blobhub"
|
|
33
|
-
|
|
34
30
|
_TRANSIENT = (ApiRateLimited, ApiTransientError, ApiNetworkError)
|
|
35
31
|
_ATTEMPTS = 3
|
|
36
32
|
_BASE_DELAY = 0.5
|
|
@@ -61,8 +57,12 @@ class Client:
|
|
|
61
57
|
def __exit__(self, *exc_info: object) -> None:
|
|
62
58
|
self._http.close()
|
|
63
59
|
|
|
64
|
-
def get(self, path: str) -> dict:
|
|
65
|
-
|
|
60
|
+
def get(self, path: str, params: dict[str, object] | None = None) -> dict:
|
|
61
|
+
# A `None` entry is dropped rather than sent: the paged routes validate their query
|
|
62
|
+
# parameters instead of ignoring them, so `?limit=` or `?cursor=None` is a 400.
|
|
63
|
+
if params is not None:
|
|
64
|
+
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)
|
|
66
66
|
|
|
67
67
|
def probe(self, path: str = "/users/me") -> tuple[int, float]:
|
|
68
68
|
"""One unretried request, returning (status_code, elapsed_ms) without gating the body.
|
|
@@ -78,20 +78,20 @@ class Client:
|
|
|
78
78
|
raise ApiNetworkError(str(exc)) from exc
|
|
79
79
|
return resp.status_code, (time.monotonic() - start) * 1000
|
|
80
80
|
|
|
81
|
-
def query(self, revision_id: str, command: str,
|
|
82
|
-
return self._data_request(revision_id, "query", command,
|
|
81
|
+
def query(self, revision_id: str, command: str, **args: object) -> dict:
|
|
82
|
+
return self._data_request(revision_id, "query", command, gate_status=True, **args)
|
|
83
83
|
|
|
84
|
-
def command(self, revision_id: str, command: str,
|
|
85
|
-
return self._data_request(revision_id, "command", command,
|
|
84
|
+
def command(self, revision_id: str, command: str, **args: object) -> dict:
|
|
85
|
+
return self._data_request(revision_id, "command", command, gate_status=True, **args)
|
|
86
86
|
|
|
87
|
-
def raw_query(self, revision_id: str, command: str,
|
|
88
|
-
return self._data_request(revision_id, "query", command,
|
|
87
|
+
def raw_query(self, revision_id: str, command: str, **args: object) -> dict:
|
|
88
|
+
return self._data_request(revision_id, "query", command, gate_status=False, **args)
|
|
89
89
|
|
|
90
90
|
def _data_request(
|
|
91
|
-
self, revision_id: str, channel: str, command: str, *,
|
|
91
|
+
self, revision_id: str, channel: str, command: str, *, gate_status: bool, **args: object
|
|
92
92
|
) -> dict:
|
|
93
93
|
path = f"/revisions/{revision_id}/data/{channel}"
|
|
94
|
-
body = {"
|
|
94
|
+
body = {"command": command, **args}
|
|
95
95
|
return self._send("POST", path, gate_status=gate_status, json=body)
|
|
96
96
|
|
|
97
97
|
def _send(self, method: str, path: str, *, gate_status: bool, **kwargs: object) -> dict:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Typed wrappers over `Client`, one method per platform read or
|
|
1
|
+
"""Typed wrappers over `Client`, one method per platform read or data-channel command.
|
|
2
2
|
|
|
3
3
|
Contract: each method sends exactly one request and returns only the envelope key(s) the
|
|
4
4
|
platform documents for that command, adding no caching, retries, or aggregation of its own.
|
|
@@ -7,7 +7,7 @@ Invariant: `create_definition` includes `layout` in the request body only when i
|
|
|
7
7
|
400 for a workflow-category definition, which never has a `layout` field.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
from blobhub_cli.api.client import
|
|
10
|
+
from blobhub_cli.api.client import Client
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class Api:
|
|
@@ -26,41 +26,45 @@ class Api:
|
|
|
26
26
|
def get_revision(self, revision_id: str) -> dict:
|
|
27
27
|
return self._client.get(f"/revisions/{revision_id}")["revision"]
|
|
28
28
|
|
|
29
|
-
def
|
|
30
|
-
|
|
29
|
+
def resolve_revision(self, org: str, blob: str, ref: str) -> dict:
|
|
30
|
+
# `ref` is a revision id or the literal `default`. Unlike `get_revision`, this route resolves
|
|
31
|
+
# the ref *within the blob*, so a revision id belonging to another blob answers the same 404
|
|
32
|
+
# as one that does not exist -- which `/revisions/{id}` cannot do, having no blob in its path.
|
|
33
|
+
return self._client.get(f"/blobs/{org}/{blob}/revisions/{ref}")["revision"]
|
|
34
|
+
|
|
35
|
+
def list_revisions(
|
|
36
|
+
self, org: str, blob: str, *, limit: int | None = None, cursor: str | None = None
|
|
37
|
+
) -> tuple[list[dict], str | None]:
|
|
38
|
+
# One page and the next cursor -- this method deliberately does not loop, for the reason
|
|
39
|
+
# list_schedules gives: a loop here would hide the paging from every test that uses it.
|
|
40
|
+
body = self._client.get(
|
|
41
|
+
f"/blobs/{org}/{blob}/revisions", {"limit": limit, "cursor": cursor})
|
|
42
|
+
return body["revisions"], body["cursor"]
|
|
31
43
|
|
|
32
44
|
def get_blob_limits(self, org: str, blob: str) -> dict:
|
|
33
45
|
return self._client.get(f"/blobs/{org}/{blob}/limits")["limits"]
|
|
34
46
|
|
|
35
47
|
def list_definitions(self, revision_id: str, category: str) -> list[dict]:
|
|
36
|
-
return self._client.query(
|
|
37
|
-
revision_id, "list_definitions", engine=WORKFLOW_ENGINE, category=category
|
|
38
|
-
)["definitions"]
|
|
48
|
+
return self._client.query(revision_id, "list_definitions", category=category)["definitions"]
|
|
39
49
|
|
|
40
50
|
def download_definition(self, revision_id: str, definition_id: str) -> tuple[dict, dict]:
|
|
41
|
-
body = self._client.query(
|
|
42
|
-
revision_id, "download_definition", engine=WORKFLOW_ENGINE, definition_id=definition_id
|
|
43
|
-
)
|
|
51
|
+
body = self._client.query(revision_id, "download_definition", definition_id=definition_id)
|
|
44
52
|
return body["definition"], body["definition_object"]
|
|
45
53
|
|
|
46
54
|
def create_definition(self, revision_id: str, alias: str, category: str, layout: dict | None = None) -> dict:
|
|
47
55
|
args: dict[str, object] = {"alias": alias, "category": category}
|
|
48
56
|
if layout is not None:
|
|
49
57
|
args["layout"] = layout
|
|
50
|
-
return self._client.command(revision_id, "create_definition",
|
|
58
|
+
return self._client.command(revision_id, "create_definition", **args)["definition"]
|
|
51
59
|
|
|
52
60
|
def upload_definition(self, revision_id: str, definition_id: str, document: dict) -> None:
|
|
53
|
-
self._client.command(
|
|
54
|
-
revision_id, "upload_definition", engine=WORKFLOW_ENGINE, definition_id=definition_id, definition=document
|
|
55
|
-
)
|
|
61
|
+
self._client.command(revision_id, "upload_definition", definition_id=definition_id, definition=document)
|
|
56
62
|
|
|
57
63
|
def delete_definition(self, revision_id: str, definition_id: str) -> None:
|
|
58
|
-
self._client.command(revision_id, "delete_definition",
|
|
64
|
+
self._client.command(revision_id, "delete_definition", definition_id=definition_id)
|
|
59
65
|
|
|
60
66
|
def check_definition(self, revision_id: str, definition_id: str) -> tuple[str, list[dict]]:
|
|
61
|
-
body = self._client.raw_query(
|
|
62
|
-
revision_id, "check_definition", engine=WORKFLOW_ENGINE, definition_id=definition_id
|
|
63
|
-
)
|
|
67
|
+
body = self._client.raw_query(revision_id, "check_definition", definition_id=definition_id)
|
|
64
68
|
return body.get("status"), body.get("events", [])
|
|
65
69
|
|
|
66
70
|
def create_session(self, revision_id: str, *, alias: str | None = None, description: str | None = None) -> dict:
|
|
@@ -69,12 +73,12 @@ class Api:
|
|
|
69
73
|
args["alias"] = alias
|
|
70
74
|
if description is not None:
|
|
71
75
|
args["description"] = description
|
|
72
|
-
return self._client.command(revision_id, "create_session",
|
|
76
|
+
return self._client.command(revision_id, "create_session", **args)["session"]
|
|
73
77
|
|
|
74
78
|
def get_session(self, revision_id: str, session: str) -> dict:
|
|
75
79
|
# Accepts an id OR an alias -- `get_session_by_session_id_or_alias` server-side. Unlike
|
|
76
80
|
# `create_execution`, which resolves the id only (spec §1.1).
|
|
77
|
-
return self._client.query(revision_id, "get_session",
|
|
81
|
+
return self._client.query(revision_id, "get_session", session_id=session)["session"]
|
|
78
82
|
|
|
79
83
|
def create_execution(self, revision_id: str, session_id: str, *, definition_id: str) -> dict:
|
|
80
84
|
# Addressed by id, not alias: `CREATE_EXECUTION_SCHEMA` accepts either, but the server
|
|
@@ -84,13 +88,11 @@ class Api:
|
|
|
84
88
|
# definition -- an alias-addressed execution could resolve to either, unpredictably. The
|
|
85
89
|
# caller already has the id from `load_set`, so there is no reason to take that risk.
|
|
86
90
|
return self._client.command(
|
|
87
|
-
revision_id, "create_execution",
|
|
91
|
+
revision_id, "create_execution", session_id=session_id, definition_id=definition_id
|
|
88
92
|
)["execution"]
|
|
89
93
|
|
|
90
94
|
def get_execution(self, revision_id: str, execution_id: str) -> dict:
|
|
91
|
-
return self._client.query(revision_id, "get_execution",
|
|
92
|
-
"execution"
|
|
93
|
-
]
|
|
95
|
+
return self._client.query(revision_id, "get_execution", execution_id=execution_id)["execution"]
|
|
94
96
|
|
|
95
97
|
def list_execution_events(
|
|
96
98
|
self,
|
|
@@ -101,16 +103,14 @@ class Api:
|
|
|
101
103
|
ascending: bool = True,
|
|
102
104
|
limit: int | None = None,
|
|
103
105
|
) -> list[dict]:
|
|
104
|
-
# Keys are omitted rather than sent as null: every
|
|
106
|
+
# Keys are omitted rather than sent as null: every command schema is
|
|
105
107
|
# additionalProperties: false, so one stray key is a 400, not an ignored field.
|
|
106
108
|
args: dict[str, object] = {"execution_id": execution_id, "ascending": ascending}
|
|
107
109
|
if created_since is not None:
|
|
108
110
|
args["created_since"] = created_since
|
|
109
111
|
if limit is not None:
|
|
110
112
|
args["limit"] = limit
|
|
111
|
-
return self._client.query(revision_id, "list_execution_events",
|
|
112
|
-
"execution_events"
|
|
113
|
-
]
|
|
113
|
+
return self._client.query(revision_id, "list_execution_events", **args)["execution_events"]
|
|
114
114
|
|
|
115
115
|
def list_executions(
|
|
116
116
|
self, revision_id: str, session_id: str, *, start_execution_id: str | None = None
|
|
@@ -122,24 +122,20 @@ class Api:
|
|
|
122
122
|
args: dict[str, object] = {"session_id": session_id}
|
|
123
123
|
if start_execution_id is not None:
|
|
124
124
|
args["start_execution_id"] = start_execution_id
|
|
125
|
-
body = self._client.query(revision_id, "list_executions",
|
|
125
|
+
body = self._client.query(revision_id, "list_executions", **args)
|
|
126
126
|
return body["executions"], body["last_execution_id"]
|
|
127
127
|
|
|
128
128
|
def create_schedule(self, revision_id: str, **fields: object) -> dict:
|
|
129
|
-
return self._client.command(revision_id, "create_schedule",
|
|
129
|
+
return self._client.command(revision_id, "create_schedule", **fields)["schedule"]
|
|
130
130
|
|
|
131
131
|
def update_schedule(self, revision_id: str, schedule: str, **fields: object) -> dict:
|
|
132
|
-
return self._client.command(
|
|
133
|
-
revision_id, "update_schedule", engine=SCHEDULER_ENGINE, schedule_id=schedule, **fields
|
|
134
|
-
)["schedule"]
|
|
132
|
+
return self._client.command(revision_id, "update_schedule", schedule_id=schedule, **fields)["schedule"]
|
|
135
133
|
|
|
136
134
|
def delete_schedule(self, revision_id: str, schedule: str) -> None:
|
|
137
|
-
self._client.command(revision_id, "delete_schedule",
|
|
135
|
+
self._client.command(revision_id, "delete_schedule", schedule_id=schedule)
|
|
138
136
|
|
|
139
137
|
def get_schedule(self, revision_id: str, schedule: str) -> dict:
|
|
140
|
-
return self._client.query(revision_id, "get_schedule",
|
|
141
|
-
"schedule"
|
|
142
|
-
]
|
|
138
|
+
return self._client.query(revision_id, "get_schedule", schedule_id=schedule)["schedule"]
|
|
143
139
|
|
|
144
140
|
def list_schedules(
|
|
145
141
|
self, revision_id: str, *, start_schedule_id: str | None = None
|
|
@@ -153,5 +149,5 @@ class Api:
|
|
|
153
149
|
args: dict[str, object] = {}
|
|
154
150
|
if start_schedule_id is not None:
|
|
155
151
|
args["start_schedule_id"] = start_schedule_id
|
|
156
|
-
body = self._client.query(revision_id, "list_schedules",
|
|
152
|
+
body = self._client.query(revision_id, "list_schedules", **args)
|
|
157
153
|
return body["schedules"], body["last_schedule_id"]
|
|
@@ -156,7 +156,7 @@ def build_app() -> typer.Typer:
|
|
|
156
156
|
|
|
157
157
|
@blob_app.command("show")
|
|
158
158
|
def blob_show(blob: str = typer.Argument(..., help="Blob as '<org>/<name>', or a bare name with --org.")) -> None:
|
|
159
|
-
"""Show a blob's record and the phase of its
|
|
159
|
+
"""Show a blob's record and the phase of its default revision."""
|
|
160
160
|
from blobhub_cli.commands import blob as blob_cmd
|
|
161
161
|
|
|
162
162
|
blob_cmd.show(blob)
|
|
@@ -165,7 +165,7 @@ def build_app() -> typer.Typer:
|
|
|
165
165
|
def blob_revisions(
|
|
166
166
|
blob: str = typer.Argument(..., help="Blob as '<org>/<name>', or a bare name with --org."),
|
|
167
167
|
) -> None:
|
|
168
|
-
"""List a blob's revisions, newest first, marking the
|
|
168
|
+
"""List a blob's revisions, newest first, marking the default."""
|
|
169
169
|
from blobhub_cli.commands import blob as blob_cmd
|
|
170
170
|
|
|
171
171
|
blob_cmd.revisions(blob)
|
|
@@ -88,7 +88,6 @@ SCHEDULE_NOT_ACCESSIBLE = "SCHEDULE_NOT_ACCESSIBLE"
|
|
|
88
88
|
|
|
89
89
|
# Advisories (warnings, not exit-worthy failures)
|
|
90
90
|
DEFINITION_LIST_TRUNCATED = "DEFINITION_LIST_TRUNCATED"
|
|
91
|
-
REVISION_LIST_TRUNCATED = "REVISION_LIST_TRUNCATED"
|
|
92
91
|
ALLOWLIST_UNREADABLE = "ALLOWLIST_UNREADABLE"
|
|
93
92
|
ALLOWLIST_STALE = "ALLOWLIST_STALE"
|
|
94
93
|
ALLOWLIST_ENTRY_UNUSABLE = "ALLOWLIST_ENTRY_UNUSABLE"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Contract: each command takes a positional `<org/blob>`, resolves it through `blob.reference`, and
|
|
4
4
|
returns the exact dict `--json` emits; the Typer wrappers only parse arguments.
|
|
5
|
-
Invariant: none of these applies `manifest.resolver`'s
|
|
5
|
+
Invariant: none of these applies `manifest.resolver`'s blob-type or revision-phase gates.
|
|
6
6
|
Those are right for a command about to drive a workflow blob and wrong for an inspector -- a blob
|
|
7
7
|
in an unusable phase is precisely what someone runs `blob show` to diagnose.
|
|
8
8
|
"""
|
|
@@ -11,7 +11,6 @@ from collections.abc import Callable
|
|
|
11
11
|
from datetime import UTC, datetime
|
|
12
12
|
|
|
13
13
|
from blobhub_cli import codes, console
|
|
14
|
-
from blobhub_cli import limits as limits_module
|
|
15
14
|
from blobhub_cli.api.client import Client
|
|
16
15
|
from blobhub_cli.api.commands import Api
|
|
17
16
|
from blobhub_cli.api.errors import ApiAuthError, ApiError
|
|
@@ -45,13 +44,13 @@ def _render_show(payload: dict) -> None:
|
|
|
45
44
|
record = payload["record"]
|
|
46
45
|
print(f"{'Blob:':<12} {payload['blob']}")
|
|
47
46
|
for label, key in (
|
|
48
|
-
("Alias", "alias"), ("Id", "id"), ("Org", "org_id"), ("
|
|
49
|
-
("
|
|
47
|
+
("Alias", "alias"), ("Id", "id"), ("Org", "org_id"), ("Type", "type"),
|
|
48
|
+
("Visibility", "visibility"), ("Status", "status"),
|
|
50
49
|
):
|
|
51
50
|
print(f"{label + ':':<12} {record.get(key, '')}")
|
|
52
|
-
revision_id = record.get("
|
|
53
|
-
revision = payload["
|
|
54
|
-
print(f"{'
|
|
51
|
+
revision_id = record.get("default_revision_id")
|
|
52
|
+
revision = payload["default_revision"]
|
|
53
|
+
print(f"{'Default:':<12} {revision_id or '(none)'}")
|
|
55
54
|
if revision is None:
|
|
56
55
|
# Two distinct states, and telling them apart is the difference between "mint a better key"
|
|
57
56
|
# and "this blob has no revision yet".
|
|
@@ -68,7 +67,7 @@ def show(ref_text: str, *, api_factory: Callable[[Settings], Api] = _default_api
|
|
|
68
67
|
record = _get_blob(api, ref)
|
|
69
68
|
|
|
70
69
|
revision = None
|
|
71
|
-
revision_id = record.get("
|
|
70
|
+
revision_id = record.get("default_revision_id")
|
|
72
71
|
if revision_id:
|
|
73
72
|
try:
|
|
74
73
|
revision = api.get_revision(revision_id)
|
|
@@ -79,7 +78,7 @@ def show(ref_text: str, *, api_factory: Callable[[Settings], Api] = _default_api
|
|
|
79
78
|
|
|
80
79
|
# `blob` is the "org/name" label on every command in this group, never the record itself: one
|
|
81
80
|
# key name across three sibling commands cannot carry two types and stay a machine contract.
|
|
82
|
-
payload = {"blob": ref.label(), "record": record, "
|
|
81
|
+
payload = {"blob": ref.label(), "record": record, "default_revision": revision}
|
|
83
82
|
console.result(payload, _render_show)
|
|
84
83
|
return payload
|
|
85
84
|
|
|
@@ -89,7 +88,7 @@ def _render_revisions(payload: dict) -> None:
|
|
|
89
88
|
return
|
|
90
89
|
print(f"{'':2}{'REVISION':<38} {'PHASE':<9} {'STATUS':<11} {'CREATED':<26} COMMENT")
|
|
91
90
|
for item in payload["revisions"]:
|
|
92
|
-
marker = "* " if item["
|
|
91
|
+
marker = "* " if item["default"] else " "
|
|
93
92
|
print(f"{marker}{item['id']:<38} {item['phase']:<9} {item['status']:<11} "
|
|
94
93
|
f"{item['created_at']:<26} {item['comment']}")
|
|
95
94
|
|
|
@@ -98,41 +97,36 @@ def revisions(ref_text: str, *, api_factory: Callable[[Settings], Api] = _defaul
|
|
|
98
97
|
ref, resolved = _resolve_ref(ref_text, "blob revisions")
|
|
99
98
|
api = api_factory(resolved)
|
|
100
99
|
|
|
101
|
-
# The listing does not report which revision is
|
|
100
|
+
# The listing does not report which revision is the default, so the blob record is required, not
|
|
102
101
|
# merely enrichment.
|
|
103
102
|
record = _get_blob(api, ref)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
#
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
for item in found
|
|
132
|
-
),
|
|
133
|
-
key=lambda item: item["created_at"],
|
|
134
|
-
reverse=True,
|
|
135
|
-
)
|
|
103
|
+
default_revision_id = record.get("default_revision_id")
|
|
104
|
+
|
|
105
|
+
# Drained page by page, terminating on the cursor and never on a short page -- the same rule
|
|
106
|
+
# `scheduler/schedules.py`'s `load_all` follows, and for the same reason: the cursor is absent
|
|
107
|
+
# only once the query is exhausted, so a final page can legitimately be exactly full.
|
|
108
|
+
found: list[dict] = []
|
|
109
|
+
cursor: str | None = None
|
|
110
|
+
while True:
|
|
111
|
+
page, cursor = api.list_revisions(ref.org, ref.name, cursor=cursor)
|
|
112
|
+
found.extend(page)
|
|
113
|
+
if cursor is None:
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
# Order comes from the route, which queries the `blob_id-created_at` index newest-first and
|
|
117
|
+
# documents that. Re-sorting a complete, ordered result could only reorder ties.
|
|
118
|
+
items = [
|
|
119
|
+
{
|
|
120
|
+
"id": item["id"],
|
|
121
|
+
"phase": item.get("phase", ""),
|
|
122
|
+
"status": item.get("status", ""),
|
|
123
|
+
"created_at": item.get("created_at", ""),
|
|
124
|
+
"comment": item.get("comment", ""),
|
|
125
|
+
"parent_id": item.get("parent_id"),
|
|
126
|
+
"default": item["id"] == default_revision_id,
|
|
127
|
+
}
|
|
128
|
+
for item in found
|
|
129
|
+
]
|
|
136
130
|
|
|
137
131
|
payload = {"blob": ref.label(), "revisions": items}
|
|
138
132
|
console.result(payload, _render_revisions)
|
|
@@ -30,7 +30,8 @@ from blobhub_cli.workflow import definitions, executions
|
|
|
30
30
|
|
|
31
31
|
# blobhub-web serves the routes `Navigation.workflowPath` builds (src/context/nav.js) from
|
|
32
32
|
# blobhub.io; the path itself -- `/{org_id}/{blob_id}/debugger/{session_id}/{execution_id}` -- is
|
|
33
|
-
# reproduced below rather than assumed (spec §1.4).
|
|
33
|
+
# reproduced below rather than assumed (spec §1.4). The revision rides as `?revision=`, never in the
|
|
34
|
+
# path: that path form is deleted, and the slot it used is the debugger's own.
|
|
34
35
|
DEBUGGER_BASE_URL = "https://blobhub.io"
|
|
35
36
|
|
|
36
37
|
|
|
@@ -85,7 +86,13 @@ def execute(
|
|
|
85
86
|
definition_id = set_.remote[(entry.category, entry.alias)].id
|
|
86
87
|
execution = api.create_execution(target.revision_id, session_id, definition_id=definition_id)
|
|
87
88
|
|
|
88
|
-
|
|
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
|
+
)
|
|
89
96
|
# Launching a browser is a side effect, not output -- there is no `console.stream()` call this
|
|
90
97
|
# could route through -- so it is the one place this command asks `console.json_mode()` directly.
|
|
91
98
|
if open_browser and not console.json_mode():
|
|
@@ -58,7 +58,7 @@ from blobhub_cli.config import settings
|
|
|
58
58
|
from blobhub_cli.config.settings import Settings
|
|
59
59
|
from blobhub_cli.exit import CliExit
|
|
60
60
|
from blobhub_cli.formats import io
|
|
61
|
-
from blobhub_cli.manifest import resolver, scheduler
|
|
61
|
+
from blobhub_cli.manifest import base, resolver, scheduler
|
|
62
62
|
from blobhub_cli.scheduler import references, schedules
|
|
63
63
|
|
|
64
64
|
# Spec §8: the real workflow_executions_per_session maximum needs an admin key most callers don't
|
|
@@ -79,7 +79,7 @@ def _render_ls(payload: dict) -> None:
|
|
|
79
79
|
print(f"{'':2}{'ALIAS':<30} {'ID':<38} {'REPEAT':<15} {'ENABLED':<8} {'STATE':<10} WORKFLOW")
|
|
80
80
|
for item in payload["schedules"]:
|
|
81
81
|
# `*` marks a schedule the manifest covers -- the same marker `workflow ls` and
|
|
82
|
-
# `_render_revisions` (`commands/blob.py`) use for coverage/
|
|
82
|
+
# `_render_revisions` (`commands/blob.py`) use for coverage/default, so this table reads like
|
|
83
83
|
# its siblings at a glance.
|
|
84
84
|
marker = "* " if item["in_manifest"] else " "
|
|
85
85
|
# A schedule the platform stored with no alias has nothing to put in this column; the ID
|
|
@@ -136,15 +136,12 @@ def ls(
|
|
|
136
136
|
return payload
|
|
137
137
|
|
|
138
138
|
|
|
139
|
-
def _reversed_entry(api: Api, record: dict, *, manifest_org_id: str
|
|
139
|
+
def _reversed_entry(api: Api, record: dict, *, manifest_org_id: str) -> dict:
|
|
140
140
|
# Built as a plain dict, not a `manifest.scheduler.ScheduleEntry` -- `pull` writes a document
|
|
141
141
|
# through `formats.io`, and the dataclass exists to be parsed *from* a manifest, not serialized
|
|
142
142
|
# back into one. Field order here matches the manifest sample in the spec, so a pulled file reads
|
|
143
143
|
# the same way a hand-authored one does.
|
|
144
|
-
target = references.reverse_target(
|
|
145
|
-
api, record["invocation_target"], manifest_org_id=manifest_org_id,
|
|
146
|
-
original_revision=original_revision,
|
|
147
|
-
)
|
|
144
|
+
target = references.reverse_target(api, record["invocation_target"], manifest_org_id=manifest_org_id)
|
|
148
145
|
target_doc = {
|
|
149
146
|
"type": target.type,
|
|
150
147
|
"blob": target.blob,
|
|
@@ -206,21 +203,11 @@ def pull(
|
|
|
206
203
|
# firing, and dropping it silently is how an operator ends up with a schedule they cannot see.
|
|
207
204
|
unmanageable = [record["id"] for record in records if record.get("alias") is None]
|
|
208
205
|
|
|
209
|
-
# Reversal must reproduce the symbol the entry actually wrote, and a stored `invocation_target`
|
|
210
|
-
# records only the resolved id -- so the manifest's own entries are the sole surviving evidence
|
|
211
|
-
# of whether a target revision was written as `latest` or deliberately pinned. Matched by alias,
|
|
212
|
-
# the same identity `deploy` decides create-versus-update on; a schedule with no manifest entry
|
|
213
|
-
# has no original symbol to reproduce, and `latest` stays the right default for it.
|
|
214
|
-
original_revisions = {entry.alias: entry.target.revision for entry in manifest.schedules}
|
|
215
|
-
|
|
216
206
|
# Sorted by alias, like `ls`'s rows: deterministic output a diff or a script can rely on, not an
|
|
217
207
|
# accident of `list_schedules`' newest-first paging order.
|
|
218
208
|
entries = sorted(
|
|
219
209
|
(
|
|
220
|
-
_reversed_entry(
|
|
221
|
-
api, record, manifest_org_id=target.org_id,
|
|
222
|
-
original_revision=original_revisions.get(record["alias"], "latest"),
|
|
223
|
-
)
|
|
210
|
+
_reversed_entry(api, record, manifest_org_id=target.org_id)
|
|
224
211
|
for record in records if record.get("alias") is not None
|
|
225
212
|
),
|
|
226
213
|
key=lambda entry: entry["alias"],
|
|
@@ -276,8 +263,12 @@ def _local_fields(
|
|
|
276
263
|
"enabled": entry.enabled,
|
|
277
264
|
"start_date": entry.start_date,
|
|
278
265
|
"end_date": entry.end_date,
|
|
279
|
-
|
|
280
|
-
|
|
266
|
+
# The symbols the platform stores, not the ids they resolved to. `target.blob` is not
|
|
267
|
+
# cosmetic: under `revision: default` the revision symbol is identical for every blob, so
|
|
268
|
+
# without it a manifest retargeted at a different blob compares equal and is never written.
|
|
269
|
+
"target.blob": target.blob_id,
|
|
270
|
+
"target.revision": target.revision_ref,
|
|
271
|
+
"target.session": _stored_session(target),
|
|
281
272
|
"target.workflow": target.workflow_alias,
|
|
282
273
|
"target.input_data": target.input_data,
|
|
283
274
|
"target.description": target.description,
|
|
@@ -298,8 +289,9 @@ def _remote_fields(record: dict) -> dict:
|
|
|
298
289
|
"enabled": record["enabled"],
|
|
299
290
|
"start_date": record.get("start_date"),
|
|
300
291
|
"end_date": record.get("end_date"),
|
|
301
|
-
"target.
|
|
302
|
-
"target.
|
|
292
|
+
"target.blob": invocation_target["blob_id"],
|
|
293
|
+
"target.revision": invocation_target["revision"],
|
|
294
|
+
"target.session": invocation_target["session"],
|
|
303
295
|
"target.workflow": invocation_target["workflow_alias"],
|
|
304
296
|
"target.input_data": invocation_target.get("input_data", []),
|
|
305
297
|
"target.description": invocation_target.get("description"),
|
|
@@ -373,9 +365,13 @@ def diff(
|
|
|
373
365
|
|
|
374
366
|
|
|
375
367
|
def _invocation_target(resolved: references.ResolvedTarget) -> dict:
|
|
368
|
+
# INVOCATION_TARGET_SCHEMA is additionalProperties: false and has no `org_id` -- the platform
|
|
369
|
+
# derives the org from the blob. `revision` is the manifest's own symbol, not the id it resolved
|
|
370
|
+
# to, because storing the id is exactly the pinning a following target exists to avoid.
|
|
376
371
|
target: dict[str, object] = {
|
|
377
|
-
"
|
|
378
|
-
"
|
|
372
|
+
"blob_id": resolved.blob_id,
|
|
373
|
+
"revision": resolved.revision_ref,
|
|
374
|
+
"session": _stored_session(resolved),
|
|
379
375
|
"workflow_alias": resolved.workflow_alias,
|
|
380
376
|
"input_data": resolved.input_data,
|
|
381
377
|
}
|
|
@@ -384,8 +380,20 @@ def _invocation_target(resolved: references.ResolvedTarget) -> dict:
|
|
|
384
380
|
return target
|
|
385
381
|
|
|
386
382
|
|
|
383
|
+
def _stored_session(target: references.PreviewedTarget | references.ResolvedTarget) -> str | None:
|
|
384
|
+
"""What the platform ends up holding in `invocation_target.session` for this target.
|
|
385
|
+
|
|
386
|
+
A following target keeps its alias -- `_resolve_invocation_target_blob` writes the resolved id
|
|
387
|
+
back only for a pinned one. Sending what the platform will store is what keeps a second `deploy`
|
|
388
|
+
of an unchanged manifest a no-op rather than a perpetual update.
|
|
389
|
+
"""
|
|
390
|
+
if target.revision_ref == base.REVISION_DEFAULT:
|
|
391
|
+
return target.session_ref
|
|
392
|
+
return target.session_id
|
|
393
|
+
|
|
394
|
+
|
|
387
395
|
def _schedule_fields(entry: scheduler.ScheduleEntry, resolved: references.ResolvedTarget) -> dict:
|
|
388
|
-
# Keys omitted rather than sent as null: every
|
|
396
|
+
# Keys omitted rather than sent as null: every command schema is additionalProperties: false
|
|
389
397
|
# (api/commands.py), so an explicit start_date/end_date: null would be a 400, not an ignored field.
|
|
390
398
|
# invocation_target_type is a TOP-LEVEL sibling of invocation_target in CREATE_SCHEDULE_SCHEMA /
|
|
391
399
|
# UPDATE_SCHEDULE_SCHEMA (spec §1.2) and required on create -- omitting it 400s every real
|
|
@@ -654,7 +654,7 @@ def _render_ls(payload: dict) -> None:
|
|
|
654
654
|
print(f"{'':2}{'CATEGORY':<10} {'ALIAS':<30} ID")
|
|
655
655
|
for item in payload["definitions"]:
|
|
656
656
|
# `*` marks a definition the manifest covers -- the same marker `_render_revisions`
|
|
657
|
-
# (`commands/blob.py`) uses for "
|
|
657
|
+
# (`commands/blob.py`) uses for "default", so this table reads like its sibling at a glance.
|
|
658
658
|
marker = "* " if item["in_manifest"] else " "
|
|
659
659
|
print(f"{marker}{item['category']:<10} {item['alias']:<30} {item['id']}")
|
|
660
660
|
|