Acquit
Provably skip unaffected pytest tests on every pull request. Static analysis, fail-closed, with evidence for every skip.
Status: alpha. The engine works: on real pull requests acquit makes selective decisions, and every skip ships with a machine-checkable witness that is re-verified by replay before pytest honors it. Every failure mode still converges on "run everything". The CLI is on PyPI (
pip install acquit==0.1.1), so the analysis itself pins cleanly. Both the CLI and the GitHub Action pin cleanly to v0.1.0. Re-export narrowing for fat package inits exists and ships disabled: it survived an adversarial break-and-fix cycle before anyone relied on it, and its measured applicability where it engages is 6.7 percent of submodules (docs/study.md has the full story).
What it does
Acquit builds a dependency graph of your repository from import statements alone (it never executes your code), then works out which test files a pull request cannot possibly affect. Those tests are skipped, and every skip comes with a machine-checkable witness: the test's import closure and proof that it does not intersect the changed files.
Anything Acquit cannot reason about (dynamic imports, changed conftest files, dependency bumps, data files, and a documented list of other triggers) makes it fall back to running the full suite. It can only be wrong in the safe direction.
Measured on real history
Acquit replayed 198 merged PRs from four real repositories, running each full suite at the base and head commits and checking every skip against the observed outcomes.
| Repo | Merged PRs replayed | Unsafe skips | Median suite time skipped when selective | Selective out of the box | Selective with a docs config |
|---|---|---|---|---|---|
| pallets/click | 10 | 0 | 99.6% | 20.0% (2/10) | 80.0% (8/10) |
| pallets/flask | 41 | 0 | - | 0.0% (0/41) | 63.4% (26/41) |
| Textualize/rich | 64 | 0 | 93.4% | 6.3% (4/64) | 64.1% (41/64) |
| encode/httpx | 83 | 0 | 97.5% | 4.8% (4/83) | 53.0% (44/83) |
| Total | 198 | 0 | - | 5.1% (10/198) | 60.1% (119/198) |
The docs-config column is computed from the recorded findings for flask, rich, and httpx; click's docs-config counterfactual was re-replayed for real, with every additional selective decision verified (see docs/study.md).
The out-of-the-box share is low because acquit is fail-closed: any PR it cannot fully prove safe runs the whole suite. Docs and changelog churn (rule R001) is by far the dominant blocker; the docs-config column counts PRs where it was the only one, and the sticky PR comment suggests the exact assume_inert entry to lift it. Every number above regenerates from committed manifests via acquit-study; see study/README.md. The full writeup, including the parts that went wrong, is in docs/study.md.
Try it on a PR
One complete workflow file, ready to paste:
name: tests
on: pull_request
permissions:
contents: read
pull-requests: write
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# The base ref must exist locally; shallow clones always fall back to run-all.
fetch-depth: 0
- uses: rajeev-chaurasia/acquit@v0.1.1 # x-release-please-version
with:
mode: report
- uses: actions/setup-python@v7
with:
python-version: "3.12"
# The pytest plugin ships in the acquit package. Enforce mode silently
# changes nothing unless acquit is importable in the environment that
# runs pytest.
- run: pip install acquit
- run: pip install -e . pytest
- run: pytest
Report mode never skips a test: the action analyzes the diff, verifies the evidence with a replay, posts a sticky PR comment explaining the decision, and sets outputs. mode: canary also runs everything, but verifies the selection against reality: every would-be-skipped test still executes, a failure among them raises a loud alarm, and it is the recommended step between report and enforce. Switch to mode: enforce once you trust what the comments say, and the pytest plugin will skip the provably unaffected files; any unrecognized mode value means report. The pip install acquit line (or its uv equivalent, uv pip install acquit) is what puts the plugin into the environment that runs pytest.
Run it locally
The whole loop runs without CI:
pip install acquit # or: uv pip install acquit
acquit select --base origin/main
ACQUIT_SELECTION_FILE=acquit-selection.json pytest
acquit select --base origin/main compares the working tree against the base ref and writes three documents: the report (acquit-report.json), the selection (acquit-selection.json), and the witnesses (acquit-witnesses.json). The defaults land in the current directory and work as they are, because the selection records its own outputs and they never invalidate the tree fingerprint; pointing --report, --selection, and --witnesses at an out-of-tree or gitignored directory keeps the checkout tidier.
From there:
ACQUIT_SELECTION_FILE=... pytestverifies the selection against the tree and deselects the provably unaffected files; without the variable, pytest is untouched.acquit explain tests/test_something.py --base origin/mainwalks through the decision for one test file.acquit replay acquit-report.jsonre-verifies every witness from first principles. Replay rebuilds the analyzed commit, and a working-tree report records no head sha, so pass--head <commit>at select time if you want the run to be replayable.acquit analyzeprints the dependency graph's health.
How it compares
| Tool | Mechanism | Needs code execution | Deterministic | Explains each skip | Works with plain pytest | Cost |
|---|---|---|---|---|---|---|
| acquit | static import graph, fail-closed rules | no | yes | yes: a witness per skip, a named rule per run-all | yes | free, Apache-2.0 |
| pytest-testmon | runtime coverage database | yes | given the same database, yes; the database is per-machine state | dependency data exists, but no proof artifact per skip | yes | free |
| Codecov ATS | testmon-based, hosted | yes | same caveats as testmon | no | yes | commercial |
| Launchable | ML over test-result history | needs outcome history | no, probabilistic by design | no, it predicts | yes | commercial |
| Bazel / Pants | declared build dependency graph | no | yes | queryable graph, no per-skip artifact | no, requires build-system migration | free, plus the migration |
| dorny/paths-filter | hand-written path globs | no | yes | no, the globs are the reasoning | yes | free |
Acquit occupies one niche: fail-closed static selection with a proof per skip, on an unmodified pytest project. If you want function-level precision and are willing to maintain runtime coverage state, pytest-testmon is the honest alternative; it is precise where acquit is conservative, and stateful where acquit is a pure function of the diff.
Design principles
- Never execute user code during analysis
- Fail closed: every error path converges on "run all tests"
- Deterministic: same tree and diff produce byte-identical reports
- Explainable: every decision names the rule or the graph path behind it
FAQ
Static analysis cannot be sound in Python. It cannot bound everything, and acquit does not try. Anything outside what static imports can bound (non-literal importlib, exec, sys.path mutation, changed conftests, dependency bumps, unparseable files, and the rest of an eighteen-rule table) fails closed to running more tests, up to everything. Soundness comes from the direction of the failure, not from the reach of the analysis. Measured: 198 replayed PRs, zero unsafe skips, every skip re-verified by acquit replay from first principles.
Why file-level selection instead of test-level? Module-level imports are the unit static analysis can bound honestly. Function-level selection would require bounding fixture resolution and parametrization behavior, and a bound I cannot defend is a skip I will not make. Function-level granularity is on the research list, not in the product.
What about fixtures and conftest magic? Conftest scoping is modeled directly: a changed non-root conftest forces its whole subtree to run, a changed root conftest or pytest config forces everything, collection-altering hooks and unresolvable pytest_plugins entries fail closed globally. Fixtures defined in a conftest ride on those edges; fixture changes inside a test's own import closure are ordinary graph reachability.
My PRs all touch the changelog, so this will never skip anything. That is rule R001 doing its job until you say otherwise. An assume_inert list under [tool.acquit] vouches that named docs or data files feed no test, and the sticky PR comment suggests the exact entry when docs files are the only blocker. The proof obligation for those files becomes yours; treat .acquit.toml diffs like CI config in review.
Documentation
- CLI reference: every subcommand, flag, document schema, exit code, and the action's inputs and outputs
- Rule reference: R001 to R018, with triggers, scopes, and the exact reason texts
- Soundness contract: what "provably unaffected" means, its assumptions, and its limits
- Architecture decision records: fail-closed policy, static analysis, rustworkx, the action, the study, granularity, tree binding, and the precision work (re-export narrowing, constant folding, derived registries)
- The replay study: 198 replayed PRs, and how to re-run it
- Contributing: dev setup, the CI gate, and the bar for soundness-critical changes
- Security policy: what counts as a vulnerability here, and how to report one
License
Apache-2.0
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file acquit-0.1.1.tar.gz.
File metadata
- Download URL: acquit-0.1.1.tar.gz
- Upload date:
- Size: 746.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9b9cb8d28ea08ff59497d54273a9ce27fc7c612d849a8a6fbb4807f9d314f673
|
|
| MD5 |
e62abc95302b5b6145805c8eaf41d27e
|
|
| BLAKE2b-256 |
4659b2015eb9d6f20a7a66a059693b322635488bdb7a25b60cecf09fe9346a49
|
Provenance
The following attestation bundles were made for acquit-0.1.1.tar.gz:
Publisher:
release.yml on rajeev-chaurasia/acquit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
acquit-0.1.1.tar.gz -
Subject digest:
9b9cb8d28ea08ff59497d54273a9ce27fc7c612d849a8a6fbb4807f9d314f673 - Sigstore transparency entry: 2401684419
- Sigstore integration time:
-
Permalink:
rajeev-chaurasia/acquit@4214cd115819dd3d389149f49a0d5e83b22cdc7e -
Branch / Tag:
refs/heads/main - Owner: https://github.com/rajeev-chaurasia
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4214cd115819dd3d389149f49a0d5e83b22cdc7e -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file acquit-0.1.1-py3-none-any.whl.
File metadata
- Download URL: acquit-0.1.1-py3-none-any.whl
- Upload date:
- Size: 145.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
46e82990e2f911a8489b35203f787094d24ee71ae338846523a90ef14d719403
|
|
| MD5 |
795ceca1b7fafc76db20659db9c1f8b8
|
|
| BLAKE2b-256 |
0c36b220718951f6659cb9c7353bd00f251eb460ad5eba5f347c2d1832e6f564
|
Provenance
The following attestation bundles were made for acquit-0.1.1-py3-none-any.whl:
Publisher:
release.yml on rajeev-chaurasia/acquit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
acquit-0.1.1-py3-none-any.whl -
Subject digest:
46e82990e2f911a8489b35203f787094d24ee71ae338846523a90ef14d719403 - Sigstore transparency entry: 2401684758
- Sigstore integration time:
-
Permalink:
rajeev-chaurasia/acquit@4214cd115819dd3d389149f49a0d5e83b22cdc7e -
Branch / Tag:
refs/heads/main - Owner: https://github.com/rajeev-chaurasia
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4214cd115819dd3d389149f49a0d5e83b22cdc7e -
Trigger Event:
workflow_dispatch
-
Statement type: