blobhub-cli 0.1.0__py3-none-any.whl
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/__init__.py +8 -0
- blobhub_cli/__main__.py +11 -0
- blobhub_cli/api/__init__.py +8 -0
- blobhub_cli/api/client.py +126 -0
- blobhub_cli/api/commands.py +157 -0
- blobhub_cli/api/errors.py +51 -0
- blobhub_cli/blob/__init__.py +9 -0
- blobhub_cli/blob/reference.py +87 -0
- blobhub_cli/cli/__init__.py +5 -0
- blobhub_cli/cli/app.py +364 -0
- blobhub_cli/codes.py +99 -0
- blobhub_cli/commands/__init__.py +7 -0
- blobhub_cli/commands/auth.py +147 -0
- blobhub_cli/commands/blob.py +187 -0
- blobhub_cli/commands/completion.py +35 -0
- blobhub_cli/commands/doctor.py +245 -0
- blobhub_cli/commands/eject.py +153 -0
- blobhub_cli/commands/execute.py +124 -0
- blobhub_cli/commands/scheduler.py +556 -0
- blobhub_cli/commands/workflow.py +798 -0
- blobhub_cli/compiler/__init__.py +8 -0
- blobhub_cli/compiler/allowlist.py +141 -0
- blobhub_cli/compiler/emit.py +109 -0
- blobhub_cli/compiler/graph.py +360 -0
- blobhub_cli/compiler/sandbox.py +56 -0
- blobhub_cli/config/__init__.py +7 -0
- blobhub_cli/config/credentials.py +57 -0
- blobhub_cli/config/settings.py +69 -0
- blobhub_cli/console.py +93 -0
- blobhub_cli/definition/__init__.py +8 -0
- blobhub_cli/definition/drift.py +40 -0
- blobhub_cli/definition/eject.py +64 -0
- blobhub_cli/definition/hashing.py +45 -0
- blobhub_cli/definition/model.py +105 -0
- blobhub_cli/exit.py +16 -0
- blobhub_cli/formats/__init__.py +7 -0
- blobhub_cli/formats/io.py +86 -0
- blobhub_cli/ids.py +18 -0
- blobhub_cli/limits.py +34 -0
- blobhub_cli/manifest/__init__.py +8 -0
- blobhub_cli/manifest/base.py +93 -0
- blobhub_cli/manifest/resolver.py +120 -0
- blobhub_cli/manifest/scheduler.py +237 -0
- blobhub_cli/manifest/workflow.py +141 -0
- blobhub_cli/scheduler/__init__.py +10 -0
- blobhub_cli/scheduler/references.py +260 -0
- blobhub_cli/scheduler/schedules.py +93 -0
- blobhub_cli/statestore.py +50 -0
- blobhub_cli/workflow/__init__.py +8 -0
- blobhub_cli/workflow/definitions.py +141 -0
- blobhub_cli/workflow/executions.py +107 -0
- blobhub_cli-0.1.0.dist-info/METADATA +189 -0
- blobhub_cli-0.1.0.dist-info/RECORD +56 -0
- blobhub_cli-0.1.0.dist-info/WHEEL +4 -0
- blobhub_cli-0.1.0.dist-info/entry_points.txt +2 -0
- blobhub_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
blobhub_cli/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""BlobHub CLI package root.
|
|
2
|
+
|
|
3
|
+
Contract: this module is the sole source of the package version — hatchling reads
|
|
4
|
+
`__version__` from here via `[tool.hatch.version] path` in pyproject.toml.
|
|
5
|
+
Invariant: no other file in this package defines `__version__`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
blobhub_cli/__main__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Process entry point for `python -m blobhub_cli`.
|
|
2
|
+
|
|
3
|
+
Contract: invoking this module runs the CLI the same way the installed `blobhub`
|
|
4
|
+
console script does.
|
|
5
|
+
Invariant: holds no logic of its own — it only delegates to `cli.app.main`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from blobhub_cli.cli.app import main
|
|
9
|
+
|
|
10
|
+
if __name__ == "__main__":
|
|
11
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""HTTP client and error hierarchy for talking to the BlobHub platform.
|
|
2
|
+
|
|
3
|
+
Contract: `client.Client` is the only object in this package (and the only one in
|
|
4
|
+
`blobhub_cli`) permitted to perform network I/O; every other module reaches the platform
|
|
5
|
+
through it.
|
|
6
|
+
Invariant: nothing in this package ever logs, echoes, or includes an API key in an
|
|
7
|
+
exception message, output line, or `--json` payload.
|
|
8
|
+
"""
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Synchronous HTTP client for the BlobHub API — the only module in this package that performs network I/O.
|
|
2
|
+
|
|
3
|
+
Contract: `get`, `query`, `command`, and `raw_query` route every response through `_handle`,
|
|
4
|
+
which maps HTTP status and the body's `status` field onto the `api.errors` hierarchy;
|
|
5
|
+
`raw_query` alone skips the status gate, and `probe` skips both the gate and the retry loop.
|
|
6
|
+
`query`/`command`/`raw_query` take a required keyword-only `engine` -- the engine a data request
|
|
7
|
+
targets is the caller's to supply, never this class's to assume, because one command can
|
|
8
|
+
legitimately span both: a scheduler `deploy` writes schedules against `scheduler_blobhub` and
|
|
9
|
+
validates its targets with `workflow_blobhub` calls against the target revision, so a client
|
|
10
|
+
fixed to one engine could not do that.
|
|
11
|
+
Invariant: `Content-Type: application/json` is sent on every request, and the API key is
|
|
12
|
+
never logged, echoed, or included in any exception message. This module is the only one that
|
|
13
|
+
performs I/O against the BlobHub API -- the single sanctioned exception elsewhere is `doctor`'s
|
|
14
|
+
PyPI version probe, which must NOT go through this class precisely because this class attaches
|
|
15
|
+
`X-API-Key` to every request and the key must never reach a third-party host.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import random
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from blobhub_cli.api.errors import (
|
|
24
|
+
ApiAuthError,
|
|
25
|
+
ApiCommandError,
|
|
26
|
+
ApiNetworkError,
|
|
27
|
+
ApiRateLimited,
|
|
28
|
+
ApiTransientError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
WORKFLOW_ENGINE = "workflow_blobhub"
|
|
32
|
+
SCHEDULER_ENGINE = "scheduler_blobhub"
|
|
33
|
+
|
|
34
|
+
_TRANSIENT = (ApiRateLimited, ApiTransientError, ApiNetworkError)
|
|
35
|
+
_ATTEMPTS = 3
|
|
36
|
+
_BASE_DELAY = 0.5
|
|
37
|
+
_MAX_DELAY = 8.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Client:
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
api_key: str,
|
|
44
|
+
base_url: str,
|
|
45
|
+
*,
|
|
46
|
+
transport: httpx.BaseTransport | None = None,
|
|
47
|
+
timeout: float = 30.0,
|
|
48
|
+
sleep=time.sleep,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._sleep = sleep
|
|
51
|
+
self._http = httpx.Client(
|
|
52
|
+
base_url=base_url,
|
|
53
|
+
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
|
|
54
|
+
transport=transport,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def __enter__(self) -> "Client":
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
62
|
+
self._http.close()
|
|
63
|
+
|
|
64
|
+
def get(self, path: str) -> dict:
|
|
65
|
+
return self._send("GET", path, gate_status=True)
|
|
66
|
+
|
|
67
|
+
def probe(self, path: str = "/users/me") -> tuple[int, float]:
|
|
68
|
+
"""One unretried request, returning (status_code, elapsed_ms) without gating the body.
|
|
69
|
+
|
|
70
|
+
`doctor` uses this to prove reachability independently of credentials: every route needs
|
|
71
|
+
auth, so a 401 is a perfectly good proof that the host answered. Deliberately unretried --
|
|
72
|
+
a diagnostic wants a fast honest answer, not three backoffs.
|
|
73
|
+
"""
|
|
74
|
+
start = time.monotonic()
|
|
75
|
+
try:
|
|
76
|
+
resp = self._http.request("GET", path)
|
|
77
|
+
except httpx.TransportError as exc:
|
|
78
|
+
raise ApiNetworkError(str(exc)) from exc
|
|
79
|
+
return resp.status_code, (time.monotonic() - start) * 1000
|
|
80
|
+
|
|
81
|
+
def query(self, revision_id: str, command: str, *, engine: str, **args: object) -> dict:
|
|
82
|
+
return self._data_request(revision_id, "query", command, engine=engine, gate_status=True, **args)
|
|
83
|
+
|
|
84
|
+
def command(self, revision_id: str, command: str, *, engine: str, **args: object) -> dict:
|
|
85
|
+
return self._data_request(revision_id, "command", command, engine=engine, gate_status=True, **args)
|
|
86
|
+
|
|
87
|
+
def raw_query(self, revision_id: str, command: str, *, engine: str, **args: object) -> dict:
|
|
88
|
+
return self._data_request(revision_id, "query", command, engine=engine, gate_status=False, **args)
|
|
89
|
+
|
|
90
|
+
def _data_request(
|
|
91
|
+
self, revision_id: str, channel: str, command: str, *, engine: str, gate_status: bool, **args: object
|
|
92
|
+
) -> dict:
|
|
93
|
+
path = f"/revisions/{revision_id}/data/{channel}"
|
|
94
|
+
body = {"engine": engine, "command": command, **args}
|
|
95
|
+
return self._send("POST", path, gate_status=gate_status, json=body)
|
|
96
|
+
|
|
97
|
+
def _send(self, method: str, path: str, *, gate_status: bool, **kwargs: object) -> dict:
|
|
98
|
+
last_error: Exception
|
|
99
|
+
for attempt in range(_ATTEMPTS):
|
|
100
|
+
try:
|
|
101
|
+
resp = self._http.request(method, path, **kwargs)
|
|
102
|
+
except httpx.TransportError as exc:
|
|
103
|
+
last_error = ApiNetworkError(str(exc))
|
|
104
|
+
else:
|
|
105
|
+
try:
|
|
106
|
+
return self._handle(resp, gate_status=gate_status)
|
|
107
|
+
except _TRANSIENT as exc:
|
|
108
|
+
last_error = exc
|
|
109
|
+
if attempt < _ATTEMPTS - 1:
|
|
110
|
+
self._sleep(random.random() * min(_MAX_DELAY, _BASE_DELAY * 2**attempt))
|
|
111
|
+
raise last_error
|
|
112
|
+
|
|
113
|
+
def _handle(self, resp: httpx.Response, *, gate_status: bool) -> dict:
|
|
114
|
+
if resp.status_code in (401, 403):
|
|
115
|
+
raise ApiAuthError(f"auth failed: HTTP {resp.status_code}")
|
|
116
|
+
if resp.status_code == 429:
|
|
117
|
+
raise ApiRateLimited("HTTP 429")
|
|
118
|
+
if resp.status_code >= 500:
|
|
119
|
+
raise ApiTransientError(f"HTTP {resp.status_code}")
|
|
120
|
+
try:
|
|
121
|
+
data = resp.json()
|
|
122
|
+
except ValueError as exc:
|
|
123
|
+
raise ApiTransientError(f"non-JSON response: HTTP {resp.status_code}") from exc
|
|
124
|
+
if gate_status and data.get("status") != "success":
|
|
125
|
+
raise ApiCommandError(data.get("error", "unknown"), data.get("message", ""))
|
|
126
|
+
return data
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Typed wrappers over `Client`, one method per platform read or engine command.
|
|
2
|
+
|
|
3
|
+
Contract: each method sends exactly one request and returns only the envelope key(s) the
|
|
4
|
+
platform documents for that command, adding no caching, retries, or aggregation of its own.
|
|
5
|
+
Invariant: `create_definition` includes `layout` in the request body only when it is not
|
|
6
|
+
`None` — the server's `additionalProperties: false` schema turns an explicit `null` into a
|
|
7
|
+
400 for a workflow-category definition, which never has a `layout` field.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from blobhub_cli.api.client import SCHEDULER_ENGINE, WORKFLOW_ENGINE, Client
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Api:
|
|
14
|
+
def __init__(self, client: Client) -> None:
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
def users_me(self) -> dict:
|
|
18
|
+
return self._client.get("/users/me")["user"]
|
|
19
|
+
|
|
20
|
+
def users_me_orgs(self) -> list[dict]:
|
|
21
|
+
return self._client.get("/users/me/orgs")["organizations"]
|
|
22
|
+
|
|
23
|
+
def get_blob(self, org: str, blob: str) -> dict:
|
|
24
|
+
return self._client.get(f"/blobs/{org}/{blob}")["blob"]
|
|
25
|
+
|
|
26
|
+
def get_revision(self, revision_id: str) -> dict:
|
|
27
|
+
return self._client.get(f"/revisions/{revision_id}")["revision"]
|
|
28
|
+
|
|
29
|
+
def list_revisions(self, org: str, blob: str) -> list[dict]:
|
|
30
|
+
return self._client.get(f"/blobs/{org}/{blob}/revisions")["revisions"]
|
|
31
|
+
|
|
32
|
+
def get_blob_limits(self, org: str, blob: str) -> dict:
|
|
33
|
+
return self._client.get(f"/blobs/{org}/{blob}/limits")["limits"]
|
|
34
|
+
|
|
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"]
|
|
39
|
+
|
|
40
|
+
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
|
+
)
|
|
44
|
+
return body["definition"], body["definition_object"]
|
|
45
|
+
|
|
46
|
+
def create_definition(self, revision_id: str, alias: str, category: str, layout: dict | None = None) -> dict:
|
|
47
|
+
args: dict[str, object] = {"alias": alias, "category": category}
|
|
48
|
+
if layout is not None:
|
|
49
|
+
args["layout"] = layout
|
|
50
|
+
return self._client.command(revision_id, "create_definition", engine=WORKFLOW_ENGINE, **args)["definition"]
|
|
51
|
+
|
|
52
|
+
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
|
+
)
|
|
56
|
+
|
|
57
|
+
def delete_definition(self, revision_id: str, definition_id: str) -> None:
|
|
58
|
+
self._client.command(revision_id, "delete_definition", engine=WORKFLOW_ENGINE, definition_id=definition_id)
|
|
59
|
+
|
|
60
|
+
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
|
+
)
|
|
64
|
+
return body.get("status"), body.get("events", [])
|
|
65
|
+
|
|
66
|
+
def create_session(self, revision_id: str, *, alias: str | None = None, description: str | None = None) -> dict:
|
|
67
|
+
args: dict[str, object] = {}
|
|
68
|
+
if alias is not None:
|
|
69
|
+
args["alias"] = alias
|
|
70
|
+
if description is not None:
|
|
71
|
+
args["description"] = description
|
|
72
|
+
return self._client.command(revision_id, "create_session", engine=WORKFLOW_ENGINE, **args)["session"]
|
|
73
|
+
|
|
74
|
+
def get_session(self, revision_id: str, session: str) -> dict:
|
|
75
|
+
# Accepts an id OR an alias -- `get_session_by_session_id_or_alias` server-side. Unlike
|
|
76
|
+
# `create_execution`, which resolves the id only (spec §1.1).
|
|
77
|
+
return self._client.query(revision_id, "get_session", engine=WORKFLOW_ENGINE, session_id=session)["session"]
|
|
78
|
+
|
|
79
|
+
def create_execution(self, revision_id: str, session_id: str, *, definition_id: str) -> dict:
|
|
80
|
+
# Addressed by id, not alias: `CREATE_EXECUTION_SCHEMA` accepts either, but the server
|
|
81
|
+
# resolves `alias` via a `(revision_id, alias)` query with no category filter, taking
|
|
82
|
+
# whichever definition comes back first. Alias uniqueness is scoped per `(category, alias)`
|
|
83
|
+
# (F1 spec §1.3), so the same alias can legally name both a workflow and a playground
|
|
84
|
+
# definition -- an alias-addressed execution could resolve to either, unpredictably. The
|
|
85
|
+
# caller already has the id from `load_set`, so there is no reason to take that risk.
|
|
86
|
+
return self._client.command(
|
|
87
|
+
revision_id, "create_execution", engine=WORKFLOW_ENGINE, session_id=session_id, definition_id=definition_id
|
|
88
|
+
)["execution"]
|
|
89
|
+
|
|
90
|
+
def get_execution(self, revision_id: str, execution_id: str) -> dict:
|
|
91
|
+
return self._client.query(revision_id, "get_execution", engine=WORKFLOW_ENGINE, execution_id=execution_id)[
|
|
92
|
+
"execution"
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
def list_execution_events(
|
|
96
|
+
self,
|
|
97
|
+
revision_id: str,
|
|
98
|
+
execution_id: str,
|
|
99
|
+
*,
|
|
100
|
+
created_since: str | None = None,
|
|
101
|
+
ascending: bool = True,
|
|
102
|
+
limit: int | None = None,
|
|
103
|
+
) -> list[dict]:
|
|
104
|
+
# Keys are omitted rather than sent as null: every engine schema is
|
|
105
|
+
# additionalProperties: false, so one stray key is a 400, not an ignored field.
|
|
106
|
+
args: dict[str, object] = {"execution_id": execution_id, "ascending": ascending}
|
|
107
|
+
if created_since is not None:
|
|
108
|
+
args["created_since"] = created_since
|
|
109
|
+
if limit is not None:
|
|
110
|
+
args["limit"] = limit
|
|
111
|
+
return self._client.query(revision_id, "list_execution_events", engine=WORKFLOW_ENGINE, **args)[
|
|
112
|
+
"execution_events"
|
|
113
|
+
]
|
|
114
|
+
|
|
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", engine=WORKFLOW_ENGINE, **args)
|
|
126
|
+
return body["executions"], body["last_execution_id"]
|
|
127
|
+
|
|
128
|
+
def create_schedule(self, revision_id: str, **fields: object) -> dict:
|
|
129
|
+
return self._client.command(revision_id, "create_schedule", engine=SCHEDULER_ENGINE, **fields)["schedule"]
|
|
130
|
+
|
|
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"]
|
|
135
|
+
|
|
136
|
+
def delete_schedule(self, revision_id: str, schedule: str) -> None:
|
|
137
|
+
self._client.command(revision_id, "delete_schedule", engine=SCHEDULER_ENGINE, schedule_id=schedule)
|
|
138
|
+
|
|
139
|
+
def get_schedule(self, revision_id: str, schedule: str) -> dict:
|
|
140
|
+
return self._client.query(revision_id, "get_schedule", engine=SCHEDULER_ENGINE, schedule_id=schedule)[
|
|
141
|
+
"schedule"
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
def list_schedules(
|
|
145
|
+
self, revision_id: str, *, start_schedule_id: str | None = None
|
|
146
|
+
) -> tuple[list[dict], str | None]:
|
|
147
|
+
# start_schedule_id is omitted rather than sent as null: LIST_SCHEDULES_SCHEMA is
|
|
148
|
+
# additionalProperties: false, so a stray null key is a 400, not an ignored field. Returns
|
|
149
|
+
# one page and the next cursor -- it deliberately does not loop. A method that looped would
|
|
150
|
+
# hide the paging from every test that uses it; the loop belongs one layer up, where a
|
|
151
|
+
# caller that forgets it would otherwise silently recreate every unseen schedule as a
|
|
152
|
+
# duplicate on the next run.
|
|
153
|
+
args: dict[str, object] = {}
|
|
154
|
+
if start_schedule_id is not None:
|
|
155
|
+
args["start_schedule_id"] = start_schedule_id
|
|
156
|
+
body = self._client.query(revision_id, "list_schedules", engine=SCHEDULER_ENGINE, **args)
|
|
157
|
+
return body["schedules"], body["last_schedule_id"]
|
|
@@ -0,0 +1,51 @@
|
|
|
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`.
|
|
6
|
+
Invariant: no instance of any of these classes ever carries the API key — the client that
|
|
7
|
+
raises them holds the only copy, and it is never included in a message here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from blobhub_cli import codes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApiError(Exception):
|
|
14
|
+
"""Base class for every error `Client` raises."""
|
|
15
|
+
|
|
16
|
+
code = "API_ERROR"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ApiAuthError(ApiError):
|
|
20
|
+
"""The API key was rejected or the resource is forbidden (HTTP 401/403)."""
|
|
21
|
+
|
|
22
|
+
code = codes.AUTH_INVALID
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ApiRateLimited(ApiError):
|
|
26
|
+
"""The server asked the caller to slow down (HTTP 429). Retried."""
|
|
27
|
+
|
|
28
|
+
code = codes.API_RATE_LIMITED
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ApiTransientError(ApiError):
|
|
32
|
+
"""A server-side or malformed-response failure (HTTP 5xx, non-JSON body). Retried."""
|
|
33
|
+
|
|
34
|
+
code = codes.API_TRANSIENT_ERROR
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ApiNetworkError(ApiError):
|
|
38
|
+
"""The request never reached the server (DNS, connection, timeout). Retried."""
|
|
39
|
+
|
|
40
|
+
code = codes.API_NETWORK_ERROR
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ApiCommandError(ApiError):
|
|
44
|
+
"""A successful HTTP response whose body reports `status` other than `success`."""
|
|
45
|
+
|
|
46
|
+
code = codes.API_COMMAND_FAILED
|
|
47
|
+
|
|
48
|
+
def __init__(self, error: str, message: str = "") -> None:
|
|
49
|
+
super().__init__(message or error)
|
|
50
|
+
self.error = error
|
|
51
|
+
self.message = message or error
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Blob-addressing helpers shared by the `blob` command group and the manifest layer.
|
|
2
|
+
|
|
3
|
+
Contract: `reference` is the sole implementation of how a blob reference splits into an org and
|
|
4
|
+
a name, so both the manifest layer (via `ManifestBase.org()` / `ManifestBase.blob_name()`) and
|
|
5
|
+
the blob command group (via positional `<org/blob>` argument parsing) can reuse it without
|
|
6
|
+
drifting.
|
|
7
|
+
Invariant: nothing in this package performs network I/O, file system I/O, or external calls —
|
|
8
|
+
all functions are pure textual operations on references.
|
|
9
|
+
"""
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The one definition of how a blob reference splits into an org and a name.
|
|
2
|
+
|
|
3
|
+
Contract: `split` performs the textual split and nothing else, so `ManifestBase` can reuse it
|
|
4
|
+
without inheriting `parse`'s org-defaulting or its CliExit behaviour; `parse` adds the default-org
|
|
5
|
+
rule and is what positional `<org/blob>` arguments go through; `not_accessible` builds the one
|
|
6
|
+
BLOB_NOT_ACCESSIBLE both `manifest.resolver` and the `blob` commands raise.
|
|
7
|
+
Invariant: a reference has at most one "/" -- `manifest.base.parse` checks that separately for
|
|
8
|
+
manifests (raising MANIFEST_INVALID, since there it really came from a manifest), and `parse`
|
|
9
|
+
checks it here for positional arguments.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from blobhub_cli import codes, ids
|
|
15
|
+
from blobhub_cli.exit import CliExit
|
|
16
|
+
|
|
17
|
+
# What a hint names when the caller does not say which command it is running.
|
|
18
|
+
DEFAULT_COMMAND = "blob show"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class BlobRef:
|
|
23
|
+
org: str
|
|
24
|
+
name: str
|
|
25
|
+
|
|
26
|
+
def label(self) -> str:
|
|
27
|
+
return f"{self.org}/{self.name}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def split(text: str) -> tuple[str | None, str]:
|
|
31
|
+
segments = text.split("/", 1)
|
|
32
|
+
return (segments[0], segments[1]) if len(segments) == 2 else (None, text)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def not_accessible(ref: BlobRef) -> CliExit:
|
|
36
|
+
"""The one BLOB_NOT_ACCESSIBLE, shared by `manifest.resolver` and the `blob` commands.
|
|
37
|
+
|
|
38
|
+
The platform answers the same 403 for a blob that is absent and for one this key cannot see,
|
|
39
|
+
so the message cannot distinguish them. The UUID clause is the only actionable thing the CLI
|
|
40
|
+
can add: an org-scoped key must address its org by UUID and gets exactly this 403 against an
|
|
41
|
+
alias -- which is why it belongs to the message rather than to one of the two raise sites.
|
|
42
|
+
"""
|
|
43
|
+
message = f"blob {ref.label()} is absent or not accessible with this key"
|
|
44
|
+
if not ids.looks_like_uuid(ref.org):
|
|
45
|
+
message += f"; if this API key is org-scoped, it must address the org by UUID, not the alias {ref.org!r}"
|
|
46
|
+
return CliExit(codes.BLOB_NOT_ACCESSIBLE, message, "blobhub whoami")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse(text: str, default_org: str | None, *, command: str = DEFAULT_COMMAND) -> BlobRef:
|
|
50
|
+
# `command` is the sub-command the caller is running ("blob revisions", "blob limits", ...),
|
|
51
|
+
# threaded in only so that every hint below is a command the user can actually paste. It is no
|
|
52
|
+
# part of the parsing rule, which is why it is keyword-only and defaulted.
|
|
53
|
+
usage_hint = f"blobhub {command} <org>/<blob>"
|
|
54
|
+
if not text:
|
|
55
|
+
raise CliExit(codes.BLOB_REFERENCE_INVALID, "blob reference is empty", usage_hint)
|
|
56
|
+
if text.count("/") > 1:
|
|
57
|
+
raise CliExit(
|
|
58
|
+
codes.BLOB_REFERENCE_INVALID,
|
|
59
|
+
f"blob reference {text!r} has more than one '/'",
|
|
60
|
+
usage_hint,
|
|
61
|
+
)
|
|
62
|
+
org, name = split(text)
|
|
63
|
+
if org == "":
|
|
64
|
+
# A leading "/" leaves an empty org segment. Rejected explicitly rather than left to fall
|
|
65
|
+
# through to the default org, which would address a blob the user never named -- and, with
|
|
66
|
+
# no default org, would produce the nonsense advice to qualify it as '<org>//flow'.
|
|
67
|
+
raise CliExit(
|
|
68
|
+
codes.BLOB_REFERENCE_INVALID,
|
|
69
|
+
f"blob reference {text!r} has an empty org before the '/'",
|
|
70
|
+
usage_hint,
|
|
71
|
+
)
|
|
72
|
+
org = org or default_org
|
|
73
|
+
if not org:
|
|
74
|
+
# Every way of supplying the org is named, matching `resolver.resolve`'s wording: a user
|
|
75
|
+
# hitting this has three options and no way to guess the two that aren't on their command
|
|
76
|
+
# line.
|
|
77
|
+
raise CliExit(
|
|
78
|
+
codes.BLOB_REFERENCE_INVALID,
|
|
79
|
+
f"blob {text!r} has no org: qualify it as '<org>/{text}', pass --org <org>, or "
|
|
80
|
+
"set BLOBHUB_ORG",
|
|
81
|
+
f"blobhub --org <org> {command} {text}",
|
|
82
|
+
)
|
|
83
|
+
if not name:
|
|
84
|
+
raise CliExit(
|
|
85
|
+
codes.BLOB_REFERENCE_INVALID, f"blob reference {text!r} has no blob name", usage_hint
|
|
86
|
+
)
|
|
87
|
+
return BlobRef(org=org, name=name)
|