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.
Files changed (89) hide show
  1. zuke/__init__.py +76 -0
  2. zuke/_awaited.py +15 -0
  3. zuke/_build.py +169 -0
  4. zuke/_ci.py +141 -0
  5. zuke/_context.py +214 -0
  6. zuke/_dsl.py +629 -0
  7. zuke/_duration.py +23 -0
  8. zuke/_facade.py +79 -0
  9. zuke/_field.py +28 -0
  10. zuke/_generate.py +411 -0
  11. zuke/_guard.py +69 -0
  12. zuke/_rpc.py +198 -0
  13. zuke/_run.py +34 -0
  14. zuke/_settings.py +162 -0
  15. zuke/_worker.py +214 -0
  16. zuke/py.typed +0 -0
  17. zuke/tools/__init__.py +9 -0
  18. zuke/tools/_tooling.py +116 -0
  19. zuke/tools/ai.py +428 -0
  20. zuke/tools/apple.py +1245 -0
  21. zuke/tools/argo_rollouts.py +596 -0
  22. zuke/tools/aws.py +2439 -0
  23. zuke/tools/az.py +2163 -0
  24. zuke/tools/biome.py +150 -0
  25. zuke/tools/bun.py +207 -0
  26. zuke/tools/chocolatey.py +540 -0
  27. zuke/tools/claude.py +186 -0
  28. zuke/tools/cmd.py +43 -0
  29. zuke/tools/codecov.py +129 -0
  30. zuke/tools/codex.py +135 -0
  31. zuke/tools/console.py +147 -0
  32. zuke/tools/cspell.py +119 -0
  33. zuke/tools/cypress.py +183 -0
  34. zuke/tools/dart.py +729 -0
  35. zuke/tools/deno.py +2765 -0
  36. zuke/tools/docker.py +1774 -0
  37. zuke/tools/docker_compose.py +1292 -0
  38. zuke/tools/docs.py +60 -0
  39. zuke/tools/dpdm.py +107 -0
  40. zuke/tools/dprint.py +87 -0
  41. zuke/tools/eslint.py +111 -0
  42. zuke/tools/flutter.py +655 -0
  43. zuke/tools/gcloud.py +2115 -0
  44. zuke/tools/gemini.py +155 -0
  45. zuke/tools/gh.py +97 -0
  46. zuke/tools/git.py +3293 -0
  47. zuke/tools/google.py +96 -0
  48. zuke/tools/helm.py +455 -0
  49. zuke/tools/homebrew.py +750 -0
  50. zuke/tools/husky.py +86 -0
  51. zuke/tools/jest.py +115 -0
  52. zuke/tools/jsr.py +135 -0
  53. zuke/tools/knip.py +79 -0
  54. zuke/tools/kubectl.py +1923 -0
  55. zuke/tools/kustomize.py +93 -0
  56. zuke/tools/lint_staged.py +82 -0
  57. zuke/tools/nest.py +259 -0
  58. zuke/tools/node.py +277 -0
  59. zuke/tools/npm.py +1320 -0
  60. zuke/tools/npx.py +71 -0
  61. zuke/tools/nx.py +135 -0
  62. zuke/tools/openapi_ts.py +72 -0
  63. zuke/tools/orval.py +80 -0
  64. zuke/tools/oxlint.py +103 -0
  65. zuke/tools/playwright.py +173 -0
  66. zuke/tools/pnpm.py +219 -0
  67. zuke/tools/prometheus.py +635 -0
  68. zuke/tools/qr.py +112 -0
  69. zuke/tools/redocly.py +143 -0
  70. zuke/tools/release_please.py +104 -0
  71. zuke/tools/security.py +341 -0
  72. zuke/tools/shellcheck.py +77 -0
  73. zuke/tools/signpath.py +376 -0
  74. zuke/tools/storybook.py +106 -0
  75. zuke/tools/terraform.py +269 -0
  76. zuke/tools/tofu.py +263 -0
  77. zuke/tools/tsc.py +164 -0
  78. zuke/tools/tsc_alias.py +91 -0
  79. zuke/tools/tsdown.py +124 -0
  80. zuke/tools/tsup.py +87 -0
  81. zuke/tools/tsx.py +111 -0
  82. zuke/tools/turbo.py +115 -0
  83. zuke/tools/vite.py +157 -0
  84. zuke/tools/vitest.py +131 -0
  85. zuke/tools/yarn.py +171 -0
  86. zuke-1.0.0.dist-info/METADATA +130 -0
  87. zuke-1.0.0.dist-info/RECORD +89 -0
  88. zuke-1.0.0.dist-info/WHEEL +4 -0
  89. 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
+ )