cuprum 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.
- cuprum/__init__.py +99 -0
- cuprum/_observability.py +77 -0
- cuprum/_pipeline_internals.py +278 -0
- cuprum/_pipeline_spawn.py +20 -0
- cuprum/_pipeline_streams.py +229 -0
- cuprum/_pipeline_wait.py +136 -0
- cuprum/_process_lifecycle.py +265 -0
- cuprum/_streams.py +230 -0
- cuprum/_testing.py +53 -0
- cuprum/adapters/__init__.py +40 -0
- cuprum/adapters/logging_adapter.py +216 -0
- cuprum/adapters/metrics_adapter.py +276 -0
- cuprum/adapters/tracing_adapter.py +387 -0
- cuprum/catalogue.py +158 -0
- cuprum/context.py +509 -0
- cuprum/events.py +72 -0
- cuprum/logging_hooks.py +145 -0
- cuprum/program.py +17 -0
- cuprum/sh.py +558 -0
- cuprum/unittests/test_adapters.py +601 -0
- cuprum/unittests/test_catalogue.py +129 -0
- cuprum/unittests/test_context.py +383 -0
- cuprum/unittests/test_logging_hook.py +193 -0
- cuprum/unittests/test_observe.py +159 -0
- cuprum/unittests/test_pipeline.py +529 -0
- cuprum/unittests/test_public_api.py +33 -0
- cuprum/unittests/test_safe_cmd_run.py +486 -0
- cuprum/unittests/test_sh.py +118 -0
- cuprum-0.1.0.dist-info/METADATA +106 -0
- cuprum-0.1.0.dist-info/RECORD +31 -0
- cuprum-0.1.0.dist-info/WHEEL +4 -0
cuprum/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""cuprum package.
|
|
2
|
+
|
|
3
|
+
Provides a typed programme catalogue system for managing curated, allowlisted
|
|
4
|
+
executables. Re-exports core types and the default catalogue for convenience.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from cuprum import DEFAULT_CATALOGUE, ECHO
|
|
8
|
+
>>> entry = DEFAULT_CATALOGUE.lookup(ECHO)
|
|
9
|
+
>>> entry.project_name
|
|
10
|
+
'core-ops'
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from cuprum.catalogue import (
|
|
17
|
+
CORE_OPS_PROJECT,
|
|
18
|
+
DEFAULT_CATALOGUE,
|
|
19
|
+
DEFAULT_PROJECTS,
|
|
20
|
+
DOC_TOOL,
|
|
21
|
+
DOCUMENTATION_PROJECT,
|
|
22
|
+
ECHO,
|
|
23
|
+
LS,
|
|
24
|
+
ProgramCatalogue,
|
|
25
|
+
ProgramEntry,
|
|
26
|
+
ProjectSettings,
|
|
27
|
+
UnknownProgramError,
|
|
28
|
+
)
|
|
29
|
+
from cuprum.context import (
|
|
30
|
+
AfterHook,
|
|
31
|
+
AllowRegistration,
|
|
32
|
+
BeforeHook,
|
|
33
|
+
CuprumContext,
|
|
34
|
+
ExecHook,
|
|
35
|
+
ForbiddenProgramError,
|
|
36
|
+
HookRegistration,
|
|
37
|
+
after,
|
|
38
|
+
allow,
|
|
39
|
+
before,
|
|
40
|
+
current_context,
|
|
41
|
+
get_context,
|
|
42
|
+
observe,
|
|
43
|
+
scoped,
|
|
44
|
+
)
|
|
45
|
+
from cuprum.events import ExecEvent
|
|
46
|
+
from cuprum.logging_hooks import LoggingHookRegistration, logging_hook
|
|
47
|
+
from cuprum.program import Program
|
|
48
|
+
from cuprum.sh import (
|
|
49
|
+
CommandResult,
|
|
50
|
+
ExecutionContext,
|
|
51
|
+
Pipeline,
|
|
52
|
+
PipelineResult,
|
|
53
|
+
SafeCmd,
|
|
54
|
+
SafeCmdBuilder,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
from . import sh
|
|
58
|
+
|
|
59
|
+
PACKAGE_NAME = "cuprum"
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"CORE_OPS_PROJECT",
|
|
63
|
+
"DEFAULT_CATALOGUE",
|
|
64
|
+
"DEFAULT_PROJECTS",
|
|
65
|
+
"DOCUMENTATION_PROJECT",
|
|
66
|
+
"DOC_TOOL",
|
|
67
|
+
"ECHO",
|
|
68
|
+
"LS",
|
|
69
|
+
"PACKAGE_NAME",
|
|
70
|
+
"AfterHook",
|
|
71
|
+
"AllowRegistration",
|
|
72
|
+
"BeforeHook",
|
|
73
|
+
"CommandResult",
|
|
74
|
+
"CuprumContext",
|
|
75
|
+
"ExecEvent",
|
|
76
|
+
"ExecHook",
|
|
77
|
+
"ExecutionContext",
|
|
78
|
+
"ForbiddenProgramError",
|
|
79
|
+
"HookRegistration",
|
|
80
|
+
"LoggingHookRegistration",
|
|
81
|
+
"Pipeline",
|
|
82
|
+
"PipelineResult",
|
|
83
|
+
"Program",
|
|
84
|
+
"ProgramCatalogue",
|
|
85
|
+
"ProgramEntry",
|
|
86
|
+
"ProjectSettings",
|
|
87
|
+
"SafeCmd",
|
|
88
|
+
"SafeCmdBuilder",
|
|
89
|
+
"UnknownProgramError",
|
|
90
|
+
"after",
|
|
91
|
+
"allow",
|
|
92
|
+
"before",
|
|
93
|
+
"current_context",
|
|
94
|
+
"get_context",
|
|
95
|
+
"logging_hook",
|
|
96
|
+
"observe",
|
|
97
|
+
"scoped",
|
|
98
|
+
"sh",
|
|
99
|
+
]
|
cuprum/_observability.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Internal helpers for structured execution event emission."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import types
|
|
8
|
+
import typing as typ
|
|
9
|
+
|
|
10
|
+
if typ.TYPE_CHECKING:
|
|
11
|
+
import collections.abc as cabc
|
|
12
|
+
|
|
13
|
+
from cuprum.events import ExecEvent, ExecHook
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _freeze_str_mapping(
|
|
17
|
+
mapping: cabc.Mapping[str, str] | None,
|
|
18
|
+
) -> cabc.Mapping[str, str] | None:
|
|
19
|
+
if mapping is None:
|
|
20
|
+
return None
|
|
21
|
+
return types.MappingProxyType(dict(mapping))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _merge_tags(*tags: cabc.Mapping[str, object] | None) -> cabc.Mapping[str, object]:
|
|
25
|
+
merged: dict[str, object] = {}
|
|
26
|
+
for mapping in tags:
|
|
27
|
+
if not mapping:
|
|
28
|
+
continue
|
|
29
|
+
merged.update(mapping)
|
|
30
|
+
return types.MappingProxyType(merged)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _emit_exec_event(
|
|
34
|
+
hooks: tuple[ExecHook, ...],
|
|
35
|
+
event: ExecEvent,
|
|
36
|
+
*,
|
|
37
|
+
pending_tasks: list[asyncio.Task[None]],
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Invoke observe hooks and schedule async hooks as background tasks."""
|
|
40
|
+
for hook in hooks:
|
|
41
|
+
result = hook(event)
|
|
42
|
+
if inspect.isawaitable(result):
|
|
43
|
+
pending_tasks.append(asyncio.create_task(_await_awaitable(result)))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _await_awaitable(awaitable: cabc.Awaitable[None]) -> None:
|
|
47
|
+
await awaitable
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _wait_for_exec_hook_tasks(pending_tasks: list[asyncio.Task[None]]) -> None:
|
|
51
|
+
"""Await background observe-hook tasks and surface the first failure.
|
|
52
|
+
|
|
53
|
+
Observe hooks may return awaitables; those awaitables are scheduled as tasks
|
|
54
|
+
by ``_emit_exec_event`` and added to ``pending_tasks``. This helper awaits
|
|
55
|
+
all pending tasks and re-raises the first ``BaseException`` encountered.
|
|
56
|
+
|
|
57
|
+
Notes
|
|
58
|
+
-----
|
|
59
|
+
When multiple hooks fail, only the first exception is raised; subsequent
|
|
60
|
+
exceptions are not surfaced and may be masked by the first failure.
|
|
61
|
+
|
|
62
|
+
"""
|
|
63
|
+
if not pending_tasks:
|
|
64
|
+
return
|
|
65
|
+
results = await asyncio.gather(*pending_tasks, return_exceptions=True)
|
|
66
|
+
pending_tasks.clear()
|
|
67
|
+
for result in results:
|
|
68
|
+
if isinstance(result, BaseException):
|
|
69
|
+
raise result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"_emit_exec_event",
|
|
74
|
+
"_freeze_str_mapping",
|
|
75
|
+
"_merge_tags",
|
|
76
|
+
"_wait_for_exec_hook_tasks",
|
|
77
|
+
]
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Internal pipeline execution coordination and fail-fast semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses as dc
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import typing as typ
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cuprum._observability import (
|
|
12
|
+
_emit_exec_event,
|
|
13
|
+
_freeze_str_mapping,
|
|
14
|
+
_merge_tags,
|
|
15
|
+
_wait_for_exec_hook_tasks,
|
|
16
|
+
)
|
|
17
|
+
from cuprum._pipeline_spawn import _spawn_pipeline_processes
|
|
18
|
+
from cuprum._pipeline_streams import (
|
|
19
|
+
_cancel_stream_tasks,
|
|
20
|
+
_create_pipe_tasks,
|
|
21
|
+
_gather_optional_text_tasks,
|
|
22
|
+
_PipelineRunConfig,
|
|
23
|
+
_prepare_pipeline_config,
|
|
24
|
+
)
|
|
25
|
+
from cuprum._pipeline_wait import _PipelineWaitResult, _wait_for_pipeline
|
|
26
|
+
from cuprum.context import current_context
|
|
27
|
+
from cuprum.events import ExecEvent
|
|
28
|
+
|
|
29
|
+
if typ.TYPE_CHECKING:
|
|
30
|
+
import asyncio
|
|
31
|
+
import types
|
|
32
|
+
|
|
33
|
+
from cuprum.context import AfterHook, BeforeHook
|
|
34
|
+
from cuprum.events import ExecHook
|
|
35
|
+
from cuprum.sh import CommandResult, ExecutionContext, PipelineResult, SafeCmd
|
|
36
|
+
|
|
37
|
+
_MIN_PIPELINE_STAGES = 2
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _sh_module() -> types.ModuleType:
|
|
41
|
+
module = sys.modules.get("cuprum.sh")
|
|
42
|
+
if module is None:
|
|
43
|
+
msg = "cuprum.sh must be imported before running pipelines"
|
|
44
|
+
raise RuntimeError(msg)
|
|
45
|
+
return module
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dc.dataclass(frozen=True, slots=True)
|
|
49
|
+
class _ExecutionHooks:
|
|
50
|
+
before_hooks: tuple[BeforeHook, ...]
|
|
51
|
+
after_hooks: tuple[AfterHook, ...]
|
|
52
|
+
observe_hooks: tuple[ExecHook, ...]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _run_before_hooks(cmd: SafeCmd) -> _ExecutionHooks:
|
|
56
|
+
"""Collect hooks for a command after enforcing the current allowlist."""
|
|
57
|
+
ctx = current_context()
|
|
58
|
+
ctx.check_allowed(cmd.program)
|
|
59
|
+
return _ExecutionHooks(
|
|
60
|
+
before_hooks=ctx.before_hooks,
|
|
61
|
+
after_hooks=ctx.after_hooks,
|
|
62
|
+
observe_hooks=ctx.observe_hooks,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dc.dataclass(frozen=True, slots=True)
|
|
67
|
+
class _StageObservation:
|
|
68
|
+
cmd: SafeCmd
|
|
69
|
+
hooks: _ExecutionHooks
|
|
70
|
+
tags: typ.Mapping[str, object]
|
|
71
|
+
cwd: Path | None
|
|
72
|
+
env_overlay: typ.Mapping[str, str] | None
|
|
73
|
+
pending_tasks: list[asyncio.Task[None]]
|
|
74
|
+
|
|
75
|
+
def emit(
|
|
76
|
+
self,
|
|
77
|
+
phase: typ.Literal["plan", "start", "stdout", "stderr", "exit"],
|
|
78
|
+
details: _EventDetails,
|
|
79
|
+
) -> None:
|
|
80
|
+
if not self.hooks.observe_hooks:
|
|
81
|
+
return
|
|
82
|
+
_emit_exec_event(
|
|
83
|
+
self.hooks.observe_hooks,
|
|
84
|
+
ExecEvent(
|
|
85
|
+
phase=phase,
|
|
86
|
+
program=self.cmd.program,
|
|
87
|
+
argv=self.cmd.argv_with_program,
|
|
88
|
+
cwd=self.cwd,
|
|
89
|
+
env=self.env_overlay,
|
|
90
|
+
pid=details.pid,
|
|
91
|
+
timestamp=time.time(),
|
|
92
|
+
line=details.line,
|
|
93
|
+
exit_code=details.exit_code,
|
|
94
|
+
duration_s=details.duration_s,
|
|
95
|
+
tags=self.tags,
|
|
96
|
+
),
|
|
97
|
+
pending_tasks=self.pending_tasks,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dc.dataclass(frozen=True, slots=True)
|
|
102
|
+
class _EventDetails:
|
|
103
|
+
pid: int | None
|
|
104
|
+
line: str | None = None
|
|
105
|
+
exit_code: int | None = None
|
|
106
|
+
duration_s: float | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dc.dataclass(frozen=True, slots=True)
|
|
110
|
+
class _PipelineStageResultInputs:
|
|
111
|
+
wait_result: _PipelineWaitResult
|
|
112
|
+
stderr_by_stage: tuple[str | None, ...]
|
|
113
|
+
final_stdout: str | None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _build_pipeline_observations(
|
|
117
|
+
parts: tuple[SafeCmd, ...],
|
|
118
|
+
config: _PipelineRunConfig,
|
|
119
|
+
*,
|
|
120
|
+
pending_tasks: list[asyncio.Task[None]],
|
|
121
|
+
) -> tuple[_StageObservation, ...]:
|
|
122
|
+
hooks_by_stage = tuple(_run_before_hooks(cmd) for cmd in parts)
|
|
123
|
+
cwd = None if config.ctx.cwd is None else Path(config.ctx.cwd)
|
|
124
|
+
env_overlay = _freeze_str_mapping(config.ctx.env)
|
|
125
|
+
return tuple(
|
|
126
|
+
_StageObservation(
|
|
127
|
+
cmd=cmd,
|
|
128
|
+
hooks=hooks,
|
|
129
|
+
tags=_merge_tags(
|
|
130
|
+
{
|
|
131
|
+
"project": cmd.project.name,
|
|
132
|
+
"capture": config.capture,
|
|
133
|
+
"echo": config.echo,
|
|
134
|
+
"pipeline_stage_index": idx,
|
|
135
|
+
"pipeline_stages": len(parts),
|
|
136
|
+
},
|
|
137
|
+
config.ctx.tags,
|
|
138
|
+
),
|
|
139
|
+
cwd=cwd,
|
|
140
|
+
env_overlay=env_overlay,
|
|
141
|
+
pending_tasks=pending_tasks,
|
|
142
|
+
)
|
|
143
|
+
for idx, (cmd, hooks) in enumerate(zip(parts, hooks_by_stage, strict=True))
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _emit_plan_events_and_run_before_hooks(
|
|
148
|
+
observations: tuple[_StageObservation, ...],
|
|
149
|
+
) -> None:
|
|
150
|
+
for obs in observations:
|
|
151
|
+
obs.emit("plan", _EventDetails(pid=None))
|
|
152
|
+
for hook in obs.hooks.before_hooks:
|
|
153
|
+
hook(obs.cmd)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _build_pipeline_stage_results(
|
|
157
|
+
parts: tuple[SafeCmd, ...],
|
|
158
|
+
observations: tuple[_StageObservation, ...],
|
|
159
|
+
*,
|
|
160
|
+
processes: list[asyncio.subprocess.Process],
|
|
161
|
+
inputs: _PipelineStageResultInputs,
|
|
162
|
+
) -> list[CommandResult]:
|
|
163
|
+
sh = _sh_module()
|
|
164
|
+
stage_results: list[CommandResult] = []
|
|
165
|
+
for idx, obs in enumerate(observations):
|
|
166
|
+
process = processes[idx]
|
|
167
|
+
ended_at = inputs.wait_result.ended_at[idx]
|
|
168
|
+
duration_s = (
|
|
169
|
+
None
|
|
170
|
+
if ended_at is None
|
|
171
|
+
else max(0.0, ended_at - inputs.wait_result.started_at[idx])
|
|
172
|
+
)
|
|
173
|
+
obs.emit(
|
|
174
|
+
"exit",
|
|
175
|
+
_EventDetails(
|
|
176
|
+
pid=process.pid,
|
|
177
|
+
exit_code=inputs.wait_result.exit_codes[idx],
|
|
178
|
+
duration_s=duration_s,
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
stage_results.append(
|
|
182
|
+
sh.CommandResult(
|
|
183
|
+
program=obs.cmd.program,
|
|
184
|
+
argv=obs.cmd.argv,
|
|
185
|
+
exit_code=inputs.wait_result.exit_codes[idx],
|
|
186
|
+
pid=process.pid if process.pid is not None else -1,
|
|
187
|
+
stdout=inputs.final_stdout if idx == len(parts) - 1 else None,
|
|
188
|
+
stderr=inputs.stderr_by_stage[idx],
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
return stage_results
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def _finalize_pipeline_execution(
|
|
195
|
+
parts: tuple[SafeCmd, ...],
|
|
196
|
+
observations: tuple[_StageObservation, ...],
|
|
197
|
+
stage_results: list[CommandResult],
|
|
198
|
+
pending_tasks: list[asyncio.Task[None]],
|
|
199
|
+
) -> None:
|
|
200
|
+
hooks_by_stage = tuple(obs.hooks for obs in observations)
|
|
201
|
+
_run_pipeline_after_hooks(parts, hooks_by_stage, stage_results)
|
|
202
|
+
await _wait_for_exec_hook_tasks(pending_tasks)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _run_pipeline(
|
|
206
|
+
parts: tuple[SafeCmd, ...],
|
|
207
|
+
*,
|
|
208
|
+
capture: bool,
|
|
209
|
+
echo: bool,
|
|
210
|
+
context: ExecutionContext | None,
|
|
211
|
+
) -> PipelineResult:
|
|
212
|
+
"""Execute a pipeline and return a structured result."""
|
|
213
|
+
config = _prepare_pipeline_config(capture=capture, echo=echo, context=context)
|
|
214
|
+
pending_tasks: list[asyncio.Task[None]] = []
|
|
215
|
+
observations = _build_pipeline_observations(
|
|
216
|
+
parts,
|
|
217
|
+
config,
|
|
218
|
+
pending_tasks=pending_tasks,
|
|
219
|
+
)
|
|
220
|
+
try:
|
|
221
|
+
_emit_plan_events_and_run_before_hooks(observations)
|
|
222
|
+
(
|
|
223
|
+
processes,
|
|
224
|
+
stderr_tasks,
|
|
225
|
+
stdout_task,
|
|
226
|
+
started_at,
|
|
227
|
+
) = await _spawn_pipeline_processes(
|
|
228
|
+
parts,
|
|
229
|
+
config,
|
|
230
|
+
observations=observations,
|
|
231
|
+
)
|
|
232
|
+
except BaseException:
|
|
233
|
+
await _wait_for_exec_hook_tasks(pending_tasks)
|
|
234
|
+
raise
|
|
235
|
+
try:
|
|
236
|
+
wait_result = await _wait_for_pipeline(
|
|
237
|
+
processes,
|
|
238
|
+
pipe_tasks=_create_pipe_tasks(processes),
|
|
239
|
+
cancel_grace=config.ctx.cancel_grace,
|
|
240
|
+
started_at=started_at,
|
|
241
|
+
)
|
|
242
|
+
inputs = _PipelineStageResultInputs(
|
|
243
|
+
wait_result=wait_result,
|
|
244
|
+
stderr_by_stage=await _gather_optional_text_tasks(stderr_tasks),
|
|
245
|
+
final_stdout=None if stdout_task is None else await stdout_task,
|
|
246
|
+
)
|
|
247
|
+
except BaseException:
|
|
248
|
+
await _cancel_stream_tasks(stderr_tasks, stdout_task)
|
|
249
|
+
await _wait_for_exec_hook_tasks(pending_tasks)
|
|
250
|
+
raise
|
|
251
|
+
stage_results = _build_pipeline_stage_results(
|
|
252
|
+
parts,
|
|
253
|
+
observations,
|
|
254
|
+
processes=processes,
|
|
255
|
+
inputs=inputs,
|
|
256
|
+
)
|
|
257
|
+
await _finalize_pipeline_execution(
|
|
258
|
+
parts,
|
|
259
|
+
observations,
|
|
260
|
+
stage_results,
|
|
261
|
+
pending_tasks,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return _sh_module().PipelineResult(
|
|
265
|
+
stages=tuple(stage_results),
|
|
266
|
+
failure_index=inputs.wait_result.failure_index,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _run_pipeline_after_hooks(
|
|
271
|
+
parts: tuple[SafeCmd, ...],
|
|
272
|
+
hooks_by_stage: tuple[_ExecutionHooks, ...],
|
|
273
|
+
results: list[CommandResult],
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Run registered after hooks for each pipeline stage."""
|
|
276
|
+
for cmd, hooks, result in zip(parts, hooks_by_stage, results, strict=True):
|
|
277
|
+
for hook in hooks.after_hooks:
|
|
278
|
+
hook(cmd, result)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Pipeline subprocess spawning and stream wiring.
|
|
2
|
+
|
|
3
|
+
This module is a thin wrapper around the underlying spawn and cleanup helpers.
|
|
4
|
+
The implementations live in ``cuprum._process_lifecycle`` to keep pipeline
|
|
5
|
+
orchestration cohesive and avoid import cycles.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from cuprum._process_lifecycle import (
|
|
11
|
+
_build_spawn_observations,
|
|
12
|
+
_cleanup_spawned_processes,
|
|
13
|
+
_spawn_pipeline_processes,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"_build_spawn_observations",
|
|
18
|
+
"_cleanup_spawned_processes",
|
|
19
|
+
"_spawn_pipeline_processes",
|
|
20
|
+
]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Stream coordination for pipeline execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import dataclasses as dc
|
|
7
|
+
import sys
|
|
8
|
+
import typing as typ
|
|
9
|
+
|
|
10
|
+
from cuprum._streams import _consume_stream, _pump_stream, _StreamConfig
|
|
11
|
+
|
|
12
|
+
if typ.TYPE_CHECKING:
|
|
13
|
+
from cuprum._pipeline_internals import _StageObservation
|
|
14
|
+
from cuprum.sh import ExecutionContext
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dc.dataclass(frozen=True, slots=True)
|
|
18
|
+
class _PipelineRunConfig:
|
|
19
|
+
ctx: ExecutionContext
|
|
20
|
+
capture: bool
|
|
21
|
+
echo: bool
|
|
22
|
+
stdout_sink: typ.IO[str]
|
|
23
|
+
stderr_sink: typ.IO[str]
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def capture_or_echo(self) -> bool:
|
|
27
|
+
return self.capture or self.echo
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def stream_config(self) -> _StreamConfig:
|
|
31
|
+
return _StreamConfig(
|
|
32
|
+
capture_output=self.capture,
|
|
33
|
+
echo_output=self.echo,
|
|
34
|
+
sink=self.stdout_sink,
|
|
35
|
+
encoding=self.ctx.encoding,
|
|
36
|
+
errors=self.ctx.errors,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _prepare_pipeline_config(
|
|
41
|
+
*,
|
|
42
|
+
capture: bool,
|
|
43
|
+
echo: bool,
|
|
44
|
+
context: ExecutionContext | None,
|
|
45
|
+
) -> _PipelineRunConfig:
|
|
46
|
+
"""Normalise runtime options for pipeline execution."""
|
|
47
|
+
from cuprum._pipeline_internals import _sh_module
|
|
48
|
+
|
|
49
|
+
sh = _sh_module()
|
|
50
|
+
ctx = context or sh.ExecutionContext()
|
|
51
|
+
stdout_sink = ctx.stdout_sink if ctx.stdout_sink is not None else sys.stdout
|
|
52
|
+
stderr_sink = ctx.stderr_sink if ctx.stderr_sink is not None else sys.stderr
|
|
53
|
+
return _PipelineRunConfig(
|
|
54
|
+
ctx=ctx,
|
|
55
|
+
capture=capture,
|
|
56
|
+
echo=echo,
|
|
57
|
+
stdout_sink=stdout_sink,
|
|
58
|
+
stderr_sink=stderr_sink,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dc.dataclass(frozen=True, slots=True)
|
|
63
|
+
class _StageStreamConfig:
|
|
64
|
+
"""Stream file descriptor configuration for a pipeline stage."""
|
|
65
|
+
|
|
66
|
+
stdin: int
|
|
67
|
+
stdout: int
|
|
68
|
+
stderr: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_stage_stream_fds(
|
|
72
|
+
idx: int,
|
|
73
|
+
last_idx: int,
|
|
74
|
+
*,
|
|
75
|
+
capture_or_echo: bool,
|
|
76
|
+
) -> _StageStreamConfig:
|
|
77
|
+
"""Determine stream file descriptors for a pipeline stage.
|
|
78
|
+
|
|
79
|
+
First stage reads from DEVNULL; intermediate stages use pipes for stdin.
|
|
80
|
+
stdout is piped for intermediate stages or when capturing. stderr is piped
|
|
81
|
+
only when capturing or echoing.
|
|
82
|
+
"""
|
|
83
|
+
stdin = asyncio.subprocess.DEVNULL if idx == 0 else asyncio.subprocess.PIPE
|
|
84
|
+
stdout = (
|
|
85
|
+
asyncio.subprocess.PIPE
|
|
86
|
+
if idx != last_idx or capture_or_echo
|
|
87
|
+
else asyncio.subprocess.DEVNULL
|
|
88
|
+
)
|
|
89
|
+
stderr = asyncio.subprocess.PIPE if capture_or_echo else asyncio.subprocess.DEVNULL
|
|
90
|
+
return _StageStreamConfig(stdin=stdin, stdout=stdout, stderr=stderr)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _create_stage_capture_tasks(
|
|
94
|
+
process: asyncio.subprocess.Process,
|
|
95
|
+
config: _PipelineRunConfig,
|
|
96
|
+
*,
|
|
97
|
+
is_last_stage: bool,
|
|
98
|
+
observation: _StageObservation,
|
|
99
|
+
) -> tuple[asyncio.Task[str | None] | None, asyncio.Task[str | None] | None]:
|
|
100
|
+
"""Create stderr and stdout capture tasks for a pipeline stage.
|
|
101
|
+
|
|
102
|
+
Returns (stderr_task, stdout_task). stderr is captured for all stages when
|
|
103
|
+
capture_or_echo is enabled. stdout is only captured for the final stage.
|
|
104
|
+
"""
|
|
105
|
+
stderr_task: asyncio.Task[str | None] | None = None
|
|
106
|
+
stdout_task: asyncio.Task[str | None] | None = None
|
|
107
|
+
|
|
108
|
+
if not config.capture_or_echo:
|
|
109
|
+
return stderr_task, stdout_task
|
|
110
|
+
|
|
111
|
+
stderr_on_line: typ.Callable[[str], None] | None = None
|
|
112
|
+
if observation.hooks.observe_hooks:
|
|
113
|
+
|
|
114
|
+
def stderr_on_line(line: str) -> None:
|
|
115
|
+
from cuprum._pipeline_internals import _EventDetails
|
|
116
|
+
|
|
117
|
+
observation.emit(
|
|
118
|
+
"stderr",
|
|
119
|
+
_EventDetails(pid=process.pid, line=line),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
stderr_task = asyncio.create_task(
|
|
123
|
+
_consume_stream(
|
|
124
|
+
process.stderr,
|
|
125
|
+
dc.replace(config.stream_config, sink=config.stderr_sink),
|
|
126
|
+
on_line=stderr_on_line,
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if not is_last_stage:
|
|
131
|
+
return stderr_task, stdout_task
|
|
132
|
+
|
|
133
|
+
stdout_on_line: typ.Callable[[str], None] | None = None
|
|
134
|
+
if observation.hooks.observe_hooks:
|
|
135
|
+
|
|
136
|
+
def stdout_on_line(line: str) -> None:
|
|
137
|
+
from cuprum._pipeline_internals import _EventDetails
|
|
138
|
+
|
|
139
|
+
observation.emit(
|
|
140
|
+
"stdout",
|
|
141
|
+
_EventDetails(pid=process.pid, line=line),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
stdout_task = asyncio.create_task(
|
|
145
|
+
_consume_stream(
|
|
146
|
+
process.stdout,
|
|
147
|
+
config.stream_config,
|
|
148
|
+
on_line=stdout_on_line,
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return stderr_task, stdout_task
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _create_pipe_tasks(
|
|
156
|
+
processes: list[asyncio.subprocess.Process],
|
|
157
|
+
) -> list[asyncio.Task[None]]:
|
|
158
|
+
"""Create streaming tasks between adjacent pipeline stages."""
|
|
159
|
+
return [
|
|
160
|
+
asyncio.create_task(
|
|
161
|
+
_pump_stream(
|
|
162
|
+
processes[idx].stdout,
|
|
163
|
+
processes[idx + 1].stdin,
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
for idx in range(len(processes) - 1)
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _flatten_stream_tasks(
|
|
171
|
+
stderr_tasks: list[asyncio.Task[str | None] | None],
|
|
172
|
+
stdout_task: asyncio.Task[str | None] | None,
|
|
173
|
+
) -> list[asyncio.Task[str | None]]:
|
|
174
|
+
"""Collect all running stream consumer tasks for cancellation cleanup."""
|
|
175
|
+
tasks = [task for task in stderr_tasks if task is not None]
|
|
176
|
+
if stdout_task is not None:
|
|
177
|
+
tasks.append(stdout_task)
|
|
178
|
+
return tasks
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def _cancel_stream_tasks(
|
|
182
|
+
stderr_tasks: list[asyncio.Task[str | None] | None],
|
|
183
|
+
stdout_task: asyncio.Task[str | None] | None,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Cancel stream consumer tasks and await their completion."""
|
|
186
|
+
tasks = _flatten_stream_tasks(stderr_tasks, stdout_task)
|
|
187
|
+
for task in tasks:
|
|
188
|
+
task.cancel()
|
|
189
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def _gather_optional_text_tasks(
|
|
193
|
+
tasks: list[asyncio.Task[str | None] | None],
|
|
194
|
+
) -> tuple[str | None, ...]:
|
|
195
|
+
"""Await optional capture tasks, returning a tuple aligned with inputs."""
|
|
196
|
+
return tuple(
|
|
197
|
+
await asyncio.gather(
|
|
198
|
+
*(
|
|
199
|
+
task if task is not None else asyncio.sleep(0, result=None)
|
|
200
|
+
for task in tasks
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def _collect_pipe_results(
|
|
207
|
+
pipe_tasks: list[asyncio.Task[None]],
|
|
208
|
+
) -> list[object]:
|
|
209
|
+
"""Collect pipe task results, capturing exceptions rather than raising them.
|
|
210
|
+
|
|
211
|
+
Uses return_exceptions=True to gather all results including any exceptions
|
|
212
|
+
that occurred during pipe streaming between pipeline stages.
|
|
213
|
+
"""
|
|
214
|
+
return list(await asyncio.gather(*pipe_tasks, return_exceptions=True))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _surface_unexpected_pipe_failures(pipe_results: list[object]) -> None:
|
|
218
|
+
"""Raise non-BrokenPipe exceptions from pipe results.
|
|
219
|
+
|
|
220
|
+
BrokenPipeError and ConnectionResetError are expected when downstream
|
|
221
|
+
processes terminate early (e.g., head) and should not fail the pipeline.
|
|
222
|
+
Other exceptions indicate genuine failures and must be surfaced.
|
|
223
|
+
"""
|
|
224
|
+
for result in pipe_results:
|
|
225
|
+
if isinstance(result, Exception) and not isinstance(
|
|
226
|
+
result,
|
|
227
|
+
(BrokenPipeError, ConnectionResetError),
|
|
228
|
+
):
|
|
229
|
+
raise result
|