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 +1 -0
- verdog_cli/api.py +283 -0
- verdog_cli/catalogue.py +171 -0
- verdog_cli/local.py +1158 -0
- verdog_cli/main.py +685 -0
- verdog_cli/manage.py +2263 -0
- verdog_cli/py.typed +0 -0
- verdog_cli/requirements.py +77 -0
- verdog_cli/runner.py +129 -0
- verdog_cli/runs.py +698 -0
- verdog_cli/session.py +256 -0
- verdog_cli/sync.py +548 -0
- verdog_cli-0.1.0.dist-info/METADATA +127 -0
- verdog_cli-0.1.0.dist-info/RECORD +18 -0
- verdog_cli-0.1.0.dist-info/WHEEL +5 -0
- verdog_cli-0.1.0.dist-info/entry_points.txt +2 -0
- verdog_cli-0.1.0.dist-info/licenses/LICENSE +235 -0
- verdog_cli-0.1.0.dist-info/top_level.txt +1 -0
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"]
|
verdog_cli/catalogue.py
ADDED
|
@@ -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
|
+
]
|