Catalog PyPI
PACKAGE EXPLORERView in registry

acquit

PyPI

Provably skip unaffected pytest tests on every PR. Static analysis, fail-closed, with evidence for every skip.

cigithub-actionspytesttest-impact-analysistest-selectiontesting
Versions collected1
Direct dependencies1From release 0.3.0
Last observed20 Oct, 18:24 UTC

README

Version 0.3.0 documentation

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

Both the CLI and the GitHub Action pin cleanly to v0.3.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.

RepoMerged PRs replayedUnsafe skipsMedian suite time skipped when selectiveSelective out of the boxSelective with a docs config
pallets/click10099.6%20.0% (2/10)80.0% (8/10)
pallets/flask410-0.0% (0/41)63.4% (26/41)
Textualize/rich64093.4%6.3% (4/64)64.1% (41/64)
encode/httpx83097.5%4.8% (4/83)53.0% (44/83)
Total1980-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.3.0 # 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

ToolMechanismNeeds code executionDeterministicExplains each skipWorks with plain pytestCost
acquitstatic import graph, fail-closed rulesnoyesyes: a witness per skip, a named rule per run-allyesfree, Apache-2.0
pytest-testmonruntime coverage databaseyesgiven the same database, yes; the database is per-machine statedependency data exists, but no proof artifact per skipyesfree
Codecov ATStestmon-based, hostedyessame caveats as testmonnoyescommercial
LaunchableML over test-result historyneeds outcome historyno, probabilistic by designno, it predictsyescommercial
Bazel / Pantsdeclared build dependency graphnoyesqueryable graph, no per-skip artifactno, requires build-system migrationfree, plus the migration
dorny/paths-filterhand-written path globsnoyesno, the globs are the reasoningyesfree

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 a nineteen-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 R019, 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

Downloads & popularity

Awaiting observations

No metric observations yet

Download counts and popularity will appear here as the collector receives them.

Versions 1

Collected releases
VersionPublishedDirect dependenciesOpen version
0.3.028 Sept 20261

Package advisories

0
No matching advisories collected

This describes our collected data; it is not a security assessment.

Repository

PENDING
rajeev-chaurasia/acquit

Shared repository data · GitHub

License detected in repository
Not collected

Metadata history

Meaningful changes
Current collected state Current

Observed since 20 Oct, 18:24 UTC

No superseded metadata states collected. Unchanged observations do not create new revisions.

Record identity

package:f815b8f04ea2a53d038f4060789c3fa5138148fb7e4e6f8e5fde4db9111a4873