A static, AST-based test-smell linter for pytest suites.
Project description
pytest-tidy
A static, AST-based test-smell linter for pytest suites. Cross-platform, zero runtime execution — it reads your tests, it never runs them.
pytest-tidy walks the abstract syntax tree of your test files and flags the
semantic smells that let test suites rot: tests that assert nothing,
assert True, except: pass swallowing failures, pytest.raises(Exception)
catching everything, time.sleep(5) making CI slow and flaky, fixtures with the
wrong scope, and more. flake8-pytest-style catches formatting; pytest-tidy
catches meaning.
It ships three ways: a standalone CLI, a pre-commit hook, and a pytest plugin that warns during collection.
$ pytest-tidy tests/
tests/test_orders.py:14:12: PTD002 assertion on a 2-tuple is always true; did you mean `assert <cond>, <message>`? [*]
tests/test_orders.py:28:5: PTD101 `except Exception:` with no handling body swallows failures
tests/test_orders.py:41:5: PTD003 test 'test_refund' contains no assertions
tests/test_billing.py:9:23: PTD203 scope="function" is the default and can be removed [*]
Found 4 problems 2 fixable with --fix
Why
A test that silently passes is worse than no test: it gives false confidence and
hides regressions forever. These bugs are invisible in review because the code
looks like it tests something. pytest-tidy finds them statically:
# All of these pass no matter what your code does:
assert (order.total == 99, "wrong total") # PTD002 - a 2-tuple is always truthy
mock.assert_called_once # PTD501 - never called; missing ()
try:
charge(order)
except Exception:
pass # PTD101 - swallows AssertionError too
Highlights
- 32 rules across 7 categories (assertions, exceptions, fixtures, timing, structure, mocking, markers) - see the full catalog.
- Zero required dependencies. The core is pure-stdlib
ast. Works on Python 3.9+, every OS. - Autofix.
--fixrewrites the unambiguous ones (byte-precise range edits that preserve your formatting and comments);--diffpreviews them. - Near-zero false positives by design. Noisier rules are opt-in, not on by default.
- Three delivery modes: CLI,
pre-commit, and a pytest plugin. - CI-friendly output:
--format githubemits GitHub Actions annotations;--format jsonis machine-readable. - Configurable via
pyproject.tomlorsetup.cfg, with# noqasupport.
Install
pip install pytest-tidy
On Python 3.11+ TOML config works out of the box. On 3.9 / 3.10, add the toml
extra if you want to configure via pyproject.toml (or just use setup.cfg,
which needs nothing extra):
pip install "pytest-tidy[toml]"
Usage
pytest-tidy # lint test files under the current directory
pytest-tidy tests/ pkg/tests # lint specific paths
pytest-tidy tests/ --fix # apply autofixes in place
pytest-tidy tests/ --diff # show what --fix would change, write nothing
pytest-tidy tests/ --statistics # per-rule counts
When given a directory, pytest-tidy lints only files matching your
test-file patterns (test_*.py, *_test.py, conftest.py). When given an
explicit file, it always lints it.
Useful flags
| Flag | Purpose |
|---|---|
--select CODES |
Enable only these rules/prefixes (replaces defaults). --select ALL turns on everything. |
--extend-select CODES |
Enable extra rules on top of the defaults (handy for opt-in rules). |
--ignore CODES |
Disable rules/prefixes. |
--fix / --diff |
Apply / preview autofixes. |
--format {text,json,github} |
Output format. |
--show-source |
Print the offending line with a caret. |
--statistics |
Per-rule problem counts. |
--list-rules |
Print the whole catalog (respects your config). |
--explain PTD002 |
Show a single rule's rationale. |
--no-config |
Ignore any discovered config file. |
Codes are matched by prefix, so --select PTD2 selects the whole fixtures
category and --ignore PTD3 silences all timing rules.
Exit codes: 0 clean, 1 problems found, 2 usage/internal error.
pre-commit
Add to .pre-commit-config.yaml:
repos:
- repo: https://github.com/arizzubair/pytest-tidy
rev: v0.1.0
hooks:
- id: pytest-tidy
# ...or auto-fix on commit instead:
# - id: pytest-tidy-fix
Both hooks only run on test_*.py, *_test.py, and conftest.py files.
pytest plugin
The plugin is opt-in so it never surprises an existing suite. Enable it per run:
pytest --tidy
...or persistently in pyproject.toml:
[tool.pytest.ini_options]
tidy = true
# optional, mirror the CLI selection flags:
tidy_extend_select = ["PTD404"]
tidy_ignore = ["PTD003"]
Each finding surfaces as a PytestTidyWarning in the warnings summary during
collection - visible, but it never fails the run.
Configuration
pytest-tidy reads [tool.pytest-tidy] from the nearest pyproject.toml, or
[pytest-tidy] from setup.cfg / tox.ini (closest file wins):
[tool.pytest-tidy]
# Turn opt-in rules on alongside the defaults:
extend-select = ["PTD008", "PTD302"]
# Silence a rule or a whole category:
ignore = ["PTD404"]
# Or lock to an explicit set (replaces the defaults):
# select = ["PTD001", "PTD002", "PTD1"]
# Don't lint these paths:
exclude = ["tests/data", "tests/legacy"]
# Extra helper calls that count as "an assertion" for PTD003:
assert-calls = ["assert_matches", "verify"]
# Treat functions with this prefix as tests (default: "test"):
test-function-prefix = "test"
# Which files count as test files when a directory is scanned:
test-file-patterns = ["test_*.py", "*_test.py", "conftest.py"]
The equivalent setup.cfg (no extra dependency needed):
[pytest-tidy]
extend-select = PTD008, PTD302
ignore = PTD404
CLI flags always override file config.
Inline suppression
Standard # noqa comments are honored:
assert True # noqa - suppress every rule on this line
assert value == None # noqa: PTD004 - suppress just PTD004 (comma-separate more)
Rules
32 rules, grouped by category. Full rationale, examples, and autofix notes live
in docs/rules.md; run pytest-tidy --list-rules to see the
set enabled by your config.
| Category | Codes | Examples |
|---|---|---|
| Assertions | PTD001-PTD009 | assert-on-constant, assert-on-tuple, test-without-assertion, assert-eq-none |
| Exceptions | PTD101-PTD104 | except-pass, raises-broad-exception, raises-multiple-statements |
| Fixtures | PTD201-PTD206 | fixture-scope-mismatch, redundant-fixture-scope, yield-fixture-deprecated |
| Timing & determinism | PTD301-PTD304 | sleep-in-test, datetime-now, random-without-seed, network-call |
| Structure & collection | PTD401-PTD404 | test-class-init, duplicate-test-name, return-in-test |
| Mocking | PTD501-PTD502 | mock-uncalled-assert, mock-nonexistent-attr |
| Markers & parametrize | PTD601-PTD603 | skip-without-reason, unconditional-skip, parametrize-values-mismatch |
Opt-in (disabled by default) rules: PTD008, PTD009, PTD103, PTD206, PTD302,
PTD303, PTD304, PTD404, PTD602. Enable them with --extend-select or config.
Philosophy
- Read, never run. Everything is a pure
astwalk. No imports of your code, no side effects, fully deterministic and cross-platform. - Near-zero false positives. A linter you have to argue with gets turned off. Rules that can't be sure stay opt-in, and default rules bias hard toward "only flag it if it's almost certainly a bug."
- Fix what's safe. Autofixes only fire on unambiguous rewrites, and re-lint to convergence so chained fixes settle.
Contributing / development
python -m venv .venv
.venv/Scripts/pip install -e ".[dev]" # POSIX: .venv/bin/pip
python -m pytest # run the suite
python -m pytest_tidy tests # dogfood the linter on itself
python scripts/gen_rules_doc.py # regenerate docs/rules.md
Each rule is a small class in src/pytest_tidy/rules/ declaring the AST node
types it cares about and a check() method; the engine walks each file once and
dispatches nodes to interested rules.
Releasing
Continuous integration runs the suite across Python 3.9-3.13 on Linux, macOS,
and Windows (.github/workflows/ci.yml). Publishing is automated via
.github/workflows/publish.yml using PyPI Trusted Publishing (OIDC), so
no API tokens are stored in the repo. To cut a release:
- Bump
versioninpyproject.tomland updateCHANGELOG.md. - Tag and push:
git tag v0.1.0 && git push --tags. - Publish a GitHub Release for the tag - the workflow builds the sdist/wheel and uploads them to PyPI.
Run the publish workflow manually (workflow_dispatch) to push to TestPyPI
first as a dry run.
License
MIT - see LICENSE.
Project details
Release history Release notifications | RSS feed
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_tidy-0.1.0.tar.gz.
File metadata
- Download URL: pytest_tidy-0.1.0.tar.gz
- Upload date:
- Size: 51.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d8dd30bb9e035cd4df91c00eb1f2a1a0e3504074133121fb48452970f9dc5d8c
|
|
| MD5 |
1e3b4fc5d505265a53631ba3a5a4f212
|
|
| BLAKE2b-256 |
99c2300b145681c237ad7bf601afe6b0ef206a56089de7ec8f6f995001860064
|
Provenance
The following attestation bundles were made for pytest_tidy-0.1.0.tar.gz:
Publisher:
publish.yml on az-pz/pytest-tidy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_tidy-0.1.0.tar.gz -
Subject digest:
d8dd30bb9e035cd4df91c00eb1f2a1a0e3504074133121fb48452970f9dc5d8c - Sigstore transparency entry: 2173651326
- Sigstore integration time:
-
Permalink:
az-pz/pytest-tidy@483bb28554d310d43aba60ff537cbe9877936efc -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/az-pz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@483bb28554d310d43aba60ff537cbe9877936efc -
Trigger Event:
release
-
Statement type:
File details
Details for the file pytest_tidy-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pytest_tidy-0.1.0-py3-none-any.whl
- Upload date:
- Size: 46.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2b56b5147c094e4cdc8101f7ee5e412e673b8da203ecbec9314f1bf94e0e69a2
|
|
| MD5 |
6eb313a21a701e25e51286eaf910b758
|
|
| BLAKE2b-256 |
7d6cdf5f66d11ab9d53ce6d8173ce326b97d9ce11baa6b44c1f976e2ccddb323
|
Provenance
The following attestation bundles were made for pytest_tidy-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on az-pz/pytest-tidy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_tidy-0.1.0-py3-none-any.whl -
Subject digest:
2b56b5147c094e4cdc8101f7ee5e412e673b8da203ecbec9314f1bf94e0e69a2 - Sigstore transparency entry: 2173651329
- Sigstore integration time:
-
Permalink:
az-pz/pytest-tidy@483bb28554d310d43aba60ff537cbe9877936efc -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/az-pz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@483bb28554d310d43aba60ff537cbe9877936efc -
Trigger Event:
release
-
Statement type: