cuprum 0.1.0__tar.gz
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-0.1.0/PKG-INFO +106 -0
- cuprum-0.1.0/README.md +94 -0
- cuprum-0.1.0/cuprum/__init__.py +99 -0
- cuprum-0.1.0/cuprum/_observability.py +77 -0
- cuprum-0.1.0/cuprum/_pipeline_internals.py +278 -0
- cuprum-0.1.0/cuprum/_pipeline_spawn.py +20 -0
- cuprum-0.1.0/cuprum/_pipeline_streams.py +229 -0
- cuprum-0.1.0/cuprum/_pipeline_wait.py +136 -0
- cuprum-0.1.0/cuprum/_process_lifecycle.py +265 -0
- cuprum-0.1.0/cuprum/_streams.py +230 -0
- cuprum-0.1.0/cuprum/_testing.py +53 -0
- cuprum-0.1.0/cuprum/adapters/__init__.py +40 -0
- cuprum-0.1.0/cuprum/adapters/logging_adapter.py +216 -0
- cuprum-0.1.0/cuprum/adapters/metrics_adapter.py +276 -0
- cuprum-0.1.0/cuprum/adapters/tracing_adapter.py +387 -0
- cuprum-0.1.0/cuprum/catalogue.py +158 -0
- cuprum-0.1.0/cuprum/context.py +509 -0
- cuprum-0.1.0/cuprum/events.py +72 -0
- cuprum-0.1.0/cuprum/logging_hooks.py +145 -0
- cuprum-0.1.0/cuprum/program.py +17 -0
- cuprum-0.1.0/cuprum/sh.py +558 -0
- cuprum-0.1.0/cuprum/unittests/test_adapters.py +601 -0
- cuprum-0.1.0/cuprum/unittests/test_catalogue.py +129 -0
- cuprum-0.1.0/cuprum/unittests/test_context.py +383 -0
- cuprum-0.1.0/cuprum/unittests/test_logging_hook.py +193 -0
- cuprum-0.1.0/cuprum/unittests/test_observe.py +159 -0
- cuprum-0.1.0/cuprum/unittests/test_pipeline.py +529 -0
- cuprum-0.1.0/cuprum/unittests/test_public_api.py +33 -0
- cuprum-0.1.0/cuprum/unittests/test_safe_cmd_run.py +486 -0
- cuprum-0.1.0/cuprum/unittests/test_sh.py +118 -0
- cuprum-0.1.0/pyproject.toml +136 -0
cuprum-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: cuprum
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: cuprum package
|
|
5
|
+
Author: Payton McIntosh
|
|
6
|
+
Author-email: Payton McIntosh <pmcintosh@df12.net>
|
|
7
|
+
License: ISC
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Project-URL: Homepage, https://df12.studio/docs.html
|
|
10
|
+
Project-URL: Repository, https://github.com/leynos/cuprum
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# cuprum
|
|
14
|
+
|
|
15
|
+
Typed, async command execution for Python—so you can ditch the shell scripts
|
|
16
|
+
without losing your mind.
|
|
17
|
+
|
|
18
|
+
## What is this?
|
|
19
|
+
|
|
20
|
+
If you've ever written a Python script that calls out to external commands,
|
|
21
|
+
you've probably experienced the joys of `subprocess`: stringly-typed arguments,
|
|
22
|
+
mysterious failures, and output that vanishes into the void. Cuprum is here to
|
|
23
|
+
help.
|
|
24
|
+
|
|
25
|
+
We give you a **typed, safe approach to running external programs**. Instead of
|
|
26
|
+
passing arbitrary strings to a shell, you work with a curated catalogue of
|
|
27
|
+
approved executables. Each command carries metadata about its project, so
|
|
28
|
+
downstream tooling knows how to filter noise from logs or where to find
|
|
29
|
+
documentation.
|
|
30
|
+
|
|
31
|
+
Cuprum is async-first but provides synchronous wrappers for scripts that don't
|
|
32
|
+
need the full async machinery. Whether you're building deployment helpers, CI
|
|
33
|
+
glue, or maintenance scripts, we want "Python instead of Bash" to feel like an
|
|
34
|
+
upgrade rather than a chore.
|
|
35
|
+
|
|
36
|
+
## Quick taste
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from cuprum import ECHO, ExecutionContext, sh
|
|
40
|
+
|
|
41
|
+
# Create a builder for a curated program
|
|
42
|
+
echo = sh.make(ECHO)
|
|
43
|
+
|
|
44
|
+
# Build a command with typed arguments
|
|
45
|
+
cmd = echo("-n", "hello, cuprum!")
|
|
46
|
+
|
|
47
|
+
# Run it (async)
|
|
48
|
+
result = await cmd.run(echo=True)
|
|
49
|
+
if result.ok:
|
|
50
|
+
print(f"Output: {result.stdout}")
|
|
51
|
+
|
|
52
|
+
# Or run it synchronously
|
|
53
|
+
result = cmd.run_sync()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Features
|
|
57
|
+
|
|
58
|
+
- **Catalogue-based safety** – Only approved programs can run; unknown
|
|
59
|
+
executables raise `UnknownProgramError`.
|
|
60
|
+
- **Typed command building** – `sh.make()` returns builders that validate
|
|
61
|
+
arguments and carry project metadata.
|
|
62
|
+
- **Async-first execution** – `await cmd.run()` with capture/echo toggles,
|
|
63
|
+
environment overlays, and working directory control.
|
|
64
|
+
- **Synchronous convenience** – `cmd.run_sync()` when you don't need async.
|
|
65
|
+
- **Graceful cancellation** – Cancelled tasks send `SIGTERM`, wait briefly,
|
|
66
|
+
then escalate to `SIGKILL`.
|
|
67
|
+
- **Structured results** – `CommandResult` gives you exit code, pid, stdout,
|
|
68
|
+
stderr, and an `ok` helper.
|
|
69
|
+
- **Zero dependencies** – Just Python 3.12+ and the standard library.
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
```shell
|
|
74
|
+
pip install cuprum
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
78
|
+
|
|
79
|
+
```shell
|
|
80
|
+
uv add cuprum
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Status
|
|
84
|
+
|
|
85
|
+
Cuprum is in early development. The typed command core and execution runtime
|
|
86
|
+
are complete (Phase 1 of the [roadmap](docs/roadmap.md)), but context-scoped
|
|
87
|
+
allowlists and hooks are still on the way. The API may shift as we learn what
|
|
88
|
+
works best.
|
|
89
|
+
|
|
90
|
+
## Documentation
|
|
91
|
+
|
|
92
|
+
For the full guide—including how to build your own program catalogues, write
|
|
93
|
+
project-specific builders, and control execution contexts—see the
|
|
94
|
+
[users' guide](docs/users-guide.md).
|
|
95
|
+
|
|
96
|
+
## Why "cuprum"?
|
|
97
|
+
|
|
98
|
+
The name is a tip of the hat to [Plumbum](https://plumbum.readthedocs.io/), the
|
|
99
|
+
library that showed us shell-like scripting in Python could actually be
|
|
100
|
+
pleasant. "Cuprum" is Latin for copper—another metal used in pipes—and we hope
|
|
101
|
+
to carry that spirit forward with a focus on type safety and explicit
|
|
102
|
+
allowlists.
|
|
103
|
+
|
|
104
|
+
## Licence
|
|
105
|
+
|
|
106
|
+
[ISC](LICENSE)
|
cuprum-0.1.0/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# cuprum
|
|
2
|
+
|
|
3
|
+
Typed, async command execution for Python—so you can ditch the shell scripts
|
|
4
|
+
without losing your mind.
|
|
5
|
+
|
|
6
|
+
## What is this?
|
|
7
|
+
|
|
8
|
+
If you've ever written a Python script that calls out to external commands,
|
|
9
|
+
you've probably experienced the joys of `subprocess`: stringly-typed arguments,
|
|
10
|
+
mysterious failures, and output that vanishes into the void. Cuprum is here to
|
|
11
|
+
help.
|
|
12
|
+
|
|
13
|
+
We give you a **typed, safe approach to running external programs**. Instead of
|
|
14
|
+
passing arbitrary strings to a shell, you work with a curated catalogue of
|
|
15
|
+
approved executables. Each command carries metadata about its project, so
|
|
16
|
+
downstream tooling knows how to filter noise from logs or where to find
|
|
17
|
+
documentation.
|
|
18
|
+
|
|
19
|
+
Cuprum is async-first but provides synchronous wrappers for scripts that don't
|
|
20
|
+
need the full async machinery. Whether you're building deployment helpers, CI
|
|
21
|
+
glue, or maintenance scripts, we want "Python instead of Bash" to feel like an
|
|
22
|
+
upgrade rather than a chore.
|
|
23
|
+
|
|
24
|
+
## Quick taste
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from cuprum import ECHO, ExecutionContext, sh
|
|
28
|
+
|
|
29
|
+
# Create a builder for a curated program
|
|
30
|
+
echo = sh.make(ECHO)
|
|
31
|
+
|
|
32
|
+
# Build a command with typed arguments
|
|
33
|
+
cmd = echo("-n", "hello, cuprum!")
|
|
34
|
+
|
|
35
|
+
# Run it (async)
|
|
36
|
+
result = await cmd.run(echo=True)
|
|
37
|
+
if result.ok:
|
|
38
|
+
print(f"Output: {result.stdout}")
|
|
39
|
+
|
|
40
|
+
# Or run it synchronously
|
|
41
|
+
result = cmd.run_sync()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- **Catalogue-based safety** – Only approved programs can run; unknown
|
|
47
|
+
executables raise `UnknownProgramError`.
|
|
48
|
+
- **Typed command building** – `sh.make()` returns builders that validate
|
|
49
|
+
arguments and carry project metadata.
|
|
50
|
+
- **Async-first execution** – `await cmd.run()` with capture/echo toggles,
|
|
51
|
+
environment overlays, and working directory control.
|
|
52
|
+
- **Synchronous convenience** – `cmd.run_sync()` when you don't need async.
|
|
53
|
+
- **Graceful cancellation** – Cancelled tasks send `SIGTERM`, wait briefly,
|
|
54
|
+
then escalate to `SIGKILL`.
|
|
55
|
+
- **Structured results** – `CommandResult` gives you exit code, pid, stdout,
|
|
56
|
+
stderr, and an `ok` helper.
|
|
57
|
+
- **Zero dependencies** – Just Python 3.12+ and the standard library.
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
```shell
|
|
62
|
+
pip install cuprum
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
66
|
+
|
|
67
|
+
```shell
|
|
68
|
+
uv add cuprum
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Status
|
|
72
|
+
|
|
73
|
+
Cuprum is in early development. The typed command core and execution runtime
|
|
74
|
+
are complete (Phase 1 of the [roadmap](docs/roadmap.md)), but context-scoped
|
|
75
|
+
allowlists and hooks are still on the way. The API may shift as we learn what
|
|
76
|
+
works best.
|
|
77
|
+
|
|
78
|
+
## Documentation
|
|
79
|
+
|
|
80
|
+
For the full guide—including how to build your own program catalogues, write
|
|
81
|
+
project-specific builders, and control execution contexts—see the
|
|
82
|
+
[users' guide](docs/users-guide.md).
|
|
83
|
+
|
|
84
|
+
## Why "cuprum"?
|
|
85
|
+
|
|
86
|
+
The name is a tip of the hat to [Plumbum](https://plumbum.readthedocs.io/), the
|
|
87
|
+
library that showed us shell-like scripting in Python could actually be
|
|
88
|
+
pleasant. "Cuprum" is Latin for copper—another metal used in pipes—and we hope
|
|
89
|
+
to carry that spirit forward with a focus on type safety and explicit
|
|
90
|
+
allowlists.
|
|
91
|
+
|
|
92
|
+
## Licence
|
|
93
|
+
|
|
94
|
+
[ISC](LICENSE)
|
|
@@ -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
|
+
]
|