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.
Files changed (58) hide show
  1. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/PKG-INFO +2 -2
  2. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/README.md +1 -1
  3. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/__init__.py +1 -1
  4. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/client.py +18 -18
  5. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/commands.py +34 -38
  6. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/cli/app.py +2 -2
  7. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/codes.py +0 -1
  8. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/blob.py +37 -43
  9. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/execute.py +9 -2
  10. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/scheduler.py +33 -25
  11. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/workflow.py +1 -1
  12. blobhub_cli-0.4.0/src/blobhub_cli/ids.py +64 -0
  13. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/limits.py +0 -2
  14. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/base.py +19 -3
  15. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/resolver.py +35 -35
  16. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/scheduler.py +39 -7
  17. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/scheduler/references.py +41 -28
  18. blobhub_cli-0.2.0/src/blobhub_cli/ids.py +0 -18
  19. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/.gitignore +0 -0
  20. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/LICENSE +0 -0
  21. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/pyproject.toml +0 -0
  22. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/__main__.py +0 -0
  23. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/__init__.py +0 -0
  24. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/errors.py +0 -0
  25. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/blob/__init__.py +0 -0
  26. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/blob/reference.py +0 -0
  27. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/cli/__init__.py +0 -0
  28. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/__init__.py +0 -0
  29. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/auth.py +0 -0
  30. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/completion.py +0 -0
  31. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/doctor.py +0 -0
  32. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/eject.py +0 -0
  33. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/__init__.py +0 -0
  34. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/allowlist.py +0 -0
  35. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/emit.py +0 -0
  36. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/graph.py +0 -0
  37. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/sandbox.py +0 -0
  38. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/__init__.py +0 -0
  39. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/credentials.py +0 -0
  40. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/settings.py +0 -0
  41. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/sources.py +0 -0
  42. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/console.py +0 -0
  43. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/__init__.py +0 -0
  44. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/drift.py +0 -0
  45. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/eject.py +0 -0
  46. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/hashing.py +0 -0
  47. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/model.py +0 -0
  48. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/exit.py +0 -0
  49. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/formats/__init__.py +0 -0
  50. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/formats/io.py +0 -0
  51. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/__init__.py +0 -0
  52. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/workflow.py +0 -0
  53. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/scheduler/__init__.py +0 -0
  54. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/scheduler/schedules.py +0 -0
  55. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/statestore.py +0 -0
  56. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/workflow/__init__.py +0 -0
  57. {blobhub_cli-0.2.0 → blobhub_cli-0.4.0}/src/blobhub_cli/workflow/definitions.py +0 -0
  58. {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.2.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: latest # or a pinned revision UUID
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: latest # or a pinned revision UUID
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
@@ -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.4.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
@@ -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
- return self._send("GET", path, gate_status=True)
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, *, engine: str, **args: object) -> dict:
82
- return self._data_request(revision_id, "query", command, engine=engine, gate_status=True, **args)
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, *, engine: str, **args: object) -> dict:
85
- return self._data_request(revision_id, "command", command, engine=engine, gate_status=True, **args)
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, *, engine: str, **args: object) -> dict:
88
- return self._data_request(revision_id, "query", command, engine=engine, gate_status=False, **args)
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, *, engine: str, gate_status: bool, **args: object
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 = {"engine": engine, "command": command, **args}
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 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:
@@ -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 list_revisions(self, org: str, blob: str) -> list[dict]:
30
- return self._client.get(f"/blobs/{org}/{blob}/revisions")["revisions"]
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", engine=WORKFLOW_ENGINE, **args)["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", engine=WORKFLOW_ENGINE, definition_id=definition_id)
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", engine=WORKFLOW_ENGINE, **args)["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", engine=WORKFLOW_ENGINE, session_id=session)["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", engine=WORKFLOW_ENGINE, session_id=session_id, definition_id=definition_id
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", engine=WORKFLOW_ENGINE, execution_id=execution_id)[
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 engine schema is
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", engine=WORKFLOW_ENGINE, **args)[
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", engine=WORKFLOW_ENGINE, **args)
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", engine=SCHEDULER_ENGINE, **fields)["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", engine=SCHEDULER_ENGINE, schedule_id=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", engine=SCHEDULER_ENGINE, schedule_id=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", engine=SCHEDULER_ENGINE, **args)
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 latest revision."""
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 latest."""
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 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
  """
@@ -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"), ("Domain", "domain"),
49
- ("Type", "type"), ("Format", "format"), ("Visibility", "visibility"), ("Status", "status"),
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("latest_revision_id")
53
- revision = payload["latest_revision"]
54
- print(f"{'Latest:':<12} {revision_id or '(none)'}")
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("latest_revision_id")
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, "latest_revision": revision}
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["latest"] else " "
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 latest, so the blob record is required, not
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
- latest_revision_id = record.get("latest_revision_id")
105
- found = api.list_revisions(ref.org, ref.name)
106
-
107
- maximum = limits_module.maximum(
108
- limits_module.read_optional(api, ref.org, ref.name), limits_module.LIMIT_REVISIONS_PER_BLOB
109
- )
110
- if maximum is not None and len(found) >= maximum:
111
- console.advisory(
112
- codes.REVISION_LIST_TRUNCATED,
113
- f"the revision listing returned {len(found)} revision(s), matching the blob's configured "
114
- f"maximum of {maximum} -- the platform cannot page this listing, so the result may be "
115
- "missing revisions it silently dropped",
116
- )
117
-
118
- # Newest first: DynamoDB returns index order, which is neither documented nor useful, and the
119
- # reason to run this command is almost always to find the newest revision.
120
- items = sorted(
121
- (
122
- {
123
- "id": item["id"],
124
- "phase": item.get("phase", ""),
125
- "status": item.get("status", ""),
126
- "created_at": item.get("created_at", ""),
127
- "comment": item.get("comment", ""),
128
- "parent_id": item.get("parent_id"),
129
- "latest": item["id"] == latest_revision_id,
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
- url = f"{DEBUGGER_BASE_URL}/{target.org_id}/{target.blob_id}/debugger/{session_id}/{execution['id']}"
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/latest, so this table reads like
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, original_revision: str) -> dict:
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
- "target.revision": target.revision_id,
280
- "target.session": target.session_id,
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.revision": invocation_target["revision_id"],
302
- "target.session": invocation_target["session_id"],
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
- "revision_id": resolved.revision_id,
378
- "session_id": resolved.session_id,
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 engine schema is additionalProperties: false
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 "latest", so this table reads like its sibling at a glance.
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