blobhub-cli 0.2.0__tar.gz → 0.3.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.3.0}/PKG-INFO +1 -1
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/__init__.py +1 -1
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/api/client.py +12 -16
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/api/commands.py +20 -36
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/blob.py +3 -3
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/scheduler.py +1 -1
- blobhub_cli-0.3.0/src/blobhub_cli/ids.py +42 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/manifest/resolver.py +19 -18
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/manifest/scheduler.py +24 -6
- blobhub_cli-0.2.0/src/blobhub_cli/ids.py +0 -18
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/.gitignore +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/LICENSE +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/README.md +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/pyproject.toml +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/__main__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/api/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/api/errors.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/blob/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/blob/reference.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/cli/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/cli/app.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/codes.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/auth.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/completion.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/doctor.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/eject.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/execute.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/workflow.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/compiler/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/compiler/allowlist.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/compiler/emit.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/compiler/graph.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/compiler/sandbox.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/config/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/config/credentials.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/config/settings.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/config/sources.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/console.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/definition/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/definition/drift.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/definition/eject.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/definition/hashing.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/definition/model.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/exit.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/formats/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/formats/io.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/limits.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/manifest/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/manifest/base.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/manifest/workflow.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/scheduler/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/scheduler/references.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/scheduler/schedules.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/statestore.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/workflow/__init__.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/workflow/definitions.py +0 -0
- {blobhub_cli-0.2.0 → blobhub_cli-0.3.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.3.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,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
|
|
@@ -78,20 +74,20 @@ class Client:
|
|
|
78
74
|
raise ApiNetworkError(str(exc)) from exc
|
|
79
75
|
return resp.status_code, (time.monotonic() - start) * 1000
|
|
80
76
|
|
|
81
|
-
def query(self, revision_id: str, command: str,
|
|
82
|
-
return self._data_request(revision_id, "query", command,
|
|
77
|
+
def query(self, revision_id: str, command: str, **args: object) -> dict:
|
|
78
|
+
return self._data_request(revision_id, "query", command, gate_status=True, **args)
|
|
83
79
|
|
|
84
|
-
def command(self, revision_id: str, command: str,
|
|
85
|
-
return self._data_request(revision_id, "command", command,
|
|
80
|
+
def command(self, revision_id: str, command: str, **args: object) -> dict:
|
|
81
|
+
return self._data_request(revision_id, "command", command, gate_status=True, **args)
|
|
86
82
|
|
|
87
|
-
def raw_query(self, revision_id: str, command: str,
|
|
88
|
-
return self._data_request(revision_id, "query", command,
|
|
83
|
+
def raw_query(self, revision_id: str, command: str, **args: object) -> dict:
|
|
84
|
+
return self._data_request(revision_id, "query", command, gate_status=False, **args)
|
|
89
85
|
|
|
90
86
|
def _data_request(
|
|
91
|
-
self, revision_id: str, channel: str, command: str, *,
|
|
87
|
+
self, revision_id: str, channel: str, command: str, *, gate_status: bool, **args: object
|
|
92
88
|
) -> dict:
|
|
93
89
|
path = f"/revisions/{revision_id}/data/{channel}"
|
|
94
|
-
body = {"
|
|
90
|
+
body = {"command": command, **args}
|
|
95
91
|
return self._send("POST", path, gate_status=gate_status, json=body)
|
|
96
92
|
|
|
97
93
|
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:
|
|
@@ -33,34 +33,26 @@ class Api:
|
|
|
33
33
|
return self._client.get(f"/blobs/{org}/{blob}/limits")["limits"]
|
|
34
34
|
|
|
35
35
|
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"]
|
|
36
|
+
return self._client.query(revision_id, "list_definitions", category=category)["definitions"]
|
|
39
37
|
|
|
40
38
|
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
|
-
)
|
|
39
|
+
body = self._client.query(revision_id, "download_definition", definition_id=definition_id)
|
|
44
40
|
return body["definition"], body["definition_object"]
|
|
45
41
|
|
|
46
42
|
def create_definition(self, revision_id: str, alias: str, category: str, layout: dict | None = None) -> dict:
|
|
47
43
|
args: dict[str, object] = {"alias": alias, "category": category}
|
|
48
44
|
if layout is not None:
|
|
49
45
|
args["layout"] = layout
|
|
50
|
-
return self._client.command(revision_id, "create_definition",
|
|
46
|
+
return self._client.command(revision_id, "create_definition", **args)["definition"]
|
|
51
47
|
|
|
52
48
|
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
|
-
)
|
|
49
|
+
self._client.command(revision_id, "upload_definition", definition_id=definition_id, definition=document)
|
|
56
50
|
|
|
57
51
|
def delete_definition(self, revision_id: str, definition_id: str) -> None:
|
|
58
|
-
self._client.command(revision_id, "delete_definition",
|
|
52
|
+
self._client.command(revision_id, "delete_definition", definition_id=definition_id)
|
|
59
53
|
|
|
60
54
|
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
|
-
)
|
|
55
|
+
body = self._client.raw_query(revision_id, "check_definition", definition_id=definition_id)
|
|
64
56
|
return body.get("status"), body.get("events", [])
|
|
65
57
|
|
|
66
58
|
def create_session(self, revision_id: str, *, alias: str | None = None, description: str | None = None) -> dict:
|
|
@@ -69,12 +61,12 @@ class Api:
|
|
|
69
61
|
args["alias"] = alias
|
|
70
62
|
if description is not None:
|
|
71
63
|
args["description"] = description
|
|
72
|
-
return self._client.command(revision_id, "create_session",
|
|
64
|
+
return self._client.command(revision_id, "create_session", **args)["session"]
|
|
73
65
|
|
|
74
66
|
def get_session(self, revision_id: str, session: str) -> dict:
|
|
75
67
|
# Accepts an id OR an alias -- `get_session_by_session_id_or_alias` server-side. Unlike
|
|
76
68
|
# `create_execution`, which resolves the id only (spec §1.1).
|
|
77
|
-
return self._client.query(revision_id, "get_session",
|
|
69
|
+
return self._client.query(revision_id, "get_session", session_id=session)["session"]
|
|
78
70
|
|
|
79
71
|
def create_execution(self, revision_id: str, session_id: str, *, definition_id: str) -> dict:
|
|
80
72
|
# Addressed by id, not alias: `CREATE_EXECUTION_SCHEMA` accepts either, but the server
|
|
@@ -84,13 +76,11 @@ class Api:
|
|
|
84
76
|
# definition -- an alias-addressed execution could resolve to either, unpredictably. The
|
|
85
77
|
# caller already has the id from `load_set`, so there is no reason to take that risk.
|
|
86
78
|
return self._client.command(
|
|
87
|
-
revision_id, "create_execution",
|
|
79
|
+
revision_id, "create_execution", session_id=session_id, definition_id=definition_id
|
|
88
80
|
)["execution"]
|
|
89
81
|
|
|
90
82
|
def get_execution(self, revision_id: str, execution_id: str) -> dict:
|
|
91
|
-
return self._client.query(revision_id, "get_execution",
|
|
92
|
-
"execution"
|
|
93
|
-
]
|
|
83
|
+
return self._client.query(revision_id, "get_execution", execution_id=execution_id)["execution"]
|
|
94
84
|
|
|
95
85
|
def list_execution_events(
|
|
96
86
|
self,
|
|
@@ -101,16 +91,14 @@ class Api:
|
|
|
101
91
|
ascending: bool = True,
|
|
102
92
|
limit: int | None = None,
|
|
103
93
|
) -> list[dict]:
|
|
104
|
-
# Keys are omitted rather than sent as null: every
|
|
94
|
+
# Keys are omitted rather than sent as null: every command schema is
|
|
105
95
|
# additionalProperties: false, so one stray key is a 400, not an ignored field.
|
|
106
96
|
args: dict[str, object] = {"execution_id": execution_id, "ascending": ascending}
|
|
107
97
|
if created_since is not None:
|
|
108
98
|
args["created_since"] = created_since
|
|
109
99
|
if limit is not None:
|
|
110
100
|
args["limit"] = limit
|
|
111
|
-
return self._client.query(revision_id, "list_execution_events",
|
|
112
|
-
"execution_events"
|
|
113
|
-
]
|
|
101
|
+
return self._client.query(revision_id, "list_execution_events", **args)["execution_events"]
|
|
114
102
|
|
|
115
103
|
def list_executions(
|
|
116
104
|
self, revision_id: str, session_id: str, *, start_execution_id: str | None = None
|
|
@@ -122,24 +110,20 @@ class Api:
|
|
|
122
110
|
args: dict[str, object] = {"session_id": session_id}
|
|
123
111
|
if start_execution_id is not None:
|
|
124
112
|
args["start_execution_id"] = start_execution_id
|
|
125
|
-
body = self._client.query(revision_id, "list_executions",
|
|
113
|
+
body = self._client.query(revision_id, "list_executions", **args)
|
|
126
114
|
return body["executions"], body["last_execution_id"]
|
|
127
115
|
|
|
128
116
|
def create_schedule(self, revision_id: str, **fields: object) -> dict:
|
|
129
|
-
return self._client.command(revision_id, "create_schedule",
|
|
117
|
+
return self._client.command(revision_id, "create_schedule", **fields)["schedule"]
|
|
130
118
|
|
|
131
119
|
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"]
|
|
120
|
+
return self._client.command(revision_id, "update_schedule", schedule_id=schedule, **fields)["schedule"]
|
|
135
121
|
|
|
136
122
|
def delete_schedule(self, revision_id: str, schedule: str) -> None:
|
|
137
|
-
self._client.command(revision_id, "delete_schedule",
|
|
123
|
+
self._client.command(revision_id, "delete_schedule", schedule_id=schedule)
|
|
138
124
|
|
|
139
125
|
def get_schedule(self, revision_id: str, schedule: str) -> dict:
|
|
140
|
-
return self._client.query(revision_id, "get_schedule",
|
|
141
|
-
"schedule"
|
|
142
|
-
]
|
|
126
|
+
return self._client.query(revision_id, "get_schedule", schedule_id=schedule)["schedule"]
|
|
143
127
|
|
|
144
128
|
def list_schedules(
|
|
145
129
|
self, revision_id: str, *, start_schedule_id: str | None = None
|
|
@@ -153,5 +137,5 @@ class Api:
|
|
|
153
137
|
args: dict[str, object] = {}
|
|
154
138
|
if start_schedule_id is not None:
|
|
155
139
|
args["start_schedule_id"] = start_schedule_id
|
|
156
|
-
body = self._client.query(revision_id, "list_schedules",
|
|
140
|
+
body = self._client.query(revision_id, "list_schedules", **args)
|
|
157
141
|
return body["schedules"], body["last_schedule_id"]
|
|
@@ -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
|
"""
|
|
@@ -45,8 +45,8 @@ def _render_show(payload: dict) -> None:
|
|
|
45
45
|
record = payload["record"]
|
|
46
46
|
print(f"{'Blob:':<12} {payload['blob']}")
|
|
47
47
|
for label, key in (
|
|
48
|
-
("Alias", "alias"), ("Id", "id"), ("Org", "org_id"), ("
|
|
49
|
-
("
|
|
48
|
+
("Alias", "alias"), ("Id", "id"), ("Org", "org_id"), ("Type", "type"),
|
|
49
|
+
("Visibility", "visibility"), ("Status", "status"),
|
|
50
50
|
):
|
|
51
51
|
print(f"{label + ':':<12} {record.get(key, '')}")
|
|
52
52
|
revision_id = record.get("latest_revision_id")
|
|
@@ -385,7 +385,7 @@ def _invocation_target(resolved: references.ResolvedTarget) -> dict:
|
|
|
385
385
|
|
|
386
386
|
|
|
387
387
|
def _schedule_fields(entry: scheduler.ScheduleEntry, resolved: references.ResolvedTarget) -> dict:
|
|
388
|
-
# Keys omitted rather than sent as null: every
|
|
388
|
+
# Keys omitted rather than sent as null: every command schema is additionalProperties: false
|
|
389
389
|
# (api/commands.py), so an explicit start_date/end_date: null would be a 400, not an ignored field.
|
|
390
390
|
# invocation_target_type is a TOP-LEVEL sibling of invocation_target in CREATE_SCHEDULE_SCHEMA /
|
|
391
391
|
# UPDATE_SCHEDULE_SCHEMA (spec §1.2) and required on create -- omitting it 400s every real
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Identifier-shape checks shared across domains.
|
|
2
|
+
|
|
3
|
+
Contract: `looks_like_uuid` is the one syntax check for whether a string is shaped like a UUID,
|
|
4
|
+
factored out here so no single domain module (blob references, sessions, and schedules) owns a
|
|
5
|
+
check every domain needs.
|
|
6
|
+
Invariant: this is a shape check, not an existence check -- a well-formed UUID may still resolve to
|
|
7
|
+
nothing, and callers must still handle a not-found/not-accessible response after this returns True.
|
|
8
|
+
|
|
9
|
+
There are two predicates here, not one, because they answer different questions. `looks_like_uuid`
|
|
10
|
+
is read-side and permissive on purpose: it delegates to `uuid.UUID`, which also accepts a dashless
|
|
11
|
+
32-hex string, a `{braced}` form, and a `urn:uuid:` form, and every one of the CLI's existing
|
|
12
|
+
"is this an id or an alias" branches wants exactly that leniency -- a caller handing back any spelling
|
|
13
|
+
a UUID can take should be treated as meaning an id. `is_canonical_uuid` is write-side and narrow: it
|
|
14
|
+
is the CLI's local mirror of the platform's own shape-exclusion for a user-supplied alias (a create or
|
|
15
|
+
a rename), which refuses only the *canonical* 8-4-4-4-12 form, because that is the one shape the
|
|
16
|
+
platform's own id generator emits and the one shape an alias could collide with unrecoverably. A
|
|
17
|
+
dashless 32-hex string is `looks_like_uuid`-true but `is_canonical_uuid`-false, and is an entirely
|
|
18
|
+
ordinary alias as far as the platform is concerned -- `is_canonical_uuid` must never be used in place
|
|
19
|
+
of `looks_like_uuid`, or it would reject aliases the platform accepts, and `looks_like_uuid` must
|
|
20
|
+
never be used to gate alias creation, or it would reject aliases the platform accepts for a different
|
|
21
|
+
reason (dashless 32-hex parses as a UUID but is not the canonical shape the platform refuses).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
import uuid
|
|
26
|
+
|
|
27
|
+
# The platform's own shape-exclusion for a user-supplied alias (create/rename): canonical
|
|
28
|
+
# 8-4-4-4-12 lowercase hex only. Narrower than `looks_like_uuid` on purpose -- see the module
|
|
29
|
+
# docstring for why a dashless 32-hex string must stay outside this pattern.
|
|
30
|
+
_CANONICAL_UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def looks_like_uuid(value: str) -> bool:
|
|
34
|
+
try:
|
|
35
|
+
uuid.UUID(value)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return False
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_canonical_uuid(value: str) -> bool:
|
|
42
|
+
return bool(_CANONICAL_UUID.match(value))
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"""General reference resolver: turns a parsed `ManifestBase` into concrete platform coordinates.
|
|
2
2
|
|
|
3
|
-
Contract: `resolve` checks blob accessibility, the caller-supplied `expect`
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
Contract: `resolve` checks blob accessibility, the caller-supplied `expect` blob type (before any
|
|
4
|
+
data-channel call, which would otherwise answer an opaque `500`), `latest` resolution through
|
|
5
|
+
`blob.latest_revision_id`, then the status/phase gate, in that fixed order. `expect` is a required
|
|
6
|
+
keyword-only argument -- this module resolves `blob`/`revision` coordinates for any blob type, and
|
|
7
|
+
a default of the workflow type would leave that one type's assumption sitting inside a resolver
|
|
8
|
+
that calls itself general.
|
|
9
9
|
Invariant: resolves only `blob`/`revision`, with no dependency on any manifest body shape, so a
|
|
10
10
|
later `session`/`workflow` reference-kind extension adds to it rather than rewriting it.
|
|
11
11
|
"""
|
|
@@ -23,8 +23,8 @@ from blobhub_cli.manifest.base import ManifestBase
|
|
|
23
23
|
WRITABLE_PHASES = {"draft", "managed"}
|
|
24
24
|
QUERYABLE_PHASES = {"draft", "commit", "managed"}
|
|
25
25
|
READY_STATUS = "ready"
|
|
26
|
-
WORKFLOW_BLOB =
|
|
27
|
-
SCHEDULER_BLOB =
|
|
26
|
+
WORKFLOW_BLOB = "blobhub.compute.workflow"
|
|
27
|
+
SCHEDULER_BLOB = "blobhub.compute.scheduler"
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
@dataclass(frozen=True)
|
|
@@ -37,18 +37,19 @@ class Target:
|
|
|
37
37
|
latest_revision_id: str
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def command_group(expect:
|
|
41
|
-
"""The CLI command group that manages blobs of the expected
|
|
40
|
+
def command_group(expect: str) -> str:
|
|
41
|
+
"""The CLI command group that manages blobs of the expected type -- `workflow` for a workflow
|
|
42
42
|
blob, `scheduler` for a scheduler one. Derived from `expect` rather than taken as a second
|
|
43
43
|
argument so the two can never disagree: a hint is contractually a command the user can paste
|
|
44
44
|
(`exit.py`), and `blobhub workflow diff -f <scheduler manifest>` answers MANIFEST_TYPE_MISMATCH.
|
|
45
|
-
The
|
|
45
|
+
The type string's last dotted segment *is* the group name in this CLI
|
|
46
|
+
(`"blobhub.compute.workflow"` -> `"workflow"`), by construction of both command trees.
|
|
46
47
|
"""
|
|
47
|
-
return expect
|
|
48
|
+
return expect.rsplit(".", 1)[-1]
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
def resolve(
|
|
51
|
-
api: Api, base: ManifestBase, *, require_writable: bool, expect:
|
|
52
|
+
api: Api, base: ManifestBase, *, require_writable: bool, expect: str, default_org: str | None = None
|
|
52
53
|
) -> Target:
|
|
53
54
|
group = command_group(expect)
|
|
54
55
|
org = base.org() or default_org
|
|
@@ -69,14 +70,14 @@ def resolve(
|
|
|
69
70
|
except ApiAuthError as exc:
|
|
70
71
|
raise reference.not_accessible(BlobRef(org=org, name=blob_name)) from exc
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
if
|
|
73
|
+
blob_type = blob.get("type")
|
|
74
|
+
if blob_type != expect:
|
|
74
75
|
# BLOB_NOT_WORKFLOW is kept as the code name even though this check now serves any
|
|
75
|
-
#
|
|
76
|
-
# consumers for no behavioural gain. The message names the expected
|
|
76
|
+
# type: renaming a published error code to gain one word would break `--json`
|
|
77
|
+
# consumers for no behavioural gain. The message names the expected type instead.
|
|
77
78
|
raise CliExit(
|
|
78
79
|
codes.BLOB_NOT_WORKFLOW,
|
|
79
|
-
f"blob {org}/{blob_name} is {
|
|
80
|
+
f"blob {org}/{blob_name} is {blob_type!r}, not a {expect!r} blob",
|
|
80
81
|
)
|
|
81
82
|
|
|
82
83
|
revision_id = base.revision
|
|
@@ -17,7 +17,7 @@ import re
|
|
|
17
17
|
from dataclasses import dataclass
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
-
from blobhub_cli import codes
|
|
20
|
+
from blobhub_cli import codes, ids
|
|
21
21
|
from blobhub_cli.exit import CliExit
|
|
22
22
|
from blobhub_cli.formats import io
|
|
23
23
|
from blobhub_cli.manifest import base
|
|
@@ -26,11 +26,16 @@ from blobhub_cli.manifest.base import ManifestBase
|
|
|
26
26
|
MANIFEST_TYPE = "scheduler_blob_deployment"
|
|
27
27
|
SUPPORTED_VERSIONS = {"1.0"}
|
|
28
28
|
|
|
29
|
-
# `SCHEDULE_ALIAS_SCHEMA` is `DEFINITION_ALIAS_SCHEMA` verbatim
|
|
30
|
-
# `api/storage/scheduler.py` -> `workflow.py`)
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
29
|
+
# `SCHEDULE_ALIAS_SCHEMA` is no longer `DEFINITION_ALIAS_SCHEMA` verbatim. It used to be exactly that
|
|
30
|
+
# schema (blobhub-infra `api/storage/scheduler.py` -> `workflow.py`), spelt the same way here rather
|
|
31
|
+
# than shared since the two were equal only by coincidence of policy. Infra has since moved a
|
|
32
|
+
# schedule's alias onto the *session* schema instead: the same `^[a-z0-9_-]*$`, minLength 6, maxLength
|
|
33
|
+
# 42 grammar `manifest/workflow.py` still enforces for a definition alias, plus the shape exclusion
|
|
34
|
+
# below -- a schedule is one of the five kinds addressed by id-or-alias through one parameter, and a
|
|
35
|
+
# canonical UUID-shaped alias would be unreachable by its own name and permanently spend that id's
|
|
36
|
+
# uniqueness reservation. A definition alias is not shape-resolved (`create_execution` takes
|
|
37
|
+
# `definition_id` and `alias` as separate fields), which is why `manifest/workflow.py`'s `_ALIAS`
|
|
38
|
+
# stays exactly the grammar it always was.
|
|
34
39
|
_ALIAS = re.compile(r"^[a-z0-9_-]{6,42}$")
|
|
35
40
|
|
|
36
41
|
_REPEAT_KINDS = {"one_time", "recurring_cron"}
|
|
@@ -151,6 +156,19 @@ def _parse_schedule(raw: object, index: int, hint: str) -> ScheduleEntry:
|
|
|
151
156
|
f"schedule alias {alias!r} must match {_ALIAS.pattern!r}",
|
|
152
157
|
hint,
|
|
153
158
|
)
|
|
159
|
+
# The grammar above admits a canonical UUID (36 lowercase hex-and-hyphen characters), which is
|
|
160
|
+
# exactly the shape the platform reserves for a generated id. Such an alias would be unreachable
|
|
161
|
+
# by its own name and would permanently spend that id's uniqueness reservation -- possibly
|
|
162
|
+
# resolving a lookup to whichever object really owns it. The platform now refuses this at create
|
|
163
|
+
# and rename with a 400; this mirrors that refusal locally so a manifest with one entry like this
|
|
164
|
+
# fails before any earlier entry in the same `deploy` is written and already firing.
|
|
165
|
+
if ids.is_canonical_uuid(alias):
|
|
166
|
+
raise CliExit(
|
|
167
|
+
codes.SCHEDULE_ALIAS_INVALID,
|
|
168
|
+
f"schedule alias {alias!r} looks like a UUID; a schedule alias must not take the shape "
|
|
169
|
+
"the platform reserves for a generated id",
|
|
170
|
+
hint,
|
|
171
|
+
)
|
|
154
172
|
|
|
155
173
|
repeat = _require_str(raw, "repeat", hint)
|
|
156
174
|
if repeat not in _REPEAT_KINDS:
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
"""Identifier-shape checks shared across domains.
|
|
2
|
-
|
|
3
|
-
Contract: `looks_like_uuid` is the one syntax check for whether a string is shaped like a UUID,
|
|
4
|
-
factored out here so no single domain module (blob references, sessions, and soon schedules) owns a
|
|
5
|
-
check every domain needs.
|
|
6
|
-
Invariant: this is a shape check, not an existence check -- a well-formed UUID may still resolve to
|
|
7
|
-
nothing, and callers must still handle a not-found/not-accessible response after this returns True.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import uuid
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def looks_like_uuid(value: str) -> bool:
|
|
14
|
-
try:
|
|
15
|
-
uuid.UUID(value)
|
|
16
|
-
except ValueError:
|
|
17
|
-
return False
|
|
18
|
-
return True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|