verdog-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.
verdog_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """The Verdog command-line client."""
verdog_cli/api.py ADDED
@@ -0,0 +1,283 @@
1
+ """HTTP client for compiler and authenticated catalogue operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from typing import Any, cast, override
11
+
12
+
13
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
14
+ @override
15
+ def redirect_request(
16
+ self,
17
+ req: urllib.request.Request,
18
+ fp: Any,
19
+ code: int,
20
+ msg: str,
21
+ headers: Any,
22
+ newurl: str,
23
+ ) -> None:
24
+ # Tokens and uploaded source stay at the explicitly selected
25
+ # destination.
26
+ return None
27
+
28
+
29
+ urlopen = urllib.request.build_opener(_NoRedirect()).open
30
+
31
+
32
+ class ServiceError(Exception):
33
+ """The service refused, or could not be reached."""
34
+
35
+ def __init__(
36
+ self,
37
+ message: str,
38
+ *,
39
+ code: str = "service.error",
40
+ details: object = None,
41
+ machine_message: str | None = None,
42
+ ) -> None:
43
+ """Retain the human-readable message and structured service error."""
44
+ super().__init__(message)
45
+ self.code = code
46
+ self.details = details
47
+ self.machine_message = machine_message or message
48
+
49
+
50
+ def format_error(message: str, code: object, details: object = None) -> str:
51
+ """A compiler error for a terminal, including any affected paths."""
52
+ lines = [f"{message} [{code}]" if isinstance(code, str) else message]
53
+ paths = (
54
+ cast(dict[str, object], details).get("paths")
55
+ if isinstance(details, dict)
56
+ else None
57
+ )
58
+ if isinstance(paths, list):
59
+ lines.extend(
60
+ f" {path}"
61
+ for path in cast(list[object], paths)
62
+ if isinstance(path, str)
63
+ )
64
+ return "\n".join(lines)
65
+
66
+
67
+ @dataclasses.dataclass(frozen=True, slots=True)
68
+ class Service:
69
+ """A service client with optional credentials and no redirects."""
70
+
71
+ origin: str
72
+ token: str | None = dataclasses.field(default=None, repr=False)
73
+ timeout: float = 60.0
74
+
75
+ def json(
76
+ self,
77
+ method: str,
78
+ path: str,
79
+ body: dict[str, Any] | None = None,
80
+ ) -> dict[str, Any]:
81
+ """Send JSON, returning an object or raising ServiceError."""
82
+ payload = None if body is None else json.dumps(body).encode("utf-8")
83
+ request = urllib.request.Request( # noqa: S310 - the origin comes from the local config
84
+ f"{self.origin.rstrip('/')}{path}",
85
+ data=payload,
86
+ method=method,
87
+ headers={
88
+ "Accept": "application/json",
89
+ **(
90
+ {"Authorization": f"Bearer {self.token}"}
91
+ if self.token
92
+ else {}
93
+ ),
94
+ **(
95
+ {"Content-Type": "application/json"}
96
+ if payload is not None
97
+ else {}
98
+ ),
99
+ },
100
+ )
101
+ try:
102
+ with urlopen(request, timeout=self.timeout) as response: # noqa: S310
103
+ raw = cast(bytes, response.read())
104
+ except urllib.error.HTTPError as error:
105
+ raise _service_error(error) from error
106
+ except (urllib.error.URLError, OSError, TimeoutError) as error:
107
+ raise ServiceError(
108
+ f"{self.origin} could not be reached: {error}",
109
+ code="service.unavailable",
110
+ ) from error
111
+ if not raw:
112
+ return {}
113
+ try:
114
+ decoded = json.loads(raw)
115
+ except ValueError as error:
116
+ raise ServiceError(
117
+ f"{path} did not return valid JSON",
118
+ code="service.invalid_response",
119
+ ) from error
120
+ if not isinstance(decoded, dict):
121
+ raise ServiceError(
122
+ f"{path} did not return a JSON object",
123
+ code="service.invalid_response",
124
+ )
125
+ return cast(dict[str, Any], decoded)
126
+
127
+ # the compiler
128
+
129
+ def check(self, files: dict[str, str]) -> dict[str, Any]:
130
+ """Validate project sources and request their generated projection."""
131
+ return self.json("POST", "/api/v1/check", {"files": files})
132
+
133
+ def analyze(self, files: dict[str, str]) -> dict[str, Any]:
134
+ """Analyze graph manifests without executing project code."""
135
+ return self.json("POST", "/api/v1/analyze", {"files": files})
136
+
137
+ def rename(
138
+ self,
139
+ files: dict[str, str],
140
+ kind: str,
141
+ subroutine_id: str | None,
142
+ old_id: str,
143
+ new_id: str,
144
+ ) -> dict[str, Any]:
145
+ """Request an identity rename and its affected source projection."""
146
+ body: dict[str, Any] = {
147
+ "files": files,
148
+ "kind": kind,
149
+ "old_id": old_id,
150
+ "new_id": new_id,
151
+ }
152
+ if kind not in {"workflow", "subroutine"}:
153
+ body["subroutine_id"] = subroutine_id
154
+ return self.json("POST", "/api/v1/entities/rename", body)
155
+
156
+ # the account and the repository
157
+
158
+ def me(self) -> dict[str, Any]:
159
+ """Return the authenticated account and its access information."""
160
+ return self.json("GET", "/api/v1/me")
161
+
162
+ def access(self, owner: str, name: str) -> dict[str, Any]:
163
+ """Return the current account's access to a GitHub repository."""
164
+ return self.json("GET", f"/api/v1/access?repository={owner}/{name}")
165
+
166
+ # the catalogue
167
+
168
+ def catalogue(
169
+ self,
170
+ *,
171
+ query: str | None = None,
172
+ visibility: str | None = None,
173
+ limit: int | None = None,
174
+ cursor: str | None = None,
175
+ ) -> dict[str, Any]:
176
+ """List visible workflow offers with optional search and pagination."""
177
+ parameters: dict[str, str | int] = {}
178
+ if query:
179
+ parameters["q"] = query
180
+ if visibility and visibility != "all":
181
+ parameters["visibility"] = visibility
182
+ if limit is not None:
183
+ parameters["limit"] = limit
184
+ if cursor:
185
+ parameters["cursor"] = cursor
186
+ suffix = f"?{urllib.parse.urlencode(parameters)}" if parameters else ""
187
+ return self.json("GET", f"/api/v1/catalogue{suffix}")
188
+
189
+ def entry(self, entry_id: str) -> dict[str, Any]:
190
+ """One offer, with the preview the listing leaves out."""
191
+ return self.json("GET", f"/api/v1/catalogue/{entry_id}")
192
+
193
+ def publish(
194
+ self,
195
+ repository: str,
196
+ commit: str,
197
+ workflow_id: str,
198
+ package: str,
199
+ description: str,
200
+ preview: dict[str, Any],
201
+ *,
202
+ private: bool | None = None,
203
+ closure: list[dict[str, Any]] | None = None,
204
+ environment: dict[str, Any] | None = None,
205
+ ) -> dict[str, Any]:
206
+ """Publish immutable workflow metadata for an accessible repository."""
207
+ body: dict[str, Any] = {
208
+ "repository": repository,
209
+ "commit": commit,
210
+ "workflow_id": workflow_id,
211
+ "package": package,
212
+ "description": description,
213
+ "preview": preview,
214
+ }
215
+ if private is not None:
216
+ body["private"] = private
217
+ if closure:
218
+ body["closure"] = closure
219
+ if environment is not None:
220
+ body["environment"] = environment
221
+ return self.json("POST", "/api/v1/catalogue", body)
222
+
223
+ def retract(self, entry_id: str) -> dict[str, Any]:
224
+ """Remove an offer from the catalogue."""
225
+ return self.json("DELETE", f"/api/v1/catalogue/{entry_id}")
226
+
227
+ def preflight(
228
+ self, repository: str, commit: str, workflow_id: str
229
+ ) -> dict[str, Any]:
230
+ """Check access and resolve the metadata for a proposed import."""
231
+ return self.json(
232
+ "POST",
233
+ "/api/v1/catalogue/imports",
234
+ {
235
+ "repository": repository,
236
+ "commit": commit,
237
+ "workflow_id": workflow_id,
238
+ },
239
+ )
240
+
241
+ # tokens
242
+
243
+ def create_token(self, repository: str, name: str) -> dict[str, Any]:
244
+ """Create a named, repository-scoped token."""
245
+ return self.json(
246
+ "POST", "/api/v1/tokens", {"repository": repository, "name": name}
247
+ )
248
+
249
+ def list_tokens(self, repository: str) -> dict[str, Any]:
250
+ """List repository token metadata without exposing token values."""
251
+ return self.json("GET", f"/api/v1/tokens?repository={repository}")
252
+
253
+ def revoke_token(self, repository: str, token_id: str) -> dict[str, Any]:
254
+ """Revoke a repository token by its identifier."""
255
+ return self.json(
256
+ "DELETE", f"/api/v1/tokens/{token_id}?repository={repository}"
257
+ )
258
+
259
+
260
+ def _service_error(error: urllib.error.HTTPError) -> ServiceError:
261
+ """Retain the service's structured refusal for machine-mode CLI callers."""
262
+ message = f"{error.code} {error.reason}"
263
+ try:
264
+ payload = json.loads(error.read())
265
+ except (ValueError, OSError):
266
+ return ServiceError(message, code="service.http_error")
267
+ if isinstance(payload, dict):
268
+ detail = cast(dict[str, Any], payload).get("error")
269
+ if not isinstance(detail, dict):
270
+ return ServiceError(message, code="service.http_error")
271
+ fields = cast(dict[str, Any], detail)
272
+ text, code = fields.get("message"), fields.get("code")
273
+ if isinstance(text, str):
274
+ return ServiceError(
275
+ format_error(text, code, fields.get("details")),
276
+ code=code if isinstance(code, str) else "service.http_error",
277
+ details=fields.get("details"),
278
+ machine_message=text,
279
+ )
280
+ return ServiceError(message, code="service.http_error")
281
+
282
+
283
+ __all__ = ["Service", "ServiceError", "format_error"]
@@ -0,0 +1,171 @@
1
+ """Read catalogue metadata from manifests without importing authored code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import importlib.metadata
7
+ from typing import Any, cast
8
+
9
+ from verdog_cli import local
10
+ from verdog_cli import requirements as cli_requirements
11
+
12
+
13
+ @dataclasses.dataclass(frozen=True, slots=True)
14
+ class EnvironmentDescription:
15
+ """The declarative inputs needed to prepare one workflow environment."""
16
+
17
+ python: str
18
+ schema_version: int
19
+ requirements: tuple[str, ...]
20
+
21
+ def as_json(self) -> dict[str, Any]:
22
+ """Return the environment contract as a JSON-compatible object."""
23
+ return {
24
+ "python": self.python,
25
+ "schema_version": self.schema_version,
26
+ "requirements": list(self.requirements),
27
+ }
28
+
29
+
30
+ @dataclasses.dataclass(frozen=True, slots=True)
31
+ class WorkflowDescription:
32
+ """The publisher claim for one workflow at one immutable source revision."""
33
+
34
+ package: str
35
+ workflow_id: str
36
+ display_name: str
37
+ preview: dict[str, Any]
38
+ closure: tuple[dict[str, Any], ...]
39
+ environment: EnvironmentDescription
40
+
41
+ def as_json(self) -> dict[str, Any]:
42
+ """Return the workflow offer as a JSON-compatible object."""
43
+ return {
44
+ "schema_version": local.SCHEMA_VERSION,
45
+ "package": self.package,
46
+ "workflow_id": self.workflow_id,
47
+ "display_name": self.display_name,
48
+ "preview": self.preview,
49
+ "closure": list(self.closure),
50
+ "environment": self.environment.as_json(),
51
+ }
52
+
53
+
54
+ def describe_workflow(
55
+ clone: local.Clone, workflow_id: str | None = None
56
+ ) -> WorkflowDescription:
57
+ """Read a workflow description without network access or execution."""
58
+ workflow = clone.workflow_definition(workflow_id)
59
+ display_name, preview = _preview(clone, workflow)
60
+ package = clone.project.get("package")
61
+ if not isinstance(package, str):
62
+ raise local.WorkspaceError("project.json has no package")
63
+ try:
64
+ python = cli_requirements.canonical_python_specifier(
65
+ _python_requirement()
66
+ )
67
+ requirements = cli_requirements.canonical_requirements(
68
+ clone.workflow_requirements(workflow)
69
+ )
70
+ except ValueError as error:
71
+ raise local.WorkspaceError(str(error)) from error
72
+ return WorkflowDescription(
73
+ package=package,
74
+ workflow_id=workflow.local_id,
75
+ display_name=display_name,
76
+ preview=preview,
77
+ closure=dependency_closure(clone),
78
+ environment=EnvironmentDescription(
79
+ python=python,
80
+ schema_version=local.SCHEMA_VERSION,
81
+ requirements=requirements,
82
+ ),
83
+ )
84
+
85
+
86
+ def preview_for(clone: local.Clone, workflow_id: str) -> dict[str, Any]:
87
+ """Return a workflow's graph preview without environment metadata."""
88
+ _, preview = _preview(clone, clone.workflow_definition(workflow_id))
89
+ return preview
90
+
91
+
92
+ def _preview(
93
+ clone: local.Clone, workflow: local.LocalDefinition
94
+ ) -> tuple[str, dict[str, Any]]:
95
+ subroutine = clone.subroutine_for(workflow).body
96
+ display_name = str(subroutine.get("name", workflow.local_id))
97
+ preview: dict[str, Any] = {
98
+ "name": display_name,
99
+ "ports": _object(subroutine.get("ports")),
100
+ "nodes": _objects(subroutine.get("nodes")),
101
+ "edges": _objects(subroutine.get("edges")),
102
+ "features": _objects(subroutine.get("features")),
103
+ }
104
+ return display_name, preview
105
+
106
+
107
+ def _python_requirement() -> str:
108
+ """Use the installed runtime's constraint instead of repeating it here."""
109
+ try:
110
+ requirement = importlib.metadata.metadata("verdog-runtime").get(
111
+ "Requires-Python"
112
+ )
113
+ except importlib.metadata.PackageNotFoundError as error:
114
+ raise local.WorkspaceError(
115
+ "verdog_runtime is not installed; reinstall verdog-cli"
116
+ ) from error
117
+ if not requirement:
118
+ raise local.WorkspaceError(
119
+ "verdog_runtime has no Python compatibility "
120
+ "metadata; reinstall verdog-cli"
121
+ )
122
+ return requirement
123
+
124
+
125
+ def dependency_closure(clone: local.Clone) -> tuple[dict[str, Any], ...]:
126
+ """The unique immutable repository releases reachable from this project."""
127
+ found: list[dict[str, Any]] = []
128
+ walked: set[tuple[str, str, str]] = set()
129
+ for pin, _ in local.dependency_clones(clone)[1:]:
130
+ assert pin is not None
131
+ owner = str(pin.get("owner", ""))
132
+ name = str(pin.get("name", ""))
133
+ commit = str(pin.get("commit", ""))
134
+ release = (owner, name, commit)
135
+ if release in walked:
136
+ continue
137
+ walked.add(release)
138
+ identifier = pin.get("repository_id")
139
+ if isinstance(identifier, int):
140
+ found.append(
141
+ {
142
+ "repository_id": identifier,
143
+ "owner": owner,
144
+ "name": name,
145
+ "commit": commit,
146
+ }
147
+ )
148
+ return tuple(found)
149
+
150
+
151
+ def _object(value: object) -> dict[str, Any]:
152
+ return cast(dict[str, Any], value) if isinstance(value, dict) else {}
153
+
154
+
155
+ def _objects(value: object) -> list[dict[str, Any]]:
156
+ if not isinstance(value, list):
157
+ return []
158
+ return [
159
+ cast(dict[str, Any], item)
160
+ for item in cast(list[object], value)
161
+ if isinstance(item, dict)
162
+ ]
163
+
164
+
165
+ __all__ = [
166
+ "EnvironmentDescription",
167
+ "WorkflowDescription",
168
+ "dependency_closure",
169
+ "describe_workflow",
170
+ "preview_for",
171
+ ]