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.
Files changed (58) hide show
  1. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/PKG-INFO +1 -1
  2. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/__init__.py +1 -1
  3. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/api/client.py +12 -16
  4. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/api/commands.py +20 -36
  5. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/blob.py +3 -3
  6. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/scheduler.py +1 -1
  7. blobhub_cli-0.3.0/src/blobhub_cli/ids.py +42 -0
  8. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/manifest/resolver.py +19 -18
  9. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/manifest/scheduler.py +24 -6
  10. blobhub_cli-0.2.0/src/blobhub_cli/ids.py +0 -18
  11. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/.gitignore +0 -0
  12. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/LICENSE +0 -0
  13. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/README.md +0 -0
  14. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/pyproject.toml +0 -0
  15. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/__main__.py +0 -0
  16. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/api/__init__.py +0 -0
  17. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/api/errors.py +0 -0
  18. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/blob/__init__.py +0 -0
  19. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/blob/reference.py +0 -0
  20. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/cli/__init__.py +0 -0
  21. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/cli/app.py +0 -0
  22. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/codes.py +0 -0
  23. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/__init__.py +0 -0
  24. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/auth.py +0 -0
  25. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/completion.py +0 -0
  26. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/doctor.py +0 -0
  27. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/eject.py +0 -0
  28. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/execute.py +0 -0
  29. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/commands/workflow.py +0 -0
  30. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/compiler/__init__.py +0 -0
  31. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/compiler/allowlist.py +0 -0
  32. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/compiler/emit.py +0 -0
  33. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/compiler/graph.py +0 -0
  34. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/compiler/sandbox.py +0 -0
  35. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/config/__init__.py +0 -0
  36. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/config/credentials.py +0 -0
  37. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/config/settings.py +0 -0
  38. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/config/sources.py +0 -0
  39. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/console.py +0 -0
  40. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/definition/__init__.py +0 -0
  41. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/definition/drift.py +0 -0
  42. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/definition/eject.py +0 -0
  43. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/definition/hashing.py +0 -0
  44. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/definition/model.py +0 -0
  45. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/exit.py +0 -0
  46. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/formats/__init__.py +0 -0
  47. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/formats/io.py +0 -0
  48. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/limits.py +0 -0
  49. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/manifest/__init__.py +0 -0
  50. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/manifest/base.py +0 -0
  51. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/manifest/workflow.py +0 -0
  52. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/scheduler/__init__.py +0 -0
  53. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/scheduler/references.py +0 -0
  54. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/scheduler/schedules.py +0 -0
  55. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/statestore.py +0 -0
  56. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/workflow/__init__.py +0 -0
  57. {blobhub_cli-0.2.0 → blobhub_cli-0.3.0}/src/blobhub_cli/workflow/definitions.py +0 -0
  58. {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.2.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/
@@ -5,4 +5,4 @@ Contract: this module is the sole source of the package version — hatchling re
5
5
  Invariant: no other file in this package defines `__version__`.
6
6
  """
7
7
 
8
- __version__ = "0.2.0"
8
+ __version__ = "0.3.0"
@@ -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`/`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.
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, *, engine: str, **args: object) -> dict:
82
- return self._data_request(revision_id, "query", command, engine=engine, gate_status=True, **args)
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, *, engine: str, **args: object) -> dict:
85
- return self._data_request(revision_id, "command", command, engine=engine, gate_status=True, **args)
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, *, engine: str, **args: object) -> dict:
88
- return self._data_request(revision_id, "query", command, engine=engine, gate_status=False, **args)
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, *, engine: str, gate_status: bool, **args: object
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 = {"engine": engine, "command": command, **args}
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 engine command.
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 SCHEDULER_ENGINE, WORKFLOW_ENGINE, Client
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", engine=WORKFLOW_ENGINE, **args)["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", engine=WORKFLOW_ENGINE, definition_id=definition_id)
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", engine=WORKFLOW_ENGINE, **args)["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", engine=WORKFLOW_ENGINE, session_id=session)["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", engine=WORKFLOW_ENGINE, session_id=session_id, definition_id=definition_id
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", engine=WORKFLOW_ENGINE, execution_id=execution_id)[
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 engine schema is
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", engine=WORKFLOW_ENGINE, **args)[
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", engine=WORKFLOW_ENGINE, **args)
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", engine=SCHEDULER_ENGINE, **fields)["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", engine=SCHEDULER_ENGINE, schedule_id=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", engine=SCHEDULER_ENGINE, schedule_id=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", engine=SCHEDULER_ENGINE, **args)
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 workflow-shape or revision-phase gates.
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"), ("Domain", "domain"),
49
- ("Type", "type"), ("Format", "format"), ("Visibility", "visibility"), ("Status", "status"),
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 engine schema is additionalProperties: false
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` domain/type/format
4
- shape (before any engine call, which would otherwise answer an opaque `500`), `latest`
5
- resolution through `blob.latest_revision_id`, then the status/phase gate, in that fixed order.
6
- `expect` is a required keyword-only argument -- this module resolves `blob`/`revision`
7
- coordinates for any blob domain, and a default of the workflow shape would leave that one
8
- domain's assumption sitting inside a resolver that calls itself general.
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 = {"domain": "workflow", "type": "generic", "format": "blobhub"}
27
- SCHEDULER_BLOB = {"domain": "scheduler", "type": "generic", "format": "blobhub"}
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: dict) -> str:
41
- """The CLI command group that manages blobs of the expected shape -- `workflow` for a workflow
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 blob's `domain` *is* the group name in this CLI, by construction of both command trees.
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["domain"]
48
+ return expect.rsplit(".", 1)[-1]
48
49
 
49
50
 
50
51
  def resolve(
51
- api: Api, base: ManifestBase, *, require_writable: bool, expect: dict, default_org: str | None = None
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
- shape = {"domain": blob.get("domain"), "type": blob.get("type"), "format": blob.get("format")}
73
- if shape != expect:
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
- # domain: renaming a published error code to gain one word would break `--json`
76
- # consumers for no behavioural gain. The message names the expected shape instead.
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 {shape}, not a {expect} blob",
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 (blobhub-infra
30
- # `api/storage/scheduler.py` -> `workflow.py`): `^[a-z0-9_-]*$`, minLength 6, maxLength 42 -- the
31
- # same platform handle grammar `manifest/workflow.py` already enforces on a definition alias, spelt
32
- # the same way here rather than shared, since the two schemas are equal by coincidence of policy and
33
- # either could move without the other.
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