Skip to main content

Acquit

CI PyPI version Python versions License

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.2), so the analysis itself pins cleanly.

Both the CLI and the GitHub Action pin cleanly to v0.1.2.

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.2 # 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=... pytest verifies 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/main walks through the decision for one test file.
  • acquit replay acquit-report.json re-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 analyze prints 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

acquit-0.1.2.tar.gz (749.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

acquit-0.1.2-py3-none-any.whl (146.5 kB view details)

Uploaded Python 3

File details

Details for the file acquit-0.1.2.tar.gz.

File metadata

  • Download URL: acquit-0.1.2.tar.gz
  • Upload date:
  • Size: 749.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for acquit-0.1.2.tar.gz
Algorithm Hash digest
SHA256 69bdd5624be96a4726abfb03fc65318090172ce31924e0df856418df0402268d
MD5 9aaea922bd8c4347d53ac419a7972425
BLAKE2b-256 6615789cff81e52cfa7680546132df65498331c97c83578ad493704c4ad976df

See more details on using hashes here.

Provenance

The following attestation bundles were made for acquit-0.1.2.tar.gz:

Publisher: release.yml on rajeev-chaurasia/acquit

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file acquit-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: acquit-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 146.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for acquit-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 5172fd10fe28de387c99e6fdac534d8f3f411a3f1852094e477c7cd6c16b505f
MD5 e2a29cdad7bb76242c166d2532f7f5ef
BLAKE2b-256 ed78c477a25d48c9393c663ddc263b23bb1fcc7b38c8badc45f8da0e56c8de49

See more details on using hashes here.

Provenance

The following attestation bundles were made for acquit-0.1.2-py3-none-any.whl:

Publisher: release.yml on rajeev-chaurasia/acquit

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page