dirigent-block-execute 0.17.1__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.
@@ -0,0 +1,219 @@
1
+ """Running a child process on the worker: its workspace, its environment, and its lifetime.
2
+
3
+ Three blocks run code on the worker -- ``shell.run`` a command, ``docker.compose`` the
4
+ compose CLI, ``docker.build`` buildx -- and all three want the same containment: a process in
5
+ a session of its own so what it starts dies with it, an environment built from an allowlist
6
+ rather than inherited wholesale, and a workspace of its own inside the run's work directory.
7
+ That shared machinery lives here so a block is only the argv it assembles and the output it
8
+ parses.
9
+ """
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import os
14
+ import signal
15
+ from collections.abc import Sequence
16
+ from pathlib import Path
17
+
18
+ from dirigent_block_execute.capture import Drained, LiveLines, drain
19
+ from dirigent_block_execute.environment import allowed
20
+ from dirigent_block_execute.messages import TIMED_OUT
21
+ from dirigent_plugin import BlockFailure, ErrorClass, StepContext
22
+
23
+ #: Inherited whether or not a step's allowlist names them.
24
+ BASELINE_ENV = ("PATH", "LANG", "LC_ALL", "TZ")
25
+
26
+
27
+ def segment(ctx: StepContext, name: str) -> str:
28
+ """The path this attempt's files sit at, under either of the run's two roots.
29
+
30
+ ``{name}/{step}[/{item}]/attempt-{n}``. Both roots are the run's, so the step key and,
31
+ inside a fan-out, the item id are what keep two steps of one run and two items of one step
32
+ out of each other's files and working directories.
33
+ """
34
+ item = f"/{ctx.run_item_id}" if ctx.run_item_id is not None else ""
35
+ return f"{name}/{ctx.step}{item}/attempt-{ctx.attempt}"
36
+
37
+
38
+ def prefix(ctx: StepContext, name: str) -> str:
39
+ """The storage URI this attempt's artifact names are built on, under the run's scratch prefix."""
40
+ return f"{ctx.scratch.rstrip('/')}/{segment(ctx, name)}"
41
+
42
+
43
+ def workspace(ctx: StepContext, name: str) -> Path:
44
+ """This attempt's working directory inside the run's work directory, made if absent."""
45
+ directory = ctx.work / segment(ctx, name)
46
+ directory.mkdir(parents=True, exist_ok=True)
47
+ return directory
48
+
49
+
50
+ def environment(env_allowlist: list[str], env: dict[str, str], home: Path) -> dict[str, str]:
51
+ """Build the process environment from an allowlist, never from wholesale inheritance."""
52
+ built = allowed({*BASELINE_ENV, *env_allowlist})
53
+ built["HOME"] = str(home)
54
+ built.update(env)
55
+ return built
56
+
57
+
58
+ async def run(
59
+ *,
60
+ directory: Path,
61
+ ctx: StepContext,
62
+ stdout_uri: str,
63
+ stderr_uri: str,
64
+ timeout_seconds: float,
65
+ environ: dict[str, str],
66
+ argv: list[str] | None = None,
67
+ command: str | None = None,
68
+ what: str = "the command",
69
+ redact: Sequence[str] = (),
70
+ ) -> tuple[int, Drained, Drained]:
71
+ """Start the process, drain it under the timeout, and leave nothing of it behind.
72
+
73
+ Both pipes are read as the process writes them and written straight through to storage,
74
+ so what is held is two bounded ends of each stream rather than all of it: a process that
75
+ prints more than the worker has memory for is a file, not a dead worker. Each stream's
76
+ first lines go into the run log where they are printed, so a long command is visible
77
+ working; ``redact`` names the strings it was handed that must not reach one of them.
78
+
79
+ The process leads a session of its own, so what it starts is killable with it: a command
80
+ is usually ``sh -c``, and killing the shell alone leaves the children doing the work.
81
+
82
+ Nothing survives this function. The block's own timeout is one way out of it; the
83
+ engine's step timeout, which cancels the coroutine from outside, is another, and a
84
+ process that outlived that would run on under a worker that had stopped waiting for it.
85
+ """
86
+ started = (
87
+ asyncio.create_subprocess_shell(
88
+ command,
89
+ cwd=directory,
90
+ env=environ,
91
+ stdout=asyncio.subprocess.PIPE,
92
+ stderr=asyncio.subprocess.PIPE,
93
+ start_new_session=True,
94
+ )
95
+ if command is not None
96
+ else asyncio.create_subprocess_exec(
97
+ *(argv or []),
98
+ cwd=directory,
99
+ env=environ,
100
+ stdout=asyncio.subprocess.PIPE,
101
+ stderr=asyncio.subprocess.PIPE,
102
+ start_new_session=True,
103
+ )
104
+ )
105
+ process = await started
106
+ pgid = group_of(process)
107
+ try:
108
+ async with asyncio.timeout(timeout_seconds):
109
+ async with ctx.storage.open_write(stdout_uri) as out_sink, ctx.storage.open_write(stderr_uri) as err_sink:
110
+ out, err = await asyncio.gather(
111
+ drain(
112
+ process.stdout,
113
+ out_sink,
114
+ head_limit=ctx.inline_capture,
115
+ live=LiveLines(ctx, "stdout", redact=redact),
116
+ ),
117
+ drain(
118
+ process.stderr,
119
+ err_sink,
120
+ head_limit=ctx.inline_capture,
121
+ live=LiveLines(ctx, "stderr", redact=redact),
122
+ ),
123
+ )
124
+ await process.wait()
125
+ except TimeoutError as error:
126
+ raise BlockFailure(TIMED_OUT, error_class=ErrorClass.TRANSIENT, what=what, seconds=timeout_seconds) from error
127
+ finally:
128
+ await end(process, pgid)
129
+ return process.returncode or 0, out, err
130
+
131
+
132
+ async def output(
133
+ *,
134
+ argv: list[str],
135
+ directory: Path,
136
+ environ: dict[str, str],
137
+ timeout_seconds: float,
138
+ what: str = "the command",
139
+ stdin: bytes | None = None,
140
+ ) -> tuple[int, bytes, bytes]:
141
+ """Run a short read-only command to completion and hand back both streams whole.
142
+
143
+ For the small, bounded reads a block makes of a tool's own state -- ``compose ps``,
144
+ ``compose config`` -- where the answer is parsed rather than streamed to an artifact.
145
+
146
+ ``stdin`` is written to the process and the pipe closed, which is how a credential
147
+ reaches a tool without ever being an argument.
148
+ """
149
+ process = await asyncio.create_subprocess_exec(
150
+ *argv,
151
+ cwd=directory,
152
+ env=environ,
153
+ stdin=asyncio.subprocess.PIPE if stdin is not None else None,
154
+ stdout=asyncio.subprocess.PIPE,
155
+ stderr=asyncio.subprocess.PIPE,
156
+ start_new_session=True,
157
+ )
158
+ pgid = group_of(process)
159
+ try:
160
+ async with asyncio.timeout(timeout_seconds):
161
+ out, err = await process.communicate(stdin)
162
+ except TimeoutError as error:
163
+ raise BlockFailure(TIMED_OUT, error_class=ErrorClass.TRANSIENT, what=what, seconds=timeout_seconds) from error
164
+ finally:
165
+ await end(process, pgid)
166
+ return process.returncode or 0, out, err
167
+
168
+
169
+ def group_of(process: asyncio.subprocess.Process) -> int:
170
+ """The process group to signal at the end, read while the leader is certainly still alive.
171
+
172
+ ``os.getpgid`` fails once the leader has been reaped, so the group id is taken at the
173
+ start and held: it outlives the leader for as long as any member of the group lives.
174
+ """
175
+ with contextlib.suppress(OSError):
176
+ return os.getpgid(process.pid)
177
+ return process.pid # pragma: no cover - only reachable if the process is already gone
178
+
179
+
180
+ async def end(process: asyncio.subprocess.Process, pgid: int) -> None:
181
+ """Kill the process group and reap it, whatever the reason for leaving.
182
+
183
+ The group is signalled whether or not the leader is still running: a shell that
184
+ backgrounded its work exits 0 while the work it started keeps the group alive, and that
185
+ work must not outlive the step. A group the worker may not signal is not one this can do
186
+ anything about, and neither that nor a group already gone is worth failing a step over.
187
+ """
188
+ with contextlib.suppress(ProcessLookupError, PermissionError):
189
+ kill(pgid, process.pid)
190
+ with contextlib.suppress(ProcessLookupError):
191
+ await process.wait()
192
+
193
+
194
+ def kill(pgid: int, pid: int) -> None:
195
+ """Kill a process group and everything in it, and never anything else.
196
+
197
+ The group is only the command's own because it was started in a session of its own. A
198
+ process that is not its group's leader shares the worker's group, and signalling that
199
+ would kill the worker: kill just the process instead rather than everything running
200
+ beside it.
201
+ """
202
+ if pgid > 1 and pgid != os.getpgrp():
203
+ os.killpg(pgid, signal.SIGKILL)
204
+ else: # pragma: no cover - only reachable if the process stops leading its own session
205
+ os.kill(pid, signal.SIGKILL)
206
+
207
+
208
+ __all__ = [
209
+ "BASELINE_ENV",
210
+ "end",
211
+ "environment",
212
+ "group_of",
213
+ "kill",
214
+ "output",
215
+ "prefix",
216
+ "run",
217
+ "segment",
218
+ "workspace",
219
+ ]
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirigent-block-execute
3
+ Version: 0.17.1
4
+ Summary: The execute block family for dirigent: shell, docker, compose, buildx, and a checkout.
5
+ License-Expression: LicenseRef-Proprietary
6
+ License-File: LICENSE
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.13
9
+ Requires-Dist: dirigent-block-http==0.17.1
10
+ Requires-Dist: dirigent-common==0.17.1
11
+ Requires-Dist: dirigent-plugin==0.17.1
12
+ Requires-Dist: httpx2>=2.12.0
13
+ Requires-Python: >=3.13
14
+ Description-Content-Type: text/markdown
15
+
16
+ # dirigent-block-execute
17
+
18
+ The execute block family: everything that runs something on the worker. `shell.run` a command,
19
+ `docker.run` a container, `docker.compose.up` and `docker.compose.down` a stack, `docker.build`
20
+ an image, and `git.checkout` a working tree.
21
+
22
+ These are the blocks that declare themselves unsafe, because what they run is the step's own
23
+ code rather than a call to a service. The family registers the `docker` and `git` connection
24
+ kinds, and carries the shared internals the six have in common: process containment, output
25
+ capture, the environment allowlist, and the on-disk credential material a tool has to be
26
+ handed. `dg reap` uses the same internals to clear what a run left behind.
@@ -0,0 +1,18 @@
1
+ dirigent_block_execute/__init__.py,sha256=oyHeIBQtuKRoIEAAfXhuYjFfNKxtNno3J8VAhsDWqio,1493
2
+ dirigent_block_execute/build.py,sha256=qDMgoqbkt1UvNv8waCTIvKYW8Amc2weAwu4RggVRgc4,13486
3
+ dirigent_block_execute/capture.py,sha256=jr22uPIr0c77nUoMUS0LW8hF2lWcloJsC30VflSqXyc,10553
4
+ dirigent_block_execute/compose.py,sha256=Q0w2EKdKa0t_fumdA7VP7JW8iewKHSRMGUhFvvK3cUc,27986
5
+ dirigent_block_execute/docker.py,sha256=HIH9UH7IZ5OJ9bX3Vvi3AuzAXH9XZ3I4uE3oUbTe9Ns,55425
6
+ dirigent_block_execute/environment.py,sha256=MYdecVOBgsjC3Nta2DxhZCyEjRdNKJwW3YIlmUDeirs,2178
7
+ dirigent_block_execute/git.py,sha256=cEKhCHYoEj9tdh44Yzm-O1Te-B2TxYR0zzeznp-TCDc,24385
8
+ dirigent_block_execute/messages.py,sha256=18OCaA_Vu-HMJ6zLif_C9kdWI3w_0vIy4shygETtTSY,6695
9
+ dirigent_block_execute/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ dirigent_block_execute/reap.py,sha256=9ChMp6xoEfX4XoGU63Sne088HnuYLiI4Kt0rXwcZpEo,6657
11
+ dirigent_block_execute/secrets.py,sha256=v5ZbNCip744tt9PI3hJwwf8cPejaChGDGM6fAZtqdq4,1439
12
+ dirigent_block_execute/shell.py,sha256=EuAZnTg58ZDNqfoVRUIAKzwb8Hv7z-ncGOOIV0mrkoU,6841
13
+ dirigent_block_execute/subprocess.py,sha256=3Sm2k1php_1QpflxDn5AhtT8QkE_DR948PrU9ueH4Jc,8620
14
+ dirigent_block_execute-0.17.1.dist-info/licenses/LICENSE,sha256=LKBm7Cx-WBc1zca4DjGxq99VEpAiWGnZDxIKmntn1hQ,910
15
+ dirigent_block_execute-0.17.1.dist-info/WHEEL,sha256=R1d3uUTbmXM1FHXH_itQashbrqrOSVj-hvBCpmkIIGE,81
16
+ dirigent_block_execute-0.17.1.dist-info/entry_points.txt,sha256=nxO22WndCTu18egcPThUNmLv7E8GlBu9owyAuWICGNU,69
17
+ dirigent_block_execute-0.17.1.dist-info/METADATA,sha256=QLxSTOpygL_OmzYLbWMLqH3s54ibhU1uZvgd_yGVprY,1248
18
+ dirigent_block_execute-0.17.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.17
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [dirigent.plugins.v1]
2
+ block-execute = dirigent_block_execute:plugin
3
+
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
2
+
3
+ This source code and accompanying documentation are the property of
4
+ Morten Olav Hansen. No license, express or implied, is granted to use, copy,
5
+ modify, merge, publish, distribute, sublicense, or sell copies of this
6
+ software or its derivatives.
7
+
8
+ The source is published for reference only. Any use beyond reading
9
+ requires written permission from the copyright holder.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
12
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
13
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
14
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
15
+ OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
16
+
17
+ Third-party components redistributed with this software, and the licences they
18
+ carry, are listed in THIRD_PARTY_NOTICES.md.