zuke 1.0.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.
- zuke/__init__.py +76 -0
- zuke/_awaited.py +15 -0
- zuke/_build.py +169 -0
- zuke/_ci.py +141 -0
- zuke/_context.py +214 -0
- zuke/_dsl.py +629 -0
- zuke/_duration.py +23 -0
- zuke/_facade.py +79 -0
- zuke/_field.py +28 -0
- zuke/_generate.py +411 -0
- zuke/_guard.py +69 -0
- zuke/_rpc.py +198 -0
- zuke/_run.py +34 -0
- zuke/_settings.py +162 -0
- zuke/_worker.py +214 -0
- zuke/py.typed +0 -0
- zuke/tools/__init__.py +9 -0
- zuke/tools/_tooling.py +116 -0
- zuke/tools/ai.py +428 -0
- zuke/tools/apple.py +1245 -0
- zuke/tools/argo_rollouts.py +596 -0
- zuke/tools/aws.py +2439 -0
- zuke/tools/az.py +2163 -0
- zuke/tools/biome.py +150 -0
- zuke/tools/bun.py +207 -0
- zuke/tools/chocolatey.py +540 -0
- zuke/tools/claude.py +186 -0
- zuke/tools/cmd.py +43 -0
- zuke/tools/codecov.py +129 -0
- zuke/tools/codex.py +135 -0
- zuke/tools/console.py +147 -0
- zuke/tools/cspell.py +119 -0
- zuke/tools/cypress.py +183 -0
- zuke/tools/dart.py +729 -0
- zuke/tools/deno.py +2765 -0
- zuke/tools/docker.py +1774 -0
- zuke/tools/docker_compose.py +1292 -0
- zuke/tools/docs.py +60 -0
- zuke/tools/dpdm.py +107 -0
- zuke/tools/dprint.py +87 -0
- zuke/tools/eslint.py +111 -0
- zuke/tools/flutter.py +655 -0
- zuke/tools/gcloud.py +2115 -0
- zuke/tools/gemini.py +155 -0
- zuke/tools/gh.py +97 -0
- zuke/tools/git.py +3293 -0
- zuke/tools/google.py +96 -0
- zuke/tools/helm.py +455 -0
- zuke/tools/homebrew.py +750 -0
- zuke/tools/husky.py +86 -0
- zuke/tools/jest.py +115 -0
- zuke/tools/jsr.py +135 -0
- zuke/tools/knip.py +79 -0
- zuke/tools/kubectl.py +1923 -0
- zuke/tools/kustomize.py +93 -0
- zuke/tools/lint_staged.py +82 -0
- zuke/tools/nest.py +259 -0
- zuke/tools/node.py +277 -0
- zuke/tools/npm.py +1320 -0
- zuke/tools/npx.py +71 -0
- zuke/tools/nx.py +135 -0
- zuke/tools/openapi_ts.py +72 -0
- zuke/tools/orval.py +80 -0
- zuke/tools/oxlint.py +103 -0
- zuke/tools/playwright.py +173 -0
- zuke/tools/pnpm.py +219 -0
- zuke/tools/prometheus.py +635 -0
- zuke/tools/qr.py +112 -0
- zuke/tools/redocly.py +143 -0
- zuke/tools/release_please.py +104 -0
- zuke/tools/security.py +341 -0
- zuke/tools/shellcheck.py +77 -0
- zuke/tools/signpath.py +376 -0
- zuke/tools/storybook.py +106 -0
- zuke/tools/terraform.py +269 -0
- zuke/tools/tofu.py +263 -0
- zuke/tools/tsc.py +164 -0
- zuke/tools/tsc_alias.py +91 -0
- zuke/tools/tsdown.py +124 -0
- zuke/tools/tsup.py +87 -0
- zuke/tools/tsx.py +111 -0
- zuke/tools/turbo.py +115 -0
- zuke/tools/vite.py +157 -0
- zuke/tools/vitest.py +131 -0
- zuke/tools/yarn.py +171 -0
- zuke-1.0.0.dist-info/METADATA +130 -0
- zuke-1.0.0.dist-info/RECORD +89 -0
- zuke-1.0.0.dist-info/WHEEL +4 -0
- zuke-1.0.0.dist-info/licenses/LICENSE +21 -0
zuke/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright (c) 2026 the Zuke contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Zuke for Python: author your build as typed Python, run it on the Zuke engine.
|
|
4
|
+
|
|
5
|
+
A build is a class extending :class:`Build` whose fields are targets made with
|
|
6
|
+
:func:`target`, services made with :func:`service`, parameters made with
|
|
7
|
+
:func:`parameter`, groups made with :func:`group` and CI files made with
|
|
8
|
+
:func:`ci`; the file ends with :func:`run`. The engine starts the file as a
|
|
9
|
+
worker, asks it for the graph, and runs each target's body when its turn comes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from zuke._build import Build
|
|
13
|
+
from zuke._ci import Ci, ci, invoke
|
|
14
|
+
from zuke._context import (
|
|
15
|
+
CommandError,
|
|
16
|
+
Context,
|
|
17
|
+
ExecResult,
|
|
18
|
+
Outcome,
|
|
19
|
+
Plan,
|
|
20
|
+
ServiceContext,
|
|
21
|
+
StateHandle,
|
|
22
|
+
)
|
|
23
|
+
from zuke._dsl import (
|
|
24
|
+
BooleanParameter,
|
|
25
|
+
Group,
|
|
26
|
+
NumberListParameter,
|
|
27
|
+
NumberParameter,
|
|
28
|
+
Parameter,
|
|
29
|
+
Service,
|
|
30
|
+
StringListParameter,
|
|
31
|
+
StringParameter,
|
|
32
|
+
Target,
|
|
33
|
+
group,
|
|
34
|
+
parameter,
|
|
35
|
+
service,
|
|
36
|
+
target,
|
|
37
|
+
)
|
|
38
|
+
from zuke._duration import Duration
|
|
39
|
+
from zuke._run import run
|
|
40
|
+
from zuke._settings import LockSettings, ValidateDuringSettings, WaitSettings
|
|
41
|
+
|
|
42
|
+
__version__ = "1.0.0" # x-release-please-version
|
|
43
|
+
"""The package version; release-please bumps it alongside pyproject.toml."""
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"BooleanParameter",
|
|
47
|
+
"Build",
|
|
48
|
+
"Ci",
|
|
49
|
+
"CommandError",
|
|
50
|
+
"Context",
|
|
51
|
+
"Duration",
|
|
52
|
+
"ExecResult",
|
|
53
|
+
"Group",
|
|
54
|
+
"LockSettings",
|
|
55
|
+
"NumberListParameter",
|
|
56
|
+
"NumberParameter",
|
|
57
|
+
"Outcome",
|
|
58
|
+
"Parameter",
|
|
59
|
+
"Plan",
|
|
60
|
+
"Service",
|
|
61
|
+
"ServiceContext",
|
|
62
|
+
"StateHandle",
|
|
63
|
+
"StringListParameter",
|
|
64
|
+
"StringParameter",
|
|
65
|
+
"Target",
|
|
66
|
+
"ValidateDuringSettings",
|
|
67
|
+
"WaitSettings",
|
|
68
|
+
"__version__",
|
|
69
|
+
"ci",
|
|
70
|
+
"group",
|
|
71
|
+
"invoke",
|
|
72
|
+
"parameter",
|
|
73
|
+
"run",
|
|
74
|
+
"service",
|
|
75
|
+
"target",
|
|
76
|
+
]
|
zuke/_awaited.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright (c) 2026 the Zuke contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Settle what a user's function returned: awaited when it chose to be async."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import inspect
|
|
8
|
+
from collections.abc import Awaitable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def awaited[T](value: T | Awaitable[T]) -> T:
|
|
12
|
+
"""``value`` itself, or its result when it is awaitable."""
|
|
13
|
+
if inspect.isawaitable(value):
|
|
14
|
+
return await value
|
|
15
|
+
return value
|
zuke/_build.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Copyright (c) 2026 the Zuke contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""The build class: its fields are its targets, parameters, groups and CI files.
|
|
4
|
+
|
|
5
|
+
A subclass is read when it is created: every field holding one of those is
|
|
6
|
+
named after its attribute, in declaration order, and every target a field
|
|
7
|
+
names must be a field of the same build, or the class fails to be made with
|
|
8
|
+
the offending field named. The instance is what the worker hosts; the hooks
|
|
9
|
+
a subclass overrides are the ones the manifest flags and the engine calls.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Awaitable, Mapping
|
|
15
|
+
from typing import ClassVar
|
|
16
|
+
|
|
17
|
+
from zuke._ci import Ci
|
|
18
|
+
from zuke._dsl import Group, Parameter, Service, Target
|
|
19
|
+
from zuke._duration import Duration
|
|
20
|
+
from zuke._field import Field
|
|
21
|
+
from zuke._guard import ProtocolError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Build:
|
|
25
|
+
"""Extend this with targets as fields; see :func:`~zuke.target`."""
|
|
26
|
+
|
|
27
|
+
deadline: ClassVar[Duration | None] = None
|
|
28
|
+
"""How long the whole run may take, as a duration or milliseconds."""
|
|
29
|
+
unforceable: ClassVar[tuple[Target, ...]] = ()
|
|
30
|
+
"""Targets ``--force`` may never re-run."""
|
|
31
|
+
|
|
32
|
+
targets_: ClassVar[dict[str, Target]] = {}
|
|
33
|
+
"""The targets by name, in declaration order; set when the subclass is made."""
|
|
34
|
+
parameters_: ClassVar[dict[str, Parameter]] = {}
|
|
35
|
+
"""The parameters by name, in declaration order; set when the subclass is made."""
|
|
36
|
+
groups_: ClassVar[dict[str, Group]] = {}
|
|
37
|
+
"""The groups by name, in declaration order; set when the subclass is made."""
|
|
38
|
+
ci_: ClassVar[dict[str, Ci]] = {}
|
|
39
|
+
"""The CI files by name, in declaration order; set when the subclass is made."""
|
|
40
|
+
|
|
41
|
+
def __init_subclass__(cls) -> None:
|
|
42
|
+
"""Discover and name the fields, then check every reference between them."""
|
|
43
|
+
super().__init_subclass__()
|
|
44
|
+
cls.targets_, cls.parameters_, cls.groups_, cls.ci_ = {}, {}, {}, {}
|
|
45
|
+
for klass in reversed(cls.__mro__):
|
|
46
|
+
if klass is Build or klass is object:
|
|
47
|
+
continue
|
|
48
|
+
for name, value in vars(klass).items():
|
|
49
|
+
if isinstance(value, Target):
|
|
50
|
+
cls.targets_[name] = value
|
|
51
|
+
elif isinstance(value, Parameter):
|
|
52
|
+
cls.parameters_[name] = value
|
|
53
|
+
elif isinstance(value, Group):
|
|
54
|
+
cls.groups_[name] = value
|
|
55
|
+
elif isinstance(value, Ci):
|
|
56
|
+
cls.ci_[name] = value
|
|
57
|
+
else:
|
|
58
|
+
continue
|
|
59
|
+
value.name_ = name
|
|
60
|
+
cls._check_references()
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def _check_references(cls) -> None:
|
|
64
|
+
targets = list(cls.targets_.values())
|
|
65
|
+
for name, t in cls.targets_.items():
|
|
66
|
+
for setter, reference in t.references():
|
|
67
|
+
cls._check(name, setter, reference, targets)
|
|
68
|
+
for p in t.parameters():
|
|
69
|
+
if p not in cls.parameters_.values():
|
|
70
|
+
cls._refuse(
|
|
71
|
+
name,
|
|
72
|
+
"requires",
|
|
73
|
+
"a parameter that is not a field of this build",
|
|
74
|
+
)
|
|
75
|
+
for name, g in cls.groups_.items():
|
|
76
|
+
for member in g.members_:
|
|
77
|
+
cls._check(name, "group", member, targets)
|
|
78
|
+
for name, c in cls.ci_.items():
|
|
79
|
+
for reference in c.references():
|
|
80
|
+
cls._check(name, "invokes", reference, targets)
|
|
81
|
+
for t in cls.unforceable:
|
|
82
|
+
cls._check("unforceable", "unforceable", t, targets)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def _check(
|
|
86
|
+
cls, field: str, setter: str, reference: Field | str, targets: list[Target]
|
|
87
|
+
) -> None:
|
|
88
|
+
if isinstance(reference, str):
|
|
89
|
+
if reference not in cls.targets_:
|
|
90
|
+
cls._refuse(field, setter, f'no target named "{reference}"')
|
|
91
|
+
elif reference not in targets:
|
|
92
|
+
cls._refuse(field, setter, "a target that is not a field of this build")
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def _refuse(cls, field: str, setter: str, what: str) -> None:
|
|
96
|
+
raise TypeError(f"{cls.__name__}.{field}: {setter} names {what}")
|
|
97
|
+
|
|
98
|
+
def on_start(self) -> Awaitable[None] | None:
|
|
99
|
+
"""Override to run before the first target; sync or async."""
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def on_target_start(self, target: str) -> Awaitable[None] | None:
|
|
103
|
+
"""Override to run before each target; sync or async."""
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def on_target_end(self, target: str, status: str) -> Awaitable[None] | None:
|
|
107
|
+
"""Override to run after each target settles, with its status; sync or async."""
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def on_finish(self, ok: bool) -> Awaitable[None] | None:
|
|
111
|
+
"""Override to run when the build is done, told whether it succeeded."""
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def hooks_(self) -> dict[str, bool]:
|
|
115
|
+
"""Which hooks this build overrides: the ones the engine will call."""
|
|
116
|
+
cls = type(self)
|
|
117
|
+
flagged = {
|
|
118
|
+
"onStart": cls.on_start is not Build.on_start,
|
|
119
|
+
"onTargetStart": cls.on_target_start is not Build.on_target_start,
|
|
120
|
+
"onTargetEnd": cls.on_target_end is not Build.on_target_end,
|
|
121
|
+
"onFinish": cls.on_finish is not Build.on_finish,
|
|
122
|
+
}
|
|
123
|
+
return {name: True for name, overridden in flagged.items() if overridden}
|
|
124
|
+
|
|
125
|
+
def describe(self) -> dict[str, object]:
|
|
126
|
+
"""The manifest: what ``build.describe`` answers."""
|
|
127
|
+
m: dict[str, object] = {
|
|
128
|
+
"name": type(self).__name__,
|
|
129
|
+
"targets": [t.manifest() for t in self.targets_.values()],
|
|
130
|
+
}
|
|
131
|
+
for key, fields in (
|
|
132
|
+
("parameters", self.parameters_),
|
|
133
|
+
("groups", self.groups_),
|
|
134
|
+
("ci", self.ci_),
|
|
135
|
+
):
|
|
136
|
+
if fields:
|
|
137
|
+
m[key] = [f.manifest() for f in fields.values()]
|
|
138
|
+
hooks = self.hooks_()
|
|
139
|
+
if hooks:
|
|
140
|
+
m["hooks"] = hooks
|
|
141
|
+
config: dict[str, object] = {}
|
|
142
|
+
if self.deadline is not None:
|
|
143
|
+
config["deadline"] = self.deadline
|
|
144
|
+
if self.unforceable:
|
|
145
|
+
config["unforceable"] = [t.name for t in self.unforceable]
|
|
146
|
+
if config:
|
|
147
|
+
m["config"] = config
|
|
148
|
+
return m
|
|
149
|
+
|
|
150
|
+
def configure(self, parameters: Mapping[str, str | None]) -> None:
|
|
151
|
+
"""Take the resolved parameters from ``build.configure``."""
|
|
152
|
+
for name, p in self.parameters_.items():
|
|
153
|
+
p.resolve(parameters.get(name))
|
|
154
|
+
|
|
155
|
+
def target_(self, name: str, method: str) -> Target:
|
|
156
|
+
"""The target ``method`` asks about, or a :class:`ProtocolError` naming it."""
|
|
157
|
+
t = self.targets_.get(name)
|
|
158
|
+
if t is None:
|
|
159
|
+
raise ProtocolError(
|
|
160
|
+
f'{method}: {type(self).__name__} has no target "{name}"'
|
|
161
|
+
)
|
|
162
|
+
return t
|
|
163
|
+
|
|
164
|
+
def service_(self, name: str, method: str) -> Service:
|
|
165
|
+
"""The service ``method`` asks about, or a :class:`ProtocolError` naming it."""
|
|
166
|
+
t = self.target_(name, method)
|
|
167
|
+
if not isinstance(t, Service):
|
|
168
|
+
raise ProtocolError(f'{method}: "{name}" is not a service')
|
|
169
|
+
return t
|
zuke/_ci.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Copyright (c) 2026 the Zuke contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""CI files a build declares, which ``zuke generate-ci`` renders from the manifest.
|
|
4
|
+
|
|
5
|
+
A CI file is a build field like a target, named after its attribute, which
|
|
6
|
+
names its default path: ``releaseWorkflow`` writes
|
|
7
|
+
``.github/workflows/release.yml``. The engine renders it from the manifest
|
|
8
|
+
exactly as it renders a TypeScript build's.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from typing import Literal, Self
|
|
15
|
+
|
|
16
|
+
from zuke._field import Field, name_of
|
|
17
|
+
from zuke._guard import is_list
|
|
18
|
+
|
|
19
|
+
Provider = Literal["github", "gitlab", "azure", "bitbucket"]
|
|
20
|
+
"""A CI provider the engine renders for."""
|
|
21
|
+
|
|
22
|
+
# shortcut: the pipeline, the invocation adjustments, the fan-out options and
|
|
23
|
+
# the pins are mappings passed through as given, which the engine validates
|
|
24
|
+
# field by field and refuses by name; typed builders for them arrive with the
|
|
25
|
+
# facade generator, which can emit them from core's own CI types.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Ci(Field):
|
|
29
|
+
"""One CI file. Start one with :func:`ci`."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
"""A CI file with nothing declared yet."""
|
|
33
|
+
super().__init__()
|
|
34
|
+
self._provider: Provider | None = None
|
|
35
|
+
self._path: str | None = None
|
|
36
|
+
self._pipeline: dict[str, object] | None = None
|
|
37
|
+
self._invokes: list[Field | str | dict[str, object]] = []
|
|
38
|
+
self._fan_out: bool | dict[str, object] | None = None
|
|
39
|
+
self._pins: dict[str, object] | None = None
|
|
40
|
+
|
|
41
|
+
def provider(self, provider: Provider) -> Self:
|
|
42
|
+
"""The provider to render for; GitHub when not set."""
|
|
43
|
+
self._provider = provider
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def path(self, path: str) -> Self:
|
|
47
|
+
"""The output path, overriding the one the field name implies."""
|
|
48
|
+
self._path = path
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def pipeline(self, fields: Mapping[str, object]) -> Self:
|
|
52
|
+
"""The pipeline fields, and the jobs unless ``invokes`` or ``fan_out`` do."""
|
|
53
|
+
self._pipeline = dict(fields)
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def invokes(self, *entries: Field | str | Mapping[str, object]) -> Self:
|
|
57
|
+
"""The targets to run as jobs: bare, or through :func:`invoke` to adjust."""
|
|
58
|
+
self._invokes.extend(
|
|
59
|
+
dict(entry) if isinstance(entry, Mapping) else entry for entry in entries
|
|
60
|
+
)
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def fan_out(self, options: bool | Mapping[str, object] = True) -> Self:
|
|
64
|
+
"""One job per target in the graph, with the fan-out options."""
|
|
65
|
+
self._fan_out = dict(options) if isinstance(options, Mapping) else options
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def pins(self, pins: Mapping[str, object]) -> Self:
|
|
69
|
+
"""Pinned action references by action name."""
|
|
70
|
+
self._pins = dict(pins)
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def references(self) -> list[Field | str]:
|
|
74
|
+
"""Every target the file invokes, for the build to check."""
|
|
75
|
+
named: list[Field | str] = []
|
|
76
|
+
for entry in self._invokes:
|
|
77
|
+
if isinstance(entry, dict):
|
|
78
|
+
target = entry.get("target")
|
|
79
|
+
if isinstance(target, (Field, str)):
|
|
80
|
+
named.append(target)
|
|
81
|
+
after = entry.get("after")
|
|
82
|
+
if is_list(after):
|
|
83
|
+
named.extend(
|
|
84
|
+
item for item in after if isinstance(item, (Field, str))
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
named.append(entry)
|
|
88
|
+
return named
|
|
89
|
+
|
|
90
|
+
def manifest(self) -> dict[str, object]:
|
|
91
|
+
"""The file as the manifest's ``ci`` list carries it."""
|
|
92
|
+
m: dict[str, object] = {"name": self.name}
|
|
93
|
+
for key, value in (
|
|
94
|
+
("provider", self._provider),
|
|
95
|
+
("path", self._path),
|
|
96
|
+
("pipeline", self._pipeline),
|
|
97
|
+
):
|
|
98
|
+
if value is not None:
|
|
99
|
+
m[key] = value
|
|
100
|
+
if self._invokes:
|
|
101
|
+
m["invokes"] = [_invocation(entry) for entry in self._invokes]
|
|
102
|
+
if self._fan_out is not None:
|
|
103
|
+
m["fanOut"] = self._fan_out
|
|
104
|
+
if self._pins is not None:
|
|
105
|
+
m["pins"] = self._pins
|
|
106
|
+
return m
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _invocation(entry: Field | str | dict[str, object]) -> object:
|
|
110
|
+
if not isinstance(entry, dict):
|
|
111
|
+
return name_of(entry)
|
|
112
|
+
resolved = dict(entry)
|
|
113
|
+
target = resolved.get("target")
|
|
114
|
+
if isinstance(target, (Field, str)):
|
|
115
|
+
resolved["target"] = name_of(target)
|
|
116
|
+
after = resolved.get("after")
|
|
117
|
+
if is_list(after):
|
|
118
|
+
resolved["after"] = [
|
|
119
|
+
name_of(item) if isinstance(item, (Field, str)) else item for item in after
|
|
120
|
+
]
|
|
121
|
+
return resolved
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def ci() -> Ci:
|
|
125
|
+
"""A new CI file, to configure fluently and assign to a build field."""
|
|
126
|
+
return Ci()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def invoke(
|
|
130
|
+
target: Field | str, *, after: Sequence[Field | str] = (), **adjustments: object
|
|
131
|
+
) -> dict[str, object]:
|
|
132
|
+
"""An invoked target with the runner's adjustments, for :meth:`Ci.invokes`.
|
|
133
|
+
|
|
134
|
+
``after`` adds ``needs:`` edges; the other keywords, such as ``matrix``, are
|
|
135
|
+
the invocation's fields as the engine names them.
|
|
136
|
+
"""
|
|
137
|
+
entry: dict[str, object] = {"target": target}
|
|
138
|
+
if after:
|
|
139
|
+
entry["after"] = list(after)
|
|
140
|
+
entry.update(adjustments)
|
|
141
|
+
return entry
|
zuke/_context.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Copyright (c) 2026 the Zuke contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""The contexts a body and a service phase are handed, every call served by the engine.
|
|
4
|
+
|
|
5
|
+
A body never spawns a process or touches the run's state itself. Each method
|
|
6
|
+
here is a request the engine serves inside the caller's own scope, so its
|
|
7
|
+
dry-run echo, redaction and cancellation apply, and each is named as the
|
|
8
|
+
engine's protocol names it. A service phase gets the smaller context, since
|
|
9
|
+
the engine serves it no body-only call. The engine's answers are untrusted
|
|
10
|
+
and narrowed field by field.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from zuke._guard import boolean, integer, record, string, string_or_none, strings
|
|
20
|
+
from zuke._rpc import CANCELLED, Peer, RpcError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ExecResult:
|
|
25
|
+
"""What a command the engine ran for the body came back with."""
|
|
26
|
+
|
|
27
|
+
code: int
|
|
28
|
+
"""The exit code."""
|
|
29
|
+
stdout: str
|
|
30
|
+
"""What it printed to stdout."""
|
|
31
|
+
stderr: str
|
|
32
|
+
"""What it printed to stderr."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def exec_result_of(answer: object, method: str) -> ExecResult:
|
|
36
|
+
"""The :class:`ExecResult` an answer of ``method`` carries, each field guarded."""
|
|
37
|
+
return ExecResult(
|
|
38
|
+
code=integer(answer, "code", method),
|
|
39
|
+
stdout=string(answer, "stdout", method),
|
|
40
|
+
stderr=string(answer, "stderr", method),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CommandError(Exception):
|
|
45
|
+
"""A command :meth:`ServiceContext.sh` ran exited with a code other than zero."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, argv: tuple[str, ...], result: ExecResult) -> None:
|
|
48
|
+
"""Name the command and its code."""
|
|
49
|
+
super().__init__(f"`{' '.join(argv)}` exited with code {result.code}")
|
|
50
|
+
self.argv = argv
|
|
51
|
+
"""The command, as discrete arguments."""
|
|
52
|
+
self.result = result
|
|
53
|
+
"""Its exit code and output."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Outcome:
|
|
58
|
+
"""What another target in this run did, as :meth:`Context.outcome_of` tells."""
|
|
59
|
+
|
|
60
|
+
status: str
|
|
61
|
+
"""The record's status: ``succeeded``, ``failed``, ``skipped``, ``waiting``, …"""
|
|
62
|
+
error: str | None
|
|
63
|
+
"""The failure's message, when it failed."""
|
|
64
|
+
started_at: str | None
|
|
65
|
+
"""When it started, ISO-8601, if it did."""
|
|
66
|
+
ended_at: str | None
|
|
67
|
+
"""When it settled, ISO-8601, if it has."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Plan:
|
|
72
|
+
"""The run's plan, as :meth:`Context.plan` reports it."""
|
|
73
|
+
|
|
74
|
+
targets: list[str]
|
|
75
|
+
"""The planned targets, in order."""
|
|
76
|
+
dependencies: dict[str, list[str]]
|
|
77
|
+
"""Each planned target's dependencies."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class StateHandle:
|
|
81
|
+
"""Durable state of one target, kept by the engine across runs and resumes."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, ctx: Context, of: str | None) -> None:
|
|
84
|
+
"""State of ``of``, or of the body's own target when ``None``."""
|
|
85
|
+
self._ctx = ctx
|
|
86
|
+
self._of = {} if of is None else {"of": of}
|
|
87
|
+
|
|
88
|
+
async def get(self) -> dict[str, object]:
|
|
89
|
+
"""The state as last written."""
|
|
90
|
+
return record(
|
|
91
|
+
await self._ctx.call_("state.get", **self._of), "state.get: the answer"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def set(self, patch: Mapping[str, object]) -> None:
|
|
95
|
+
"""Merge ``patch`` into the state."""
|
|
96
|
+
await self._ctx.call_("state.set", patch=dict(patch), **self._of)
|
|
97
|
+
|
|
98
|
+
async def try_set(self, patch: Mapping[str, object]) -> bool:
|
|
99
|
+
"""Merge ``patch`` if the engine will record it now; whether it did."""
|
|
100
|
+
answer = await self._ctx.call_("state.trySet", patch=dict(patch), **self._of)
|
|
101
|
+
return boolean(answer, "recorded", "state.trySet")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ServiceContext:
|
|
105
|
+
"""What a service phase is handed: the engine's services a phase may use."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, engine: Peer, target: str) -> None:
|
|
108
|
+
"""A context for a phase of ``target``."""
|
|
109
|
+
self._engine = engine
|
|
110
|
+
self.target = target
|
|
111
|
+
"""The target whose phase or body this is."""
|
|
112
|
+
|
|
113
|
+
async def call_(self, method: str, **params: object) -> object:
|
|
114
|
+
"""Ask the engine as this target; an engine-side cancel unwinds the caller."""
|
|
115
|
+
try:
|
|
116
|
+
return await self._engine.request(method, {"target": self.target, **params})
|
|
117
|
+
except RpcError as error:
|
|
118
|
+
if error.code == CANCELLED:
|
|
119
|
+
raise asyncio.CancelledError(str(error)) from None
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
async def sh(
|
|
123
|
+
self,
|
|
124
|
+
*argv: str,
|
|
125
|
+
cwd: str | None = None,
|
|
126
|
+
env: Mapping[str, str] | None = None,
|
|
127
|
+
stdin: str | None = None,
|
|
128
|
+
check: bool = True,
|
|
129
|
+
) -> ExecResult:
|
|
130
|
+
"""Run ``argv`` through the engine, in the worker's directory and environment.
|
|
131
|
+
|
|
132
|
+
``cwd`` and ``env`` adjust those for this command; ``stdin`` is fed to it.
|
|
133
|
+
Raises :class:`CommandError` on a non-zero code unless ``check`` is off.
|
|
134
|
+
"""
|
|
135
|
+
params: dict[str, object] = {"argv": list(argv)}
|
|
136
|
+
if cwd is not None:
|
|
137
|
+
params["cwd"] = cwd
|
|
138
|
+
if env is not None:
|
|
139
|
+
params["env"] = dict(env)
|
|
140
|
+
if stdin is not None:
|
|
141
|
+
params["stdin"] = stdin
|
|
142
|
+
result = exec_result_of(await self.call_("exec", **params), "exec")
|
|
143
|
+
if check and result.code != 0:
|
|
144
|
+
raise CommandError(argv, result)
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
async def secret(self, value: str) -> None:
|
|
148
|
+
"""Redact ``value`` from everything the run prints or records from now on."""
|
|
149
|
+
await self.call_("secret.register", value=value)
|
|
150
|
+
|
|
151
|
+
async def log(self, line: str) -> None:
|
|
152
|
+
"""Log a line attributed to this target."""
|
|
153
|
+
await self.call_("log", line=line)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Context(ServiceContext):
|
|
157
|
+
"""What a body is handed: the run it is part of, and all the engine serves it."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, engine: Peer, target: str, run_id: str, dry_run: bool) -> None:
|
|
160
|
+
"""A context for ``target``'s body in the run ``run_id``."""
|
|
161
|
+
super().__init__(engine, target)
|
|
162
|
+
self.run_id = run_id
|
|
163
|
+
"""The run's id, as the run record and ``zuke resume`` name it."""
|
|
164
|
+
self.dry_run = dry_run
|
|
165
|
+
"""Whether the run is a ``--dry-run``."""
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def state(self) -> StateHandle:
|
|
169
|
+
"""This target's durable state."""
|
|
170
|
+
return StateHandle(self, None)
|
|
171
|
+
|
|
172
|
+
def state_of(self, target: str) -> StateHandle:
|
|
173
|
+
"""Another target's durable state."""
|
|
174
|
+
return StateHandle(self, target)
|
|
175
|
+
|
|
176
|
+
async def outcome_of(self, target: str) -> Outcome | None:
|
|
177
|
+
"""What ``target`` did in this run, or ``None`` if it has not run."""
|
|
178
|
+
answer = await self.call_("outcome.of", of=target)
|
|
179
|
+
return None if answer is None else _outcome(answer, "outcome.of")
|
|
180
|
+
|
|
181
|
+
async def outcomes(self) -> dict[str, Outcome]:
|
|
182
|
+
"""What every settled target did in this run, by name."""
|
|
183
|
+
answer = record(await self.call_("outcomes"), "outcomes: the answer")
|
|
184
|
+
return {
|
|
185
|
+
name: _outcome(view, f"outcomes: {name}") for name, view in answer.items()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async def summary(self, pairs: Mapping[str, str | float]) -> None:
|
|
189
|
+
"""Report notes into this target's row of the build summary."""
|
|
190
|
+
await self.call_("summary.report", pairs=dict(pairs))
|
|
191
|
+
|
|
192
|
+
async def plan(self) -> Plan:
|
|
193
|
+
"""The run's plan: the planned targets and each one's dependencies."""
|
|
194
|
+
answer = record(await self.call_("plan"), "plan: the answer")
|
|
195
|
+
dependencies = record(answer.get("dependencies"), 'plan: "dependencies"')
|
|
196
|
+
return Plan(
|
|
197
|
+
targets=strings(answer, "targets", "plan"),
|
|
198
|
+
dependencies={
|
|
199
|
+
name: strings(dependencies, name, "plan") for name in dependencies
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
async def signals(self) -> dict[str, object]:
|
|
204
|
+
"""The external signals this run has received, by name."""
|
|
205
|
+
return record(await self.call_("signals"), "signals: the answer")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _outcome(value: object, what: str) -> Outcome:
|
|
209
|
+
return Outcome(
|
|
210
|
+
status=string(value, "status", what),
|
|
211
|
+
error=string_or_none(value, "error", what),
|
|
212
|
+
started_at=string_or_none(value, "startedAt", what),
|
|
213
|
+
ended_at=string_or_none(value, "endedAt", what),
|
|
214
|
+
)
|