Metadata-Version: 2.4
Name: pytest-tidy
Version: 0.1.0
Summary: A static, AST-based test-smell linter for pytest suites.
Author: Ariz Zubair
License: MIT License
        
        Copyright (c) 2026 Ariz Zubair
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
        
Project-URL: Homepage, https://github.com/arizzubair/pytest-tidy
Project-URL: Documentation, https://github.com/arizzubair/pytest-tidy/blob/main/docs/rules.md
Project-URL: Source, https://github.com/arizzubair/pytest-tidy
Project-URL: Changelog, https://github.com/arizzubair/pytest-tidy/blob/main/CHANGELOG.md
Keywords: pytest,lint,linter,ast,testing,test-smells,static-analysis,pre-commit
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Framework :: Pytest
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: toml
Requires-Dist: tomli>=1.1.0; python_version < "3.11" and extra == "toml"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: tomli>=1.1.0; python_version < "3.11" and extra == "dev"
Dynamic: license-file

# pytest-tidy

[![CI](https://github.com/arizzubair/pytest-tidy/actions/workflows/ci.yml/badge.svg)](https://github.com/arizzubair/pytest-tidy/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/pytest-tidy.svg)](https://pypi.org/project/pytest-tidy/)
[![Python versions](https://img.shields.io/pypi/pyversions/pytest-tidy.svg)](https://pypi.org/project/pytest-tidy/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

**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.

```console
$ 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:

```python
# 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](docs/rules.md).
- **Zero required dependencies.** The core is pure-stdlib `ast`. Works on
  **Python 3.9+**, every OS.
- **Autofix.** `--fix` rewrites the unambiguous ones (byte-precise range edits
  that preserve your formatting and comments); `--diff` previews 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 github` emits GitHub Actions annotations;
  `--format json` is machine-readable.
- **Configurable** via `pyproject.toml` or `setup.cfg`, with `# noqa` support.

## Install

```console
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):

```console
pip install "pytest-tidy[toml]"
```

## Usage

```console
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`:

```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:

```console
pytest --tidy
```

...or persistently in `pyproject.toml`:

```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):

```toml
[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):

```ini
[pytest-tidy]
extend-select = PTD008, PTD302
ignore = PTD404
```

CLI flags always override file config.

### Inline suppression

Standard `# noqa` comments are honored:

```python
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](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 `ast` walk. 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

```console
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][tp] (OIDC), so
no API tokens are stored in the repo. To cut a release:

1. Bump `version` in `pyproject.toml` and update `CHANGELOG.md`.
2. Tag and push: `git tag v0.1.0 && git push --tags`.
3. 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.

[tp]: https://docs.pypi.org/trusted-publishers/

## License

MIT - see [LICENSE](LICENSE).
