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 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
+ ]
@@ -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