cmdchain 0.1.2__tar.gz → 0.2.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.
- cmdchain-0.2.0/.github/dependabot.yml +17 -0
- cmdchain-0.2.0/.github/workflows/ci.yml +95 -0
- cmdchain-0.2.0/.github/workflows/publish.yml +38 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/.gitignore +2 -0
- cmdchain-0.2.0/CHANGELOG.md +125 -0
- cmdchain-0.2.0/CLAUDE.md +47 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/PKG-INFO +227 -84
- {cmdchain-0.1.2 → cmdchain-0.2.0}/README.md +220 -78
- {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/07_video_convert.py +1 -1
- {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/10_report_sbom.py +6 -2
- {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/12_webhook_metrics.py +3 -3
- cmdchain-0.2.0/examples/13_live_output.py +20 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/pyproject.toml +21 -7
- cmdchain-0.2.0/src/clichain/__init__.py +26 -0
- cmdchain-0.2.0/src/clichain/checks.py +208 -0
- cmdchain-0.2.0/src/clichain/cli.py +102 -0
- cmdchain-0.2.0/src/clichain/errors.py +156 -0
- cmdchain-0.2.0/src/clichain/execute.py +867 -0
- cmdchain-0.2.0/src/clichain/live.py +547 -0
- cmdchain-0.2.0/src/clichain/manifest.py +191 -0
- cmdchain-0.2.0/src/clichain/pipeline.py +380 -0
- cmdchain-0.2.0/src/clichain/process.py +204 -0
- cmdchain-0.2.0/src/clichain/render.py +248 -0
- cmdchain-0.2.0/src/clichain/result.py +88 -0
- cmdchain-0.2.0/src/clichain/steps.py +244 -0
- cmdchain-0.2.0/tests/conftest.py +117 -0
- cmdchain-0.2.0/tests/test_cli.py +96 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/tests/test_compile.py +7 -5
- cmdchain-0.2.0/tests/test_concurrency.py +153 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/tests/test_core.py +45 -44
- cmdchain-0.2.0/tests/test_each_merge.py +62 -0
- cmdchain-0.2.0/tests/test_environment.py +29 -0
- cmdchain-0.2.0/tests/test_features.py +107 -0
- cmdchain-0.2.0/tests/test_live.py +435 -0
- cmdchain-0.2.0/tests/test_reliability.py +393 -0
- cmdchain-0.2.0/tests/test_render.py +80 -0
- cmdchain-0.2.0/tests/test_sbom.py +51 -0
- cmdchain-0.2.0/tests/test_security.py +276 -0
- cmdchain-0.2.0/tests/test_signals.py +118 -0
- cmdchain-0.2.0/tests/test_vs_bash.py +147 -0
- cmdchain-0.2.0/uv.lock +375 -0
- cmdchain-0.1.2/.github/workflows/ci.yml +0 -61
- cmdchain-0.1.2/.github/workflows/publish.yml +0 -34
- cmdchain-0.1.2/src/clichain/__init__.py +0 -29
- cmdchain-0.1.2/src/clichain/checks.py +0 -177
- cmdchain-0.1.2/src/clichain/cli.py +0 -151
- cmdchain-0.1.2/src/clichain/core.py +0 -1639
- {cmdchain-0.1.2 → cmdchain-0.2.0}/LICENSE-APACHE +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/LICENSE-MIT +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/benchmarks/bench.py +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/benchmarks/bench_from_file.py +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/01_basic_pipe.py +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/02_capture_interpolate.py +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/03_filter.py +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/04_file_io.py +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/05_each.py +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/06_disk_image.py +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/08_checks.py +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/09_meter.py +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/11_compilable.py +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-favicon.svg +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-horizontal-dark.svg +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-horizontal-light.svg +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-logo-dark.svg +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-logo-light.svg +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-mark-dark.svg +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-mark-light.svg +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain_angular_chain_final.svg +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/src/clichain/py.typed +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/src/cmdchain/__init__.py +0 -0
- {cmdchain-0.1.2 → cmdchain-0.2.0}/tests/__init__.py +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: github-actions
|
|
4
|
+
directory: /
|
|
5
|
+
schedule:
|
|
6
|
+
interval: weekly
|
|
7
|
+
groups:
|
|
8
|
+
actions:
|
|
9
|
+
patterns: ["*"]
|
|
10
|
+
|
|
11
|
+
- package-ecosystem: uv
|
|
12
|
+
directory: /
|
|
13
|
+
schedule:
|
|
14
|
+
interval: weekly
|
|
15
|
+
groups:
|
|
16
|
+
dev-tools:
|
|
17
|
+
patterns: ["*"]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
workflow_call: # publish.yml runs this before uploading
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
# Actions are pinned by commit SHA (tag in the comment); Dependabot keeps them current.
|
|
14
|
+
# Tools come from the `dev` dependency group via uv.lock, so a new ruff or basedpyright
|
|
15
|
+
# release cannot break main without a code change.
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
lint:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
22
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
23
|
+
with:
|
|
24
|
+
python-version: "3.15"
|
|
25
|
+
- run: uv sync --locked
|
|
26
|
+
- run: uv run ruff check src/ tests/ examples/
|
|
27
|
+
- run: uv run ruff format --check src/ tests/ examples/
|
|
28
|
+
|
|
29
|
+
typecheck:
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
33
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
34
|
+
with:
|
|
35
|
+
python-version: "3.15"
|
|
36
|
+
- run: uv sync --locked
|
|
37
|
+
- run: uv run basedpyright src/ tests/
|
|
38
|
+
|
|
39
|
+
test:
|
|
40
|
+
runs-on: ${{ matrix.os }}
|
|
41
|
+
strategy:
|
|
42
|
+
fail-fast: false
|
|
43
|
+
matrix:
|
|
44
|
+
os: [ubuntu-latest, macos-latest]
|
|
45
|
+
# 3.15t is the free-threaded build: the concurrency tests run without a GIL.
|
|
46
|
+
python-version: ["3.11", "3.12", "3.13", "3.14", "3.15", "3.15t"]
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
49
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
50
|
+
with:
|
|
51
|
+
python-version: ${{ matrix.python-version }}
|
|
52
|
+
- run: uv sync --locked
|
|
53
|
+
# pytest settings (pyproject.toml) turn every warning into an error, ResourceWarning
|
|
54
|
+
# included, and give each test a 60 s timeout so a regression cannot hang the job.
|
|
55
|
+
- run: uv run coverage run -m pytest -v
|
|
56
|
+
- run: uv run coverage report
|
|
57
|
+
|
|
58
|
+
prerelease:
|
|
59
|
+
# The next Python, so breakage shows up before its release. Allowed to fail.
|
|
60
|
+
runs-on: ubuntu-latest
|
|
61
|
+
continue-on-error: true
|
|
62
|
+
steps:
|
|
63
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
64
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
65
|
+
with:
|
|
66
|
+
python-version: "3.16"
|
|
67
|
+
- run: uv sync --locked
|
|
68
|
+
- run: uv run pytest -v
|
|
69
|
+
|
|
70
|
+
compile:
|
|
71
|
+
runs-on: ubuntu-latest
|
|
72
|
+
steps:
|
|
73
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
74
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
75
|
+
with:
|
|
76
|
+
python-version: "3.15"
|
|
77
|
+
- run: uv sync --locked --extra compile
|
|
78
|
+
- run: uv run pytest -v tests/test_compile.py
|
|
79
|
+
|
|
80
|
+
examples:
|
|
81
|
+
runs-on: ubuntu-latest
|
|
82
|
+
steps:
|
|
83
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
84
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
85
|
+
with:
|
|
86
|
+
python-version: "3.15"
|
|
87
|
+
- run: uv sync --locked
|
|
88
|
+
# 12_webhook_metrics.py is skipped: it posts to a remote HTTP endpoint.
|
|
89
|
+
# 06 and 07 only describe their pipelines, so they run without pv or ffmpeg.
|
|
90
|
+
- run: |
|
|
91
|
+
for f in examples/*.py; do
|
|
92
|
+
case "$f" in *12_webhook_metrics.py) continue ;; esac
|
|
93
|
+
echo "=== $f ==="
|
|
94
|
+
(cd "$RUNNER_TEMP" && uv run --project "$GITHUB_WORKSPACE" python -W error "$GITHUB_WORKSPACE/$f") || exit 1
|
|
95
|
+
done
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
ci:
|
|
12
|
+
# Nothing is uploaded unless the full CI suite passes on the release commit.
|
|
13
|
+
uses: ./.github/workflows/ci.yml
|
|
14
|
+
|
|
15
|
+
build:
|
|
16
|
+
needs: ci
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
20
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
21
|
+
- run: uv build
|
|
22
|
+
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
23
|
+
with:
|
|
24
|
+
name: dist
|
|
25
|
+
path: dist/
|
|
26
|
+
|
|
27
|
+
publish:
|
|
28
|
+
needs: build
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
environment: pypi
|
|
31
|
+
permissions:
|
|
32
|
+
id-token: write # PyPI trusted publishing; also produces attestations
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
35
|
+
with:
|
|
36
|
+
name: dist
|
|
37
|
+
path: dist/
|
|
38
|
+
- uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the version is 0.x,
|
|
6
|
+
a breaking change bumps the minor version and everything else bumps the patch version.
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0] - 2026-11-03
|
|
11
|
+
|
|
12
|
+
A rewrite of the executor for safety and correctness. Every item below has a test.
|
|
13
|
+
|
|
14
|
+
### Breaking changes
|
|
15
|
+
|
|
16
|
+
- **Python 3.11+** is required (3.10 reached end of life in October 2026). POSIX only.
|
|
17
|
+
- `result.sbom()` / `sbom_json()` now return a **CycloneDX 1.6** document. The previous
|
|
18
|
+
run record is `result.manifest()` / `manifest_json()`.
|
|
19
|
+
- `redirect()` streams straight to its file and **consumes** what it writes, like `>`:
|
|
20
|
+
redirected stdout no longer appears in `result.stdout`, and redirected stderr not in
|
|
21
|
+
`result.stderr`. `redirect(stderr=...)` now writes the commands' stderr (it used to
|
|
22
|
+
write stdout's text).
|
|
23
|
+
- A writer killed by **SIGPIPE** because its reader exited early (`yes | head -1`) is no
|
|
24
|
+
longer a failure; use `run(ignore_sigpipe=False)` for the old behaviour.
|
|
25
|
+
- **Version checks fail closed**: if a tool's version cannot be determined, a `version=`
|
|
26
|
+
constraint is not met (`on_fail="warn"` to continue). Invalid constraints raise
|
|
27
|
+
`ValueError` when the tool is defined. Only `--version` is probed (`version_args=` for
|
|
28
|
+
other flags).
|
|
29
|
+
- **Pipelines are immutable**: builder methods return a new pipeline instead of
|
|
30
|
+
appending to the one they were called on. A second input source raises `ValueError`.
|
|
31
|
+
- After `.capture()`, only `{name}` for captured names is substituted; other braces are
|
|
32
|
+
literal (they used to raise `KeyError`).
|
|
33
|
+
- `CLICHAIN_DESCRIBE` only enables describe mode for `1`, `true`, `yes`, `describe`,
|
|
34
|
+
`text` or `json` (it used to be any non-empty value, including `0`), and raises
|
|
35
|
+
`DescribeExit` (a `SystemExit` subclass) instead of calling `sys.exit()`.
|
|
36
|
+
- A signal during `run()` reaches the previously installed handler after cleanup:
|
|
37
|
+
`KeyboardInterrupt` for SIGINT by default, instead of `SystemExit(130)`.
|
|
38
|
+
- Missing input files and unwritable redirects return a failed `Result` (returncode 1)
|
|
39
|
+
instead of raising `OSError`. Exceptions from your own callbacks propagate instead of
|
|
40
|
+
being printed by the thread and silently truncating output.
|
|
41
|
+
- `clichain check` exits 1 when a check fails, and no longer executes pipelines run by
|
|
42
|
+
the script.
|
|
43
|
+
- `clichain.core` is gone; import from `clichain`.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- `tool(..., env=, inherit_env=, cwd=, version_args=, ok_codes=)`; `ok_codes={0, 1}` makes
|
|
48
|
+
an expected status (grep with no match, diff with differences) a success.
|
|
49
|
+
- `feed()` accepts `bytes`.
|
|
50
|
+
- Live output: `run(live="auto" | "blocks" | "grouped" | "lines")` or `Live(...)`. Every
|
|
51
|
+
job (a run, or an `each()` line; or every process with `unit="process"`) gets its own
|
|
52
|
+
buffer, so concurrent tools never mangle each other's output. Blocks redraw in place on
|
|
53
|
+
a terminal; grouped output uses `::group::` on GitHub Actions. Default: nothing live.
|
|
54
|
+
- `tool(..., tty=True)`: the command's stderr is a pseudo-terminal, so tools that only
|
|
55
|
+
show progress on a terminal (git, curl, rsync) do; stdout stays a pipe.
|
|
56
|
+
- `run(..., timeout=, check=, encoding=, errors=, ignore_sigpipe=, output=)`;
|
|
57
|
+
`PipelineError` for `check=True`; exit code 124 (`X124`) for timeouts.
|
|
58
|
+
- `result.stdout_bytes`, and lossless handling of binary / non-UTF-8 data
|
|
59
|
+
(`errors="surrogateescape"` by default).
|
|
60
|
+
- `result.manifest(redact=True)`, which removes hostname, cwd and directory paths.
|
|
61
|
+
- `capture(name, reject_options=True)` against option injection.
|
|
62
|
+
- `pipe()` accepts a `Pipeline`, not only a `Cmd`.
|
|
63
|
+
- Free-threaded Python support, tested in CI.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- `StepProfile.bytes_in` / `bytes_out` are measured at every group boundary (they were
|
|
68
|
+
only set when a meter was present, and always equal); the report always has a bytes
|
|
69
|
+
column. Both are now `int`, never `None`.
|
|
70
|
+
|
|
71
|
+
### Fixed
|
|
72
|
+
|
|
73
|
+
- Hangs: a command writing more than 64 KB to stderr; a filter/peek/meter callback that
|
|
74
|
+
raises; a bridge in front of an early-exit reader (`... | filter | head -1`); a stopped
|
|
75
|
+
run whose processes left descendants holding a pipe open.
|
|
76
|
+
- File descriptor leaks (stderr pipes, bridge inputs).
|
|
77
|
+
- Meter statistics accumulating across runs of the same pipeline; meter callbacks
|
|
78
|
+
receiving a live, still-changing object.
|
|
79
|
+
- `explain` codes: invalid input to `clichain explain` no longer crashes; exit codes above
|
|
80
|
+
128 that are not signals (e.g. 255) are explained as exit codes.
|
|
81
|
+
- Parallel `each()`: a callback error stops the remaining lines; a failing line's stderr
|
|
82
|
+
is kept even when an earlier line was skipped.
|
|
83
|
+
|
|
84
|
+
### Security
|
|
85
|
+
|
|
86
|
+
- The version probe never passes positional words (`version`) or `-v`, which tools
|
|
87
|
+
without a version flag treated as file operands (BSD `rm` deleted `./version`), and it
|
|
88
|
+
runs with stdin closed (it consumed the caller's stdin).
|
|
89
|
+
- `import clichain` no longer runs `git`; the commit is looked up lazily, labelled
|
|
90
|
+
`source_commit`, from the repository of the calling script.
|
|
91
|
+
- CI actions are pinned by commit SHA, tools come from `uv.lock`, Dependabot keeps them
|
|
92
|
+
current, and publishing runs only after the full CI suite passes.
|
|
93
|
+
|
|
94
|
+
### Performance
|
|
95
|
+
|
|
96
|
+
- Bridges process data in blocks: filter about 6x, peek 11x and meter 49x faster on
|
|
97
|
+
2M lines. Redirected output never passes through Python memory.
|
|
98
|
+
|
|
99
|
+
## [0.1.2] - 2026-04-02
|
|
100
|
+
|
|
101
|
+
### Added
|
|
102
|
+
|
|
103
|
+
- `cmdchain` import alias for the `clichain` module.
|
|
104
|
+
|
|
105
|
+
### Fixed
|
|
106
|
+
|
|
107
|
+
- Logo rendering on PyPI; the version is single-sourced from `pyproject.toml`.
|
|
108
|
+
|
|
109
|
+
## [0.1.1] - 2026-04-02
|
|
110
|
+
|
|
111
|
+
### Added
|
|
112
|
+
|
|
113
|
+
- Git commit hash in the SBOM and reports; README included on PyPI.
|
|
114
|
+
|
|
115
|
+
## [0.1.0] - 2026-04-02
|
|
116
|
+
|
|
117
|
+
First release: `tool()`, pipelines with `pipe`, `filter`, `each`, `peek`, `meter`,
|
|
118
|
+
`capture`, `feed`, `from_file`, `redirect`; preflight checks; reports and SBOM; the
|
|
119
|
+
`clichain` CLI (`explain`, `check`, `compile`).
|
|
120
|
+
|
|
121
|
+
[Unreleased]: https://github.com/dardevelin/clichain/compare/v0.2.0...HEAD
|
|
122
|
+
[0.2.0]: https://github.com/dardevelin/clichain/compare/v0.1.2...v0.2.0
|
|
123
|
+
[0.1.2]: https://github.com/dardevelin/clichain/compare/v0.1.1...v0.1.2
|
|
124
|
+
[0.1.1]: https://github.com/dardevelin/clichain/compare/v0.1.0...v0.1.1
|
|
125
|
+
[0.1.0]: https://github.com/dardevelin/clichain/releases/tag/v0.1.0
|
cmdchain-0.2.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project
|
|
6
|
+
|
|
7
|
+
clichain: fluent, lazily-evaluated pipelines of CLI commands connected by real kernel pipes. Zero runtime dependencies, Python 3.11+ (free-threaded builds included), POSIX only.
|
|
8
|
+
|
|
9
|
+
**Versioning:** Semantic Versioning, recorded in `CHANGELOG.md` (Keep a Changelog). While 0.x, a breaking change bumps the minor version, anything else the patch; bump `version` in `pyproject.toml` and run `uv lock`. Releases are git tags `vX.Y.Z`; publishing a GitHub release uploads to PyPI after CI passes.
|
|
10
|
+
|
|
11
|
+
**Naming split:** the PyPI distribution is `cmdchain`, the import module is `clichain`; `src/cmdchain/` only re-exports `clichain`. The version is single-sourced in `pyproject.toml` and read via `importlib.metadata.version("cmdchain")` (in `manifest.py`), so it reads `0.0.0` unless the package is installed.
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv sync # .venv + dev tools from uv.lock (CI uses --locked)
|
|
17
|
+
uv run ruff check src/ tests/ examples/
|
|
18
|
+
uv run ruff format --check src/ tests/ examples/
|
|
19
|
+
uv run basedpyright src/ tests/
|
|
20
|
+
uv run pytest # all tests
|
|
21
|
+
GITHUB_ACTIONS=true CI=true TERM=dumb CLICHAIN_DESCRIBE=json uv run pytest # must pass too: CI's env
|
|
22
|
+
uv run pytest tests/test_core.py::test_feed # single test
|
|
23
|
+
uv run coverage run -m pytest && uv run coverage report # also measures subprocesses
|
|
24
|
+
uv run -p 3.15t pytest # free-threaded interpreter
|
|
25
|
+
uv sync --extra compile && uv run pytest tests/test_compile.py # pyinstaller; skipped without it
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Tests are hermetic: `tests/conftest.py` pins every environment variable the library reads (`HERMETIC_ENV`), and `tests/test_environment.py` fails if the library starts reading one that is not pinned. A local pass that differs from CI usually means a new environment dependency. pytest config makes **every warning an error** (so a leaked fd's `ResourceWarning` fails the test) and gives each test a 60 s timeout. `tests/conftest.py` fails any test whose worker threads raised unhandled exceptions, and provides `run_snippet()` for anything that could hang or involves signals: it runs code in a fresh interpreter in its own session and kills the whole session on timeout. CI runs 3.11–3.15 and 3.15t on Ubuntu and macOS, plus an allowed-to-fail 3.16 job; tests shell out to coreutils, so code must work with both GNU and BSD tools.
|
|
29
|
+
|
|
30
|
+
## Architecture
|
|
31
|
+
|
|
32
|
+
Flow: `tool()` → immutable `Pipeline` of frozen step dataclasses → `run()` (`pipeline.py`) → `execute()` (`execute.py`) → `Result`.
|
|
33
|
+
|
|
34
|
+
- **Immutability.** Every builder method returns a new `Pipeline`; `Cmd` is a one-step `Pipeline` whose `__call__` appends args. Steps are frozen, and runtime state (meter stats, captured values, stderr) lives in per-run objects. Pipelines can therefore be shared and run concurrently. `.feed()` / `.from_file()` set the pipeline's stdin (stored at index 0) and a second source raises.
|
|
35
|
+
- **Steps own their description** (`steps.py`): `title()`, `node()` (manifest fields) and the `streamable` class flag. Only `execute.py` knows how to run them. A new step type needs a dataclass there plus handling in `_Run.run_group` (buffered) or `_StreamGroup` (streaming).
|
|
36
|
+
- **Planning** (`execute.plan`): consecutive streamable steps form one group; a `RedirectStep` right after them joins as the group's sink (streams to the file). Every other step is its own buffering boundary; text passes between groups as `str`.
|
|
37
|
+
- **Streaming group** (`_StreamGroup`): processes run in byte mode; Python bridges (filter/peek/meter) are `_Worker` threads processing line batches (`_batches`) and decoding only for callbacks. stderr is drained concurrently into a bounded `_Tail`.
|
|
38
|
+
- **Abandonable pipe I/O:** pipes are unbuffered, and every blocking read or write goes through `_read`/`_send`, which `poll()` and ask the group's `_give_up()`. That returns True once the run is stopped or aborting and none of the group's direct children is alive, so a descendant holding a pipe open can't block teardown. Children deliberately share the terminal's process group (no `process_group=0`), so tty prompts keep working; descendants are not signalled.
|
|
39
|
+
- **Resource invariants (keep them):** every fd has a single owner. The main thread holds it in `owned` until it hands it to a child (and closes its copy) or to a worker (which closes it in `finally`). Never close an fd another thread may use. Stopping signals processes and never closes fds. A worker that stops closes its output first, then its input.
|
|
40
|
+
- **Process supervision** (`process.py`): one `ProcessGroup` per top-level run is shared by nested `each()` runs, including thread-pool workers. `stop(reason, returncode, message)` is thread- and signal-safe (RLock, because a handler can interrupt the main thread mid-critical-section). The first stop decides the Result's returncode and message (timeout → 124); `finish()` makes later stops (a late timer) no-ops. `SignalGuard` installs handlers only on the main thread, skips ignored signals, and after cleanup `deliver()` re-sends the signal to the previous handler (KeyboardInterrupt, default SIGTERM death, or a custom handler → Result 128+n).
|
|
41
|
+
- **Error model:** command and environment failures (exit codes, 127 not found, missing files/dirs → 1, timeouts → 124) become a failed `Result`; exceptions from user callbacks propagate out of `run()` after cleanup; bad definitions (`tool(version="x")`, second source) raise at build time. `run(check=True)` raises `PipelineError`.
|
|
42
|
+
- **Interpolation:** after `.capture(name)`, only `{name}` for names captured so far is replaced (`CmdStep.argv`); other braces are literal.
|
|
43
|
+
- **Version probing** (`checks.detect_version`): only `--version` (or `version_args`), stdin closed, cached per (path, args) under a lock. Unknown version + constraint fails closed. Constraints are parsed at `tool()` time.
|
|
44
|
+
- **Live output** (`live.py`): `run(live=...)` builds a `Console`; the executor only calls `console.job(label)` (per run and per `each()` line), `job.source(label)` (per process, and for teed stdout), `source.write(bytes)` from the stderr drain threads, `source.done(rc)` and `job.finish(rc)`. Mode (capture/lines/grouped/blocks) and unit (job/process: with "process", `job.source()` makes a block of its own) live only in the console. One writer per stream: lines/grouped write whole lines under the console lock; in blocks mode the renderer thread is the only terminal writer and everything else updates the model under the lock. `tty=True` gives a child's stderr a raw pty (never stdout).
|
|
45
|
+
- **Output and records:** `render.py` holds the `set_output` destination (resolved once per run; `run(output=)` overrides) and a global emit lock, plus all explain/report/describe text. `manifest.py` owns the tool/file inventory used by `describe`, `manifest()`, `sbom()` (CycloneDX 1.6) and `report()`. `manifest(redact=True)` strips host details.
|
|
46
|
+
- **Side channels:** `CLICHAIN_DESCRIBE` (`1/true/yes/describe/text` or `json`) makes `run()` print and raise `DescribeExit` (a SystemExit). It is stripped from child envs. `clichain check` uses `dry_run()` (a process-wide flag, so threads the script starts are covered too) so `run()` records instead of executing, and exits 1 on blocking check failures.
|
|
47
|
+
- **Error codes** (`S<signal>` / `X<exit>`): tables live in `errors.py` and are shared by `Result.explain()` and `clichain explain`.
|