pytest-ratchet
Debt is allowed. Lying about it is not.
A pytest plugin for baselines that cannot lie.
Most baseline tools are one-way: known debt is frozen, new violations fail CI,
and when debt gets fixed nobody notices — the baseline file quietly starts
lying. pytest-ratchet enforces the other direction too: every baseline entry
carries a reason field that must be filled in — TODO is a legal value, but
it is counted and reported on every run until someone replaces it — and an
entry that no longer matches a real finding fails CI until it is removed.
Status: v0.1.0. The core primitive, ratchet init, and the resolver
protocol are complete and tested. It is committed in one production
repository and green on developer machines, beside the hand-written guard it
is meant to replace — but skipped in that repository's CI until the
dependency can be pinned, which is the adoption trap described under Known
limits below. Not yet on PyPI.
Quickstart
Not on PyPI yet — install from the repository:
pip install "pytest-ratchet[vulture] @ git+https://github.com/YucelOzcan/pytest-ratchet"
ratchet init src/ # run vulture, seed ratchet-baseline.toml (reason = "TODO"),
# scaffold test_ratchet.py
pytest # from then on: new finding = red, stale baseline entry = red
Run ratchet init from the directory pytest treats as its rootdir — that is
where the plugin looks for the baseline (init prints the path it wrote and
reminds you). In a monorepo, either run pytest from that directory or point
it at the file with ratchet_baseline in your pytest config.
Every seeded entry starts as reason = "TODO"; each run reports how many
TODOs remain and how old the oldest is. Replace them with real reasons at
your own pace — or set ratchet_strict_todo = true in pytest config to
force the issue.
- On an existing project: your debt becomes visible, justified, and frozen — it can shrink, it cannot silently grow, and the list never goes stale.
- On a greenfield project: the baseline starts empty and the tool keeps the cost of leaving zero visible — debt can only enter with a name and a reason.
This tool does not fix your code quality. It does one thing: it keeps your baseline honest.
Proving the gate (read this before you trust it)
A check you have never watched fail is a check you do not have. So after wiring a guard, prove it: introduce a violation on purpose, watch CI go red, then remove it.
The probe itself has a trap, and it is the same trap this tool exists to
fight. Scanners reason about your whole corpus, so an obvious probe can be
silently absorbed. With vulture, adding import json to a file proves
nothing: json is used elsewhere in the project, so vulture never reports
it, the guard stays green, and you walk away believing you proved something.
Use a name that cannot occur anywhere else:
import json as _zzz_ratchet_probe # unique name — vulture will report this
Then prove the other direction too, which most people forget: add a fake entry to the baseline for a finding that does not exist and confirm the run fails with STALE. A ratchet that only bites in one direction is half a ratchet, and the half that rots quietly is the one you didn't test.
Known limits
Stated plainly, because a tool about honest records should keep an honest one about itself.
- No numeric budgets. Entries are matched as a set: a finding is present or absent. Baselines where each entry carries an allowed number — "this function may be up to 208 lines", "at most 16 exceptions may exist" — are not modeled, because exceeding a budget is neither a new finding nor a stale one. If your debt is measured rather than enumerated, this tool cannot hold it yet.
- Reasons live apart from the check. The baseline is a separate file, so a reviewer reading the guard's thresholds does not see the justifications on the same screen. That separation is what makes the reasons survive regeneration, but it is a real trade-off, not a free win.
- Adoption can force the very skip the tool forbids. A guard fails rather
than skips when its scanner is missing — but while you are still evaluating
pytest-ratchet, before it is a pinned dependency, wrapping the guard in
pytest.importorskipis the practical move, and that guard then passes silently in CI. If you must do it, make it deliberate and dated, and delete it the moment the dependency is pinned. - No
tagon entries. Teams often want to distinguish permanent accepted patterns from temporary debt. Today the only machine-readable distinction isreason = "TODO"versus a written reason.
Prior art
Baseline tools exist; most freeze debt in one direction only, and none of the surveyed ones require a written reason per entry. Full survey with sources: docs/prior-art.md (2026-08-11).
| Tool | Baseline file | New finding fails | Stale entry fails | Per-entry reason | Scanner-agnostic | pytest-native |
|---|---|---|---|---|---|---|
| betterer (JS) | machine-written snapshot | yes | CI mode, any snapshot diff | no | yes | no |
semgrep --baseline-commit |
no (diffs vs git commit) | yes | n/a — nothing stored | no | semgrep-only | no |
| ruff + noqa | no native baseline | yes | RUF100, inline noqa only | global string only | ruff-only | no |
| mypy-baseline | yes | yes | yes, by default | no | mypy-only | no |
| pylint (+pylint-silent) | no (inline disables) | yes | I0021, off by default | no | pylint-only | no |
| import-linter | allowlist in config | yes | yes, by default | no | imports-only | no |
| pytest-archon | no baseline at all | yes | n/a | rule-level comment | imports-only | yes |
| deptry | ignores in config | yes | no | no | deps-only | no |
| vulture whitelist | fake-usage code | yes | manual, optional | no | vulture-only | no |
| pytest-ratchet | human-owned, justified | yes | yes, always | required field | yes | yes |
Credit where due: bidirectional enforcement is not novel. PHPStan (including count decay), Psalm, mypy-baseline, and ESLint's bulk suppressions all fail by default when a listed finding no longer exists, and import-linter does the same for import allowlists — each within a single domain, none with justifications. betterer's CI mode rejects any drift from its snapshot, but the snapshot is machine-regenerated, not human-edited. SonarQube has first-class per-finding justification comments — optional, stored server-side, silently discarded when the issue closes. The per-entry reason is missing from existing baselines by explicit doctrine, not oversight: PHPStan's docs call the "why" comment "often crucial" and "not an option when using the baseline" — the survey's rebuttal section addresses this directly. The layer pytest-ratchet adds is the combination: a required per-entry reason field in a committed, human-owned baseline + bidirectional enforcement + arbitrary scanners + pytest. Nothing more is claimed.
The survey also covers PHPStan, Psalm, RuboCop's todo file, ESLint bulk suppressions, and SonarQube in full — see docs/prior-art.md for their rows and sources.
Scanners like vulture, deptry, and knip are not competitors here — they are producers. The scanner finds; the ratchet keeps what it found justified, frozen, and honest.
Roadmap
- Prior-art survey (betterer, semgrep/ruff/mypy baselines, import-linter, pytest-archon, deptry) — differences stated explicitly before any claim: docs/prior-art.md
- Core primitive: justified allowlist + new-violation check + staleness
check —
pytest_ratchet.core+ theratchetfixture, with tests and CI -
ratchet initscaffolding (first integration: vulture) — append-only seeding, guard-test scaffold, self-checked output - Resolver protocol + reachability guard recipes with a runnable example
project under
examples/webapp— candidates minus reachable, managed by the same baseline - Own CI (tests + the example project, Python 3.11–3.13)
- Docs, license, PyPI release
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 pytest_ratchet-0.1.0.tar.gz.
File metadata
- Download URL: pytest_ratchet-0.1.0.tar.gz
- Upload date:
- Size: 40.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1147db1ddf8c6ab3b9baf5c4a84ff7c5cde001013d0d365309d74138bc471042
|
|
| MD5 |
df60f2212bb91d4b14c1acd7576c6299
|
|
| BLAKE2b-256 |
f5bcd830917f847556adf0a5570098e30b0a62a0243082cee0c9b7644d6ed522
|
Provenance
The following attestation bundles were made for pytest_ratchet-0.1.0.tar.gz:
Publisher:
release.yml on YucelOzcan/pytest-ratchet
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_ratchet-0.1.0.tar.gz -
Subject digest:
1147db1ddf8c6ab3b9baf5c4a84ff7c5cde001013d0d365309d74138bc471042 - Sigstore transparency entry: 2436358398
- Sigstore integration time:
-
Permalink:
YucelOzcan/pytest-ratchet@1498e906940c2fb8e1ae8b91176ef63152e13f18 -
Branch / Tag:
refs/heads/master - Owner: https://github.com/YucelOzcan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@1498e906940c2fb8e1ae8b91176ef63152e13f18 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file pytest_ratchet-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pytest_ratchet-0.1.0-py3-none-any.whl
- Upload date:
- Size: 16.8 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 |
4bd1ac364e13a3dbc6b23bf2340099bf6ee45f608eda9f6e435dc3113d032339
|
|
| MD5 |
0856d339a9721d287b62065a20375597
|
|
| BLAKE2b-256 |
732f91f5693595eed82a487dca49809ba4eaa3f035b676e3de00686c3500c90e
|
Provenance
The following attestation bundles were made for pytest_ratchet-0.1.0-py3-none-any.whl:
Publisher:
release.yml on YucelOzcan/pytest-ratchet
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_ratchet-0.1.0-py3-none-any.whl -
Subject digest:
4bd1ac364e13a3dbc6b23bf2340099bf6ee45f608eda9f6e435dc3113d032339 - Sigstore transparency entry: 2436358492
- Sigstore integration time:
-
Permalink:
YucelOzcan/pytest-ratchet@1498e906940c2fb8e1ae8b91176ef63152e13f18 -
Branch / Tag:
refs/heads/master - Owner: https://github.com/YucelOzcan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@1498e906940c2fb8e1ae8b91176ef63152e13f18 -
Trigger Event:
workflow_dispatch
-
Statement type: