Metadata-Version: 2.4
Name: assert-pytest-class-holds-state
Version: 20260907154656
Summary: CLI tool to assert that every pytest class holds a fixture, an attribute, a helper or a base
Author-email: 10U Labs <dev@10ulabs.com>
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/10U-Labs/assert-pytest-class-holds-state
Project-URL: Repository, https://github.com/10U-Labs/assert-pytest-class-holds-state
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
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.10
Description-Content-Type: text/markdown
License-File: LICENSE.txt
Dynamic: license-file

# assert-pytest-class-holds-state

Assert that every pytest class holds a fixture, an attribute, a helper or a
base.

## Why Must a Class Hold Something?

A pytest class earns its place three ways. It holds a fixture, whose scope is
then the class. It holds an attribute or a helper that its methods reach
through `self`. Or it derives from a shared suite, collecting one set of tests
for many subjects.

A class that does none of those is a namespace. It costs an unused `self` on
every signature, a level of indentation on every body, a prefix on every node
ID, and it suggests a setup that is not there. The methods belong at module
level, where pytest collects them just as readily.

`no-self-use` is the mechanical signal and the wrong instrument. It reports on
a method that happens not to touch `self`, so it flags an `autouse` fixture
sitting in the class that is its scope, and it flags a kept class's one
stateless method. This tool asks what the class is for instead, so a class that
holds anything at all is silent rather than waived.

## Installation

```bash
pip install assert-pytest-class-holds-state
```

## Usage

### Command Line

```bash
# Scan specific files
assert-pytest-class-holds-state test_example.py

# Scan directories recursively
assert-pytest-class-holds-state test/

# Use glob patterns
assert-pytest-class-holds-state "test/**/test_*.py"

# Exclude patterns
assert-pytest-class-holds-state test/ --exclude "**/conftest.py"

# Verbose output
assert-pytest-class-holds-state test/ --verbose

# Fail fast (exit on first finding)
assert-pytest-class-holds-state test/ --fail-fast

# Warn only (always exit 0)
assert-pytest-class-holds-state test/ --warn-only
```

### GitHub Actions

```yaml
- uses: 10U-Labs/assert-pytest-class-holds-state@latest
  with:
    files: "test/"
    verbose: "true"
```

### As a Python Module

```bash
python -m assert_pytest_class_holds_state test/
```

## Output Format

Default output shows one finding per line:

```text
path/to/test_file.py:10:TestExample: the test methods belong at module level
```

Format: `file_path:line_number:class_name`, followed by the reason. The line
number is the `class` statement.

## Exit Codes

- `0`: No findings (or `--warn-only` specified)
- `1`: Findings detected
- `2`: Error (missing files, syntax errors, etc.)

## What Counts as Holding Something?

A class whose name begins with `Test` is refused only when all four of these
are true:

- it has no base and no class keyword
- it carries no decorator
- its body holds no assignment
- every function in its body is a test — a name beginning with `test` that no
  `pytest.fixture` decorator files as a fixture

Anything else passes. An inheritance stub passes on its base, even with an
empty body. A fixture holder passes on the fixture, `autouse` or not. A class
holding a `@staticmethod` helper, a plain helper, or a `setup_method` passes on
the non-test function. A class carrying `@pytest.mark.integration` passes on
the decorator, and one carrying `region = "us-east-2"` passes on the
assignment.

A decorator on a *method* is not a decorator on the class, so
`@pytest.mark.parametrize` on every test does not save a namespace.

## What Is Never Examined

A class defined inside a function is a suite the function produces, not a
namespace someone wrote around loose methods. Its methods close over the
function's arguments and cannot move to module level, and pytest never collects
it until another module inherits from it. The tool does not descend into
function bodies at all.

Only files pytest would collect are scanned: `test_*.py` and `*_test.py`.

## Options

| Option                | Description                                |
| --------------------- | ------------------------------------------ |
| `--exclude PATTERNS`  | Glob patterns to exclude (comma-separated) |
| `--quiet`             | Suppress all output (exit code only)       |
| `--count`             | Output only the number of findings         |
| `--verbose`           | Show detailed processing information       |
| `--fail-fast`         | Exit after first finding                   |
| `--warn-only`         | Always exit 0, even with findings          |

## License

Apache 2.0 - See [LICENSE.txt](LICENSE.txt)
