crapkit 0.7.0__tar.gz → 0.7.2__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.
- {crapkit-0.7.0/src/crapkit.egg-info → crapkit-0.7.2}/PKG-INFO +13 -7
- {crapkit-0.7.0 → crapkit-0.7.2}/README.md +11 -5
- {crapkit-0.7.0 → crapkit-0.7.2}/pyproject.toml +3 -2
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/__init__.py +1 -1
- crapkit-0.7.2/src/crapkit/_analysis_pool.py +190 -0
- crapkit-0.7.2/src/crapkit/_mcp_stdio.py +208 -0
- crapkit-0.7.2/src/crapkit/_process_family.py +83 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/_process_owner.py +36 -11
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/analyze.py +91 -55
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cache.py +12 -2
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/admin.py +21 -7
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/analyses.py +1 -1
- crapkit-0.7.2/src/crapkit/cli/maintenance.py +39 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/parser.py +7 -1
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/scoring.py +9 -8
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/verifying.py +8 -8
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/config.py +14 -4
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/config_contract.py +16 -3
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/coverage_istanbul.py +42 -4
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/covstream.py +31 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/gitio.py +25 -11
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/hook.py +4 -3
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/lanes.py +19 -14
- crapkit-0.7.2/src/crapkit/logs.py +130 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/mcp_server.py +39 -26
- crapkit-0.7.2/src/crapkit/merge.py +35 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/mutate_pool.py +170 -95
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/procs.py +198 -25
- crapkit-0.7.2/src/crapkit/resources.py +141 -0
- crapkit-0.7.2/src/crapkit/retention.py +186 -0
- {crapkit-0.7.0 → crapkit-0.7.2/src/crapkit.egg-info}/PKG-INFO +13 -7
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit.egg-info/SOURCES.txt +7 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit.egg-info/requires.txt +1 -1
- crapkit-0.7.0/src/crapkit/merge.py +0 -19
- {crapkit-0.7.0 → crapkit-0.7.2}/LICENSE +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/setup.cfg +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/__main__.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/_pygdefer.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/_windows_job.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/churn.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/churn_cache.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/churn_log.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/__init__.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/_shared.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/claude_hook.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/queue.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/ratchet_cmds.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/reports.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/coupling.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/coupling_cache.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/coverage_py.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/diffparse.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/digest.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/doctor.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/dup.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/errors.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/gitpaths.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/invocation.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/junitparse.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/keys.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/lizardcognitive.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/lizardpowershell.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/lizardrust.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/lizardshell.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/lizardtypescript.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/locks.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/mutate.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/override.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/packet.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/ratchet.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/ratchet_report.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/ratchetfile.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/records.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/report.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/repotext.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/rootfind.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/sarif.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/sarifio.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/scaffold.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/score.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/snapshot.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/store.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/uncovered.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/universe.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/verify.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/watch.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/worklist.py +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit.egg-info/dependency_links.txt +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit.egg-info/entry_points.txt +0 -0
- {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crapkit
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more.
|
|
5
5
|
Author: Jean-Francois Gagne
|
|
6
6
|
License: MIT
|
|
@@ -30,7 +30,7 @@ Requires-Dist: lizard>=1.24.0
|
|
|
30
30
|
Provides-Extra: dev
|
|
31
31
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
32
32
|
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
33
|
-
Requires-Dist: pytest-xdist>=3; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-xdist>=3.7; extra == "dev"
|
|
34
34
|
Requires-Dist: coverage>=7.10.6; extra == "dev"
|
|
35
35
|
Requires-Dist: PyYAML>=6; extra == "dev"
|
|
36
36
|
Requires-Dist: Pillow; extra == "dev"
|
|
@@ -199,7 +199,7 @@ files. Review those commands before running Crapkit in a repository you do not t
|
|
|
199
199
|
|
|
200
200
|
```
|
|
201
201
|
$ crapkit --version
|
|
202
|
-
crapkit 0.7.
|
|
202
|
+
crapkit 0.7.2
|
|
203
203
|
```
|
|
204
204
|
|
|
205
205
|
`python -m crapkit` works identically to the console script and is what to use from a
|
|
@@ -455,7 +455,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
|
|
|
455
455
|
repos:
|
|
456
456
|
- repo: https://github.com/JeanFrancoisGagne/crapkit
|
|
457
457
|
# crapkit's release step rewrites this line to the tag it just cut
|
|
458
|
-
rev: v0.7.
|
|
458
|
+
rev: v0.7.2
|
|
459
459
|
hooks:
|
|
460
460
|
- id: crapkit-gate
|
|
461
461
|
```
|
|
@@ -563,7 +563,7 @@ to a workflow, and every input has a default:
|
|
|
563
563
|
- uses: actions/checkout@v4
|
|
564
564
|
with:
|
|
565
565
|
fetch-depth: 0
|
|
566
|
-
- uses: JeanFrancoisGagne/crapkit@v0.7.
|
|
566
|
+
- uses: JeanFrancoisGagne/crapkit@v0.7.2
|
|
567
567
|
```
|
|
568
568
|
|
|
569
569
|
The whole job those four lines sit in:
|
|
@@ -583,7 +583,7 @@ jobs:
|
|
|
583
583
|
with:
|
|
584
584
|
python-version: "3.12" # the interpreter the install below lands in
|
|
585
585
|
- run: pip install -e ".[dev]" # whatever your lanes need to run
|
|
586
|
-
- uses: JeanFrancoisGagne/crapkit@v0.7.
|
|
586
|
+
- uses: JeanFrancoisGagne/crapkit@v0.7.2
|
|
587
587
|
with:
|
|
588
588
|
gate: "false"
|
|
589
589
|
```
|
|
@@ -754,6 +754,10 @@ on PATH fails that step. Both leave the rendered text in the job log.
|
|
|
754
754
|
|
|
755
755
|
## Subcommands
|
|
756
756
|
|
|
757
|
+
`crapkit clean --dry-run --json` previews policy-based cleanup. See
|
|
758
|
+
[resource policies](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/resources.md)
|
|
759
|
+
for shared analysis workers, process lifetime, bounded logs and retained evidence.
|
|
760
|
+
|
|
757
761
|
Every subcommand takes `--repo PATH`, and the flag goes **after** the subcommand. Without
|
|
758
762
|
it the root is the nearest `crapkit.toml` at or above the current directory
|
|
759
763
|
([ADR 0002](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adr/0002-configuration-is-found-upward-nearest-wins.md)): from a
|
|
@@ -780,6 +784,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
|
|
|
780
784
|
|
|
781
785
|
| Command | What it does |
|
|
782
786
|
|---|---|
|
|
787
|
+
| `clean [--dry-run] [--json]` | Applies configured retention to recognized idle default test evidence and recovers abandoned temporary mutation worktrees. Preserves active runs, caller-managed output and intentional mutation pools. `--dry-run` reports planned removals. |
|
|
783
788
|
| `init` | Sniffs tracked source into per-directory scopes, writes a self-validated starter `crapkit.toml` whose lanes report into `.crapkit/cov/`, and appends `.crapkit/` plus each runner's own droppings to `.gitignore`. Writes a live `[[lane]]` when it can detect the test runner, otherwise a commented template. Refuses to clobber an existing config. |
|
|
784
789
|
| `doctor [--show-files] [--json] [--tune] [--plugin-root [PATH]]` | Checks the config still describes the repo: unknown keys (with the accepted spellings), zero-file scopes, tracked source no scope claims, scopes no lane covers, lane cwds and commands that no longer resolve, lizard importable, oversized files. It reads each lane command with the shell that will run it, so a quoted interpreter path is one word and a runner after `&&` is checked too, and it FAILs a lane whose runner does not resolve or that the shell cannot start, naming the word to change; a bare name is looked for on PATH and a runner spelled as a path is looked for under the directory the lane runs in, so `.venv/bin/python` answers the same from any directory you run `doctor` in; each distinct runner is probed once, not once per lane. It WARNs on a lane writing its artifact at the repo root, a `coveragepy` or `istanbul` lane with no `results_artifact` (the crashed-worker and no-new-failures checks are off for it, whichever runner the lane spells), a committed hook under `core.hooksPath` that is not executable in the index, a directory whose functions are all `untested` while its tests exist, and a scope a lane measures with no `[crapkit.scoped_tests]` template behind it, which is the loop's step 4 with nothing to run. `--tune` prints suggested parallelism knobs and writes nothing. `--plugin-root PATH` reads no repo at all: it checks an installed [plugin](https://github.com/JeanFrancoisGagne/crapkit/tree/main/plugin) against the `crapkit` on PATH (the bare name its hooks and MCP server spawn) on both version and hook `--protocol`, and FAILs when PATH carries no `crapkit` at all, one line per disagreement and silence when they agree; PATH is the plugin root or any directory above it, `~/.claude` included (only manifests named `crapkit` count, and the newest install wins), and with no PATH it looks in Claude Code's plugin cache. A root it found rather than one you typed is named first, as `crapkit doctor: checking PATH`. See [docs/agent-json.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/agent-json.md#doctor---json). |
|
|
785
790
|
| `inventory [--db PATH] [--export PATH] [--json]` | One lizard pass over every in-scope file into a SQLite snapshot run, cached by content hash. `--db` is the only way to point crapkit at a store outside `.crapkit/`, and only this command accepts it. |
|
|
@@ -1177,7 +1182,7 @@ exit 5:
|
|
|
1177
1182
|
|
|
1178
1183
|
```
|
|
1179
1184
|
$ crapkit coverage
|
|
1180
|
-
crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1);
|
|
1185
|
+
crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); lane log: /repo/.crapkit/lane-js.log; last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
|
|
1181
1186
|
|
|
1182
1187
|
MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'
|
|
1183
1188
|
|
|
@@ -1339,6 +1344,7 @@ with no debt.
|
|
|
1339
1344
|
| [docs/adoption.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adoption.md) | The judgment layer over the quickstarts: scope granularity, exclude vs lane, scoped_tests wiring, the first-verify taint hazard. |
|
|
1340
1345
|
| [docs/configuration.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md) | Every `crapkit.toml` key: type, default, and what it does. |
|
|
1341
1346
|
| [docs/lanes.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md) | The lane model, vitest and jest and pytest recipes, artifact reuse, flake retest, containers. |
|
|
1347
|
+
| [docs/resources.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/resources.md) | Worker budgets, command cleanup, log rotation, test evidence retention and safe cleanup. |
|
|
1342
1348
|
| [docs/ratchet.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md) | Seeding, pruning, the git merge driver, metric stamps, debt policy, overrides. |
|
|
1343
1349
|
| [docs/upgrading.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md) | Existing installations: analysis and key versions, saved state, plugin alignment and Windows upgrades. |
|
|
1344
1350
|
| [docs/portable-records.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/portable-records.md) | Lossless exports, portable baselines and ratchets, including filenames with delimiters. |
|
|
@@ -158,7 +158,7 @@ files. Review those commands before running Crapkit in a repository you do not t
|
|
|
158
158
|
|
|
159
159
|
```
|
|
160
160
|
$ crapkit --version
|
|
161
|
-
crapkit 0.7.
|
|
161
|
+
crapkit 0.7.2
|
|
162
162
|
```
|
|
163
163
|
|
|
164
164
|
`python -m crapkit` works identically to the console script and is what to use from a
|
|
@@ -414,7 +414,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
|
|
|
414
414
|
repos:
|
|
415
415
|
- repo: https://github.com/JeanFrancoisGagne/crapkit
|
|
416
416
|
# crapkit's release step rewrites this line to the tag it just cut
|
|
417
|
-
rev: v0.7.
|
|
417
|
+
rev: v0.7.2
|
|
418
418
|
hooks:
|
|
419
419
|
- id: crapkit-gate
|
|
420
420
|
```
|
|
@@ -522,7 +522,7 @@ to a workflow, and every input has a default:
|
|
|
522
522
|
- uses: actions/checkout@v4
|
|
523
523
|
with:
|
|
524
524
|
fetch-depth: 0
|
|
525
|
-
- uses: JeanFrancoisGagne/crapkit@v0.7.
|
|
525
|
+
- uses: JeanFrancoisGagne/crapkit@v0.7.2
|
|
526
526
|
```
|
|
527
527
|
|
|
528
528
|
The whole job those four lines sit in:
|
|
@@ -542,7 +542,7 @@ jobs:
|
|
|
542
542
|
with:
|
|
543
543
|
python-version: "3.12" # the interpreter the install below lands in
|
|
544
544
|
- run: pip install -e ".[dev]" # whatever your lanes need to run
|
|
545
|
-
- uses: JeanFrancoisGagne/crapkit@v0.7.
|
|
545
|
+
- uses: JeanFrancoisGagne/crapkit@v0.7.2
|
|
546
546
|
with:
|
|
547
547
|
gate: "false"
|
|
548
548
|
```
|
|
@@ -713,6 +713,10 @@ on PATH fails that step. Both leave the rendered text in the job log.
|
|
|
713
713
|
|
|
714
714
|
## Subcommands
|
|
715
715
|
|
|
716
|
+
`crapkit clean --dry-run --json` previews policy-based cleanup. See
|
|
717
|
+
[resource policies](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/resources.md)
|
|
718
|
+
for shared analysis workers, process lifetime, bounded logs and retained evidence.
|
|
719
|
+
|
|
716
720
|
Every subcommand takes `--repo PATH`, and the flag goes **after** the subcommand. Without
|
|
717
721
|
it the root is the nearest `crapkit.toml` at or above the current directory
|
|
718
722
|
([ADR 0002](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adr/0002-configuration-is-found-upward-nearest-wins.md)): from a
|
|
@@ -739,6 +743,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
|
|
|
739
743
|
|
|
740
744
|
| Command | What it does |
|
|
741
745
|
|---|---|
|
|
746
|
+
| `clean [--dry-run] [--json]` | Applies configured retention to recognized idle default test evidence and recovers abandoned temporary mutation worktrees. Preserves active runs, caller-managed output and intentional mutation pools. `--dry-run` reports planned removals. |
|
|
742
747
|
| `init` | Sniffs tracked source into per-directory scopes, writes a self-validated starter `crapkit.toml` whose lanes report into `.crapkit/cov/`, and appends `.crapkit/` plus each runner's own droppings to `.gitignore`. Writes a live `[[lane]]` when it can detect the test runner, otherwise a commented template. Refuses to clobber an existing config. |
|
|
743
748
|
| `doctor [--show-files] [--json] [--tune] [--plugin-root [PATH]]` | Checks the config still describes the repo: unknown keys (with the accepted spellings), zero-file scopes, tracked source no scope claims, scopes no lane covers, lane cwds and commands that no longer resolve, lizard importable, oversized files. It reads each lane command with the shell that will run it, so a quoted interpreter path is one word and a runner after `&&` is checked too, and it FAILs a lane whose runner does not resolve or that the shell cannot start, naming the word to change; a bare name is looked for on PATH and a runner spelled as a path is looked for under the directory the lane runs in, so `.venv/bin/python` answers the same from any directory you run `doctor` in; each distinct runner is probed once, not once per lane. It WARNs on a lane writing its artifact at the repo root, a `coveragepy` or `istanbul` lane with no `results_artifact` (the crashed-worker and no-new-failures checks are off for it, whichever runner the lane spells), a committed hook under `core.hooksPath` that is not executable in the index, a directory whose functions are all `untested` while its tests exist, and a scope a lane measures with no `[crapkit.scoped_tests]` template behind it, which is the loop's step 4 with nothing to run. `--tune` prints suggested parallelism knobs and writes nothing. `--plugin-root PATH` reads no repo at all: it checks an installed [plugin](https://github.com/JeanFrancoisGagne/crapkit/tree/main/plugin) against the `crapkit` on PATH (the bare name its hooks and MCP server spawn) on both version and hook `--protocol`, and FAILs when PATH carries no `crapkit` at all, one line per disagreement and silence when they agree; PATH is the plugin root or any directory above it, `~/.claude` included (only manifests named `crapkit` count, and the newest install wins), and with no PATH it looks in Claude Code's plugin cache. A root it found rather than one you typed is named first, as `crapkit doctor: checking PATH`. See [docs/agent-json.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/agent-json.md#doctor---json). |
|
|
744
749
|
| `inventory [--db PATH] [--export PATH] [--json]` | One lizard pass over every in-scope file into a SQLite snapshot run, cached by content hash. `--db` is the only way to point crapkit at a store outside `.crapkit/`, and only this command accepts it. |
|
|
@@ -1136,7 +1141,7 @@ exit 5:
|
|
|
1136
1141
|
|
|
1137
1142
|
```
|
|
1138
1143
|
$ crapkit coverage
|
|
1139
|
-
crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1);
|
|
1144
|
+
crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); lane log: /repo/.crapkit/lane-js.log; last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
|
|
1140
1145
|
|
|
1141
1146
|
MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'
|
|
1142
1147
|
|
|
@@ -1298,6 +1303,7 @@ with no debt.
|
|
|
1298
1303
|
| [docs/adoption.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adoption.md) | The judgment layer over the quickstarts: scope granularity, exclude vs lane, scoped_tests wiring, the first-verify taint hazard. |
|
|
1299
1304
|
| [docs/configuration.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md) | Every `crapkit.toml` key: type, default, and what it does. |
|
|
1300
1305
|
| [docs/lanes.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md) | The lane model, vitest and jest and pytest recipes, artifact reuse, flake retest, containers. |
|
|
1306
|
+
| [docs/resources.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/resources.md) | Worker budgets, command cleanup, log rotation, test evidence retention and safe cleanup. |
|
|
1301
1307
|
| [docs/ratchet.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md) | Seeding, pruning, the git merge driver, metric stamps, debt policy, overrides. |
|
|
1302
1308
|
| [docs/upgrading.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md) | Existing installations: analysis and key versions, saved state, plugin alignment and Windows upgrades. |
|
|
1303
1309
|
| [docs/portable-records.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/portable-records.md) | Lossless exports, portable baselines and ratchets, including filenames with delimiters. |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "crapkit"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.2"
|
|
8
8
|
description = "Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more."
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -63,7 +63,8 @@ Source = "https://github.com/JeanFrancoisGagne/crapkit"
|
|
|
63
63
|
# about 8 minutes serial against about 1m30 parallel on the same tree. Nothing
|
|
64
64
|
# here needs pytest-timeout: no test takes a `--timeout` and none carries the
|
|
65
65
|
# marker.
|
|
66
|
-
|
|
66
|
+
# xdist 3.7 supports Python 3.13 and atomic reassignment for work stealing.
|
|
67
|
+
dev = ["pytest>=8", "pytest-cov>=5", "pytest-xdist>=3.7", "coverage>=7.10.6", "PyYAML>=6", "Pillow"]
|
|
67
68
|
# coverage>=7.10.6 in both extras is the floor `[tool.coverage.run] patch` needs,
|
|
68
69
|
# and it is not decoration: coverage 7.9 answers an unrecognized `[run] patch=`
|
|
69
70
|
# with a CoverageWarning that pytest-cov 6.2.0 and later file under
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crapkit: deterministic CRAP-score framework."""
|
|
2
|
-
__version__ = "0.7.
|
|
2
|
+
__version__ = "0.7.2"
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Keep shared worker slots until every gated analysis worker has stopped."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
5
|
+
from concurrent.futures.process import BrokenProcessPool
|
|
6
|
+
from contextlib import ExitStack, contextmanager
|
|
7
|
+
import multiprocessing
|
|
8
|
+
from multiprocessing.connection import wait
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
from .errors import ToolError
|
|
14
|
+
from .locks import exclusive_lock
|
|
15
|
+
from .procs import own_processes
|
|
16
|
+
from .resources import available_slots, resource_status, worker_lock
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _lock_worker_descriptor(descriptor):
|
|
20
|
+
if os.name == "nt":
|
|
21
|
+
import msvcrt
|
|
22
|
+
msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
|
|
23
|
+
else:
|
|
24
|
+
import fcntl
|
|
25
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _claim_worker_slot(path):
|
|
29
|
+
descriptor = os.open(worker_lock(path), os.O_CREAT | os.O_RDWR, 0o600)
|
|
30
|
+
try:
|
|
31
|
+
_lock_worker_descriptor(descriptor)
|
|
32
|
+
except OSError:
|
|
33
|
+
os.close(descriptor)
|
|
34
|
+
return False
|
|
35
|
+
# No Python file object or finalizer may release this before process exit.
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _retain_worker_slot(paths):
|
|
40
|
+
for path in paths:
|
|
41
|
+
if _claim_worker_slot(path):
|
|
42
|
+
return
|
|
43
|
+
raise ToolError("no analysis worker slot remains available")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _stop_with_parent(parent):
|
|
47
|
+
wait([parent.sentinel])
|
|
48
|
+
os._exit(1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _worker_gate(registrations, paths) -> None:
|
|
52
|
+
if os.name != "nt":
|
|
53
|
+
os.setsid()
|
|
54
|
+
parent = multiprocessing.parent_process()
|
|
55
|
+
directory, names = paths
|
|
56
|
+
_retain_worker_slot(Path(directory) / name for name in names)
|
|
57
|
+
receive, send = multiprocessing.Pipe(duplex=False)
|
|
58
|
+
registrations.put((os.getpid(), send))
|
|
59
|
+
try:
|
|
60
|
+
ready = wait([receive, parent.sentinel])
|
|
61
|
+
if parent.sentinel in ready or receive.recv() != "go":
|
|
62
|
+
os._exit(1)
|
|
63
|
+
finally:
|
|
64
|
+
receive.close()
|
|
65
|
+
send.close()
|
|
66
|
+
threading.Thread(target=_stop_with_parent, args=(parent,), daemon=True).start()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _release_worker(owner, pid: int, gate, errors: list) -> None:
|
|
70
|
+
try:
|
|
71
|
+
owner.register_then(pid, lambda: gate.send("go"))
|
|
72
|
+
except Exception as error:
|
|
73
|
+
errors.append(str(error))
|
|
74
|
+
_refuse_gate(gate)
|
|
75
|
+
finally:
|
|
76
|
+
gate.close()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _refuse_gate(gate):
|
|
80
|
+
try:
|
|
81
|
+
gate.send("refused")
|
|
82
|
+
except OSError:
|
|
83
|
+
pass # The worker can already have exited after its caller died.
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _register_workers(owner, registrations, errors: list) -> None:
|
|
87
|
+
for entry in iter(registrations.get, None):
|
|
88
|
+
_release_worker(owner, *entry, errors)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _OwnedPool:
|
|
92
|
+
def __init__(self, owner, paths):
|
|
93
|
+
context = multiprocessing.get_context()
|
|
94
|
+
self.start_before_submit = context.get_start_method() != "fork"
|
|
95
|
+
self.registrations = context.Queue()
|
|
96
|
+
# Windows spawn writes this descriptor through a bounded bootstrap pipe.
|
|
97
|
+
packed = (str(Path(paths[0]).parent), tuple(Path(path).name for path in paths))
|
|
98
|
+
self.executor = ProcessPoolExecutor(max_workers=len(paths), mp_context=context,
|
|
99
|
+
initializer=_worker_gate,
|
|
100
|
+
initargs=(self.registrations, packed))
|
|
101
|
+
self.errors = []
|
|
102
|
+
self.registrar = threading.Thread(target=_register_workers,
|
|
103
|
+
args=(owner, self.registrations, self.errors), daemon=True)
|
|
104
|
+
self.started = False
|
|
105
|
+
|
|
106
|
+
def map(self, function, jobs, *, chunksize=1):
|
|
107
|
+
# Submit starts all fork workers before adding our registration thread.
|
|
108
|
+
if self.start_before_submit:
|
|
109
|
+
self._start_registrar()
|
|
110
|
+
try:
|
|
111
|
+
results = self.executor.map(function, jobs, chunksize=chunksize)
|
|
112
|
+
finally:
|
|
113
|
+
self._start_registrar()
|
|
114
|
+
try:
|
|
115
|
+
yield from results
|
|
116
|
+
except BrokenProcessPool as error:
|
|
117
|
+
if self.errors:
|
|
118
|
+
raise ToolError(self.errors[0]) from error
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
def _start_registrar(self):
|
|
122
|
+
if not self.started:
|
|
123
|
+
self.registrar.start()
|
|
124
|
+
self.started = True
|
|
125
|
+
|
|
126
|
+
def close(self):
|
|
127
|
+
self.executor.shutdown(wait=True, cancel_futures=True)
|
|
128
|
+
if self.started:
|
|
129
|
+
self.registrations.put(None)
|
|
130
|
+
self.registrar.join()
|
|
131
|
+
self.registrations.close()
|
|
132
|
+
self.registrations.join_thread()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@contextmanager
|
|
136
|
+
def _pool_owner(paths):
|
|
137
|
+
with ExitStack() as stack:
|
|
138
|
+
owned = paths
|
|
139
|
+
if os.name == "nt":
|
|
140
|
+
if not _claim_primary_slots(stack, paths):
|
|
141
|
+
yield None
|
|
142
|
+
return
|
|
143
|
+
owned = ()
|
|
144
|
+
owner = stack.enter_context(own_processes(owned, optional=True, label="analysis workers"))
|
|
145
|
+
yield owner if owner.held else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _claim_primary_slots(stack, paths):
|
|
149
|
+
try:
|
|
150
|
+
for path in paths:
|
|
151
|
+
stack.enter_context(exclusive_lock(path, label="analysis workers"))
|
|
152
|
+
except (OSError, ToolError):
|
|
153
|
+
return False
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@contextmanager
|
|
158
|
+
def _held_pool(paths):
|
|
159
|
+
with _pool_owner(paths) as owner:
|
|
160
|
+
if owner is None:
|
|
161
|
+
yield None
|
|
162
|
+
return
|
|
163
|
+
pool = _OwnedPool(owner, paths)
|
|
164
|
+
try:
|
|
165
|
+
yield pool
|
|
166
|
+
except BaseException:
|
|
167
|
+
owner.cancel()
|
|
168
|
+
raise
|
|
169
|
+
finally:
|
|
170
|
+
pool.close()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@contextmanager
|
|
174
|
+
def analysis_pool(*, workers: int | None = None, worker_budget: int = 0):
|
|
175
|
+
"""Yield an owned pool, or None for cheap serial work or busy shared slots.
|
|
176
|
+
|
|
177
|
+
Probing does not reserve capacity. The caller on Windows, or a guardian on
|
|
178
|
+
POSIX, claims primary slots before workers start. Actual workers retain
|
|
179
|
+
companion slots through process exit. A competing claim falls back.
|
|
180
|
+
"""
|
|
181
|
+
status = resource_status(analysis_workers=workers or 0, worker_budget=worker_budget)
|
|
182
|
+
if status["pool_worker_limit"] == 1:
|
|
183
|
+
yield None
|
|
184
|
+
return
|
|
185
|
+
paths = available_slots(status)
|
|
186
|
+
if len(paths) < 2:
|
|
187
|
+
yield None
|
|
188
|
+
return
|
|
189
|
+
with _held_pool(paths) as pool:
|
|
190
|
+
yield pool
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""A bounded stdio session with one owned tool request at a time."""
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
3
|
+
from contextlib import ExitStack
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
from .procs import CommandCancelled, own_processes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _OutputClosed(Exception):
|
|
14
|
+
"""The client stopped accepting protocol output."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _lines(source):
|
|
18
|
+
try:
|
|
19
|
+
descriptor = source.fileno()
|
|
20
|
+
except (AttributeError, OSError):
|
|
21
|
+
yield from iter(source.readline, '')
|
|
22
|
+
return
|
|
23
|
+
pending = b''
|
|
24
|
+
while chunk := os.read(descriptor, 65536):
|
|
25
|
+
pieces = (pending + chunk).split(b'\n')
|
|
26
|
+
pending = pieces.pop()
|
|
27
|
+
yield from (piece.decode('utf-8') for piece in pieces)
|
|
28
|
+
if pending:
|
|
29
|
+
yield pending.decode('utf-8')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read(source, events, slots):
|
|
33
|
+
try:
|
|
34
|
+
for line in _lines(source):
|
|
35
|
+
slots.acquire()
|
|
36
|
+
events.put(('input', line))
|
|
37
|
+
except (OSError, ValueError):
|
|
38
|
+
pass
|
|
39
|
+
finally:
|
|
40
|
+
events.put(('eof', None))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Session:
|
|
44
|
+
"""Keep protocol input responsive while a single CLI child is running."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, source, target, reply, run_cli):
|
|
47
|
+
self.source, self.target = source, target
|
|
48
|
+
self.reply, self.run_cli = reply, run_cli
|
|
49
|
+
self.events = queue.SimpleQueue()
|
|
50
|
+
self.input_slots = threading.Semaphore(32)
|
|
51
|
+
self.stack = ExitStack()
|
|
52
|
+
self.owner_context = None
|
|
53
|
+
self.lock = threading.Lock()
|
|
54
|
+
self.owner = self.pool = self.active = None
|
|
55
|
+
self.request_id = None
|
|
56
|
+
|
|
57
|
+
def run(self):
|
|
58
|
+
threading.Thread(target=_read, args=(self.source, self.events, self.input_slots),
|
|
59
|
+
daemon=True).start()
|
|
60
|
+
try:
|
|
61
|
+
while self._event(*self.events.get()):
|
|
62
|
+
pass
|
|
63
|
+
finally:
|
|
64
|
+
self._cleanup()
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
def _cleanup(self):
|
|
68
|
+
failure = sys.exception()
|
|
69
|
+
for cleanup in (self._cancel_for_cleanup, self.stack.close, self._retire_owner):
|
|
70
|
+
try:
|
|
71
|
+
cleanup()
|
|
72
|
+
except BaseException as error:
|
|
73
|
+
failure = failure or error
|
|
74
|
+
if failure is not None:
|
|
75
|
+
raise failure
|
|
76
|
+
|
|
77
|
+
def _cancel_for_cleanup(self):
|
|
78
|
+
try:
|
|
79
|
+
self._cancel()
|
|
80
|
+
except BaseException as error:
|
|
81
|
+
try:
|
|
82
|
+
self._retire_owner()
|
|
83
|
+
finally:
|
|
84
|
+
raise error
|
|
85
|
+
|
|
86
|
+
def _event(self, kind, value):
|
|
87
|
+
if kind == 'eof':
|
|
88
|
+
return False
|
|
89
|
+
if kind == 'done':
|
|
90
|
+
self._done(value)
|
|
91
|
+
else:
|
|
92
|
+
self.input_slots.release()
|
|
93
|
+
self._input(value)
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
def _input(self, line):
|
|
97
|
+
try:
|
|
98
|
+
message = json.loads(line)
|
|
99
|
+
except ValueError:
|
|
100
|
+
return
|
|
101
|
+
if not isinstance(message, dict):
|
|
102
|
+
return
|
|
103
|
+
if message.get('method') == 'notifications/cancelled' and 'id' not in message:
|
|
104
|
+
self._notification(message)
|
|
105
|
+
return
|
|
106
|
+
self.request_id = message.get('id')
|
|
107
|
+
response = self.reply(message, self._submit)
|
|
108
|
+
if self._immediate(response):
|
|
109
|
+
self._write(response)
|
|
110
|
+
|
|
111
|
+
def _immediate(self, response):
|
|
112
|
+
if response is None:
|
|
113
|
+
return False
|
|
114
|
+
return self.active is None or response.get('result') is not self.active
|
|
115
|
+
|
|
116
|
+
def _notification(self, message):
|
|
117
|
+
params = message.get('params') or {}
|
|
118
|
+
if not isinstance(params, dict):
|
|
119
|
+
return
|
|
120
|
+
if self._matches(params.get('requestId')):
|
|
121
|
+
self._cancel()
|
|
122
|
+
|
|
123
|
+
def _matches(self, request_id):
|
|
124
|
+
if self.active is None:
|
|
125
|
+
return False
|
|
126
|
+
current = self.active['id']
|
|
127
|
+
return type(request_id) is type(current) and request_id == current
|
|
128
|
+
|
|
129
|
+
def _submit(self, tool, arguments, repo):
|
|
130
|
+
if self.active is not None:
|
|
131
|
+
return {'content': [{'type': 'text', 'text': 'another tool is running; retry after it finishes'}],
|
|
132
|
+
'isError': True}
|
|
133
|
+
if self.pool is None:
|
|
134
|
+
self.pool = self.stack.enter_context(ThreadPoolExecutor(max_workers=1))
|
|
135
|
+
request = {'id': self.request_id, 'cancelled': False}
|
|
136
|
+
self.active = request
|
|
137
|
+
future = self.pool.submit(self._execute, request, tool, arguments, repo)
|
|
138
|
+
request['future'] = future
|
|
139
|
+
future.add_done_callback(lambda done: self.events.put(('done', request)))
|
|
140
|
+
return request
|
|
141
|
+
|
|
142
|
+
def _execute(self, request, tool, arguments, repo):
|
|
143
|
+
with self.lock:
|
|
144
|
+
if request['cancelled']:
|
|
145
|
+
raise CommandCancelled('request was cancelled before it started')
|
|
146
|
+
if self.owner is None:
|
|
147
|
+
context = own_processes(())
|
|
148
|
+
self.owner = context.__enter__()
|
|
149
|
+
self.owner_context = context
|
|
150
|
+
if request['cancelled']:
|
|
151
|
+
raise CommandCancelled('request was cancelled during owner startup')
|
|
152
|
+
owner = self.owner
|
|
153
|
+
return self.run_cli(tool, arguments, repo, owner=owner)
|
|
154
|
+
|
|
155
|
+
def _cancel(self):
|
|
156
|
+
request = self.active
|
|
157
|
+
if request is None:
|
|
158
|
+
return
|
|
159
|
+
request['cancelled'] = True
|
|
160
|
+
with self.lock:
|
|
161
|
+
if self.owner is not None:
|
|
162
|
+
self.owner.cancel()
|
|
163
|
+
|
|
164
|
+
def _done(self, request):
|
|
165
|
+
self.active = None
|
|
166
|
+
if request['cancelled']:
|
|
167
|
+
self._retire_owner()
|
|
168
|
+
return
|
|
169
|
+
self._write(self._response(request))
|
|
170
|
+
|
|
171
|
+
def _retire_owner(self):
|
|
172
|
+
if self.owner_context is not None:
|
|
173
|
+
self.owner_context.__exit__(None, None, None)
|
|
174
|
+
self.owner_context = None
|
|
175
|
+
self.owner = None
|
|
176
|
+
|
|
177
|
+
def _response(self, request):
|
|
178
|
+
response = {'jsonrpc': '2.0', 'id': request['id']}
|
|
179
|
+
try:
|
|
180
|
+
response['result'] = request['future'].result()
|
|
181
|
+
except Exception as error:
|
|
182
|
+
response['error'] = {'code': -32603, 'message': f'{type(error).__name__}: {error}'}
|
|
183
|
+
return response
|
|
184
|
+
|
|
185
|
+
def _write(self, response):
|
|
186
|
+
try:
|
|
187
|
+
self.target.write(json.dumps(response) + '\n')
|
|
188
|
+
self.target.flush()
|
|
189
|
+
except OSError as error:
|
|
190
|
+
_silence_output(self.target)
|
|
191
|
+
raise _OutputClosed from error
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _silence_output(target):
|
|
195
|
+
# Prevent the interpreter's final stdout flush from repeating a broken pipe.
|
|
196
|
+
try:
|
|
197
|
+
with open(os.devnull, 'w') as sink:
|
|
198
|
+
os.dup2(sink.fileno(), target.fileno())
|
|
199
|
+
except (AttributeError, OSError):
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def serve(source, target, reply, run_cli):
|
|
204
|
+
try:
|
|
205
|
+
return Session(source, target, reply, run_cli).run()
|
|
206
|
+
except _OutputClosed:
|
|
207
|
+
# A client can close its read pipe independently of stdin.
|
|
208
|
+
return 0
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""POSIX nested guardians retain ancestor completion until their writers stop.
|
|
2
|
+
|
|
3
|
+
Command groups cannot kill a nested guardian before it finishes cleanup. Each
|
|
4
|
+
guardian runs in a separate session and holds a lease in every ancestor family.
|
|
5
|
+
Closing admission before killing a group makes late guardian starts refuse.
|
|
6
|
+
"""
|
|
7
|
+
from contextlib import ExitStack, contextmanager
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import shutil
|
|
12
|
+
import tempfile
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from .errors import ToolError
|
|
16
|
+
|
|
17
|
+
_ENV = 'CRAPKIT_COMMAND_FAMILIES'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def command_environment(environment):
|
|
21
|
+
"""Name a family before spawn; only successful registration creates it."""
|
|
22
|
+
family = str(Path(tempfile.gettempdir()) / ('crapkit-family-' + uuid.uuid4().hex))
|
|
23
|
+
parents = json.loads(os.environ.get(_ENV, '[]'))
|
|
24
|
+
return family, {**(os.environ if environment is None else environment),
|
|
25
|
+
_ENV: json.dumps([*parents, family])}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def _locked(path, mode='r+b'):
|
|
30
|
+
import fcntl
|
|
31
|
+
with path.open(mode) as stream:
|
|
32
|
+
fcntl.flock(stream, fcntl.LOCK_EX)
|
|
33
|
+
yield stream
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _register(path, stack):
|
|
37
|
+
with _locked(path / 'admission'):
|
|
38
|
+
if (path / 'closed').exists():
|
|
39
|
+
raise ToolError('ancestor command has stopped; no new work can start')
|
|
40
|
+
lease = path / (uuid.uuid4().hex + '.lease')
|
|
41
|
+
stack.enter_context(_locked(lease, 'x+b'))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@contextmanager
|
|
45
|
+
def ancestor_leases():
|
|
46
|
+
"""Keep all ancestor commands incomplete until this guardian exits."""
|
|
47
|
+
with ExitStack() as stack:
|
|
48
|
+
try:
|
|
49
|
+
for path in json.loads(os.environ.get(_ENV, '[]')):
|
|
50
|
+
_register(Path(path), stack)
|
|
51
|
+
except OSError as error:
|
|
52
|
+
raise ToolError('ancestor command has stopped; cannot retain ownership') from error
|
|
53
|
+
yield
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Family:
|
|
57
|
+
def __init__(self, path):
|
|
58
|
+
self.path = Path(path)
|
|
59
|
+
self.path.mkdir(mode=0o700)
|
|
60
|
+
try:
|
|
61
|
+
(self.path / 'admission').touch()
|
|
62
|
+
except OSError:
|
|
63
|
+
self.path.rmdir()
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
def close_admission(self):
|
|
67
|
+
with _locked(self.path / 'admission'):
|
|
68
|
+
(self.path / 'closed').touch()
|
|
69
|
+
return list(self.path.glob('*.lease'))
|
|
70
|
+
|
|
71
|
+
def finish(self, leases):
|
|
72
|
+
for lease in leases:
|
|
73
|
+
with _locked(lease):
|
|
74
|
+
pass
|
|
75
|
+
shutil.rmtree(self.path)
|
|
76
|
+
|
|
77
|
+
@contextmanager
|
|
78
|
+
def stopping(self):
|
|
79
|
+
leases = self.close_admission()
|
|
80
|
+
try:
|
|
81
|
+
yield
|
|
82
|
+
finally:
|
|
83
|
+
self.finish(leases)
|