Metadata-Version: 2.4
Name: pytest-impacted
Version: 0.24.1
Summary: A pytest plugin that selectively runs tests impacted by code changes via git introspection, AST parsing, and dependency graph analysis.
Author-email: PromptRomp <promptromp.contact@gmail.com>
Maintainer-email: PromptRomp <promptromp.contact@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/promptromp/pytest-impacted
Project-URL: Issues, https://github.com/promptromp/pytest-impacted/issues
Classifier: Framework :: Pytest
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Testing
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: astroid>=4.1.2
Requires-Dist: click>=8.2.0
Requires-Dist: gitpython>=3.1.44
Requires-Dist: networkx>=3.4.2
Requires-Dist: pytest>=8.0.0
Requires-Dist: rich>=14.0.0
Provides-Extra: fast
Requires-Dist: pytest-impacted-rs; extra == "fast"
Dynamic: license-file

# pytest-impacted

[![CI](https://github.com/promptromp/pytest-impacted/actions/workflows/ci.yml/badge.svg)](https://github.com/promptromp/pytest-impacted/actions/workflows/ci.yml)
[![GitHub License](https://img.shields.io/github/license/promptromp/pytest-impacted)](https://github.com/promptromp/pytest-impacted/blob/main/LICENSE)
[![PyPI - Version](https://img.shields.io/pypi/v/pytest-impacted)](https://pypi.org/project/pytest-impacted/)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pytest-impacted)](https://pypi.org/project/pytest-impacted/)

**Run only the tests that matter.** A pytest plugin that uses git diff, AST parsing, and dependency graph analysis to selectively run tests impacted by your code changes.

```bash
pytest --impacted --impacted-module=my_package     # unstaged changes
pytest --impacted --impacted-module=my_package \
       --impacted-git-mode=branch \
       --impacted-base-branch=main                 # branch changes vs main
```

---

### Key Features

| | Feature | Details |
|---|---|---|
| :zap: | **Fast feedback** | Only runs tests affected by your changes — skip the rest |
| :deciduous_tree: | **Dependency-aware** | Follows import chains transitively, not just direct file changes |
| :gear: | **No imports at analysis time** | Filesystem discovery + AST parsing — no module-level side effects |
| :test_tube: | **pytest-native** | Works as a standard pytest plugin with familiar CLI options |
| :wrench: | **conftest.py aware** | Changes to `conftest.py` automatically impact all tests in scope |
| :package: | **Dependency-file aware** | Changes to `uv.lock`, `requirements.txt`, `pyproject.toml` etc. trigger all tests |
| :building_construction: | **CI-friendly** | Standalone `impacted-tests` CLI for two-stage CI pipelines |
| :rocket: | **Rust-accelerated** | Optional Rust extension for 37-65x faster import parsing on large codebases |
| :electric_plug: | **Extensible** | Third-party strategies installable as plugins via Python entry points |
| :shield: | **Helpful errors** | Validates config early with clear messages and suggestions |

> [!CAUTION]
> This project is currently in beta. Please report bugs via the [Issues](https://github.com/promptromp/pytest-impacted/issues) tab.

---

## Installation

```bash
pip install pytest-impacted
```

Or with [uv](https://docs.astral.sh/uv/):

```bash
uv add pytest-impacted
```

For **37-65x faster** import parsing on large codebases, install with the optional Rust extension:

```bash
pip install pytest-impacted[fast]
```

Requires **Python 3.11+**.

---

## Quick Start

**1. Run tests impacted by uncommitted changes:**

```bash
pytest --impacted --impacted-module=my_package
```

**2. Run tests impacted by branch changes (vs `main`):**

```bash
pytest --impacted \
       --impacted-module=my_package \
       --impacted-git-mode=branch \
       --impacted-base-branch=main
```

**3. Include tests outside the package directory:**

```bash
pytest --impacted \
       --impacted-module=my_package \
       --impacted-tests-dir=tests
```

That's it. Unaffected tests are automatically skipped.

---

## How It Works

```
Git diff → Changed files → Module resolution → AST import parsing → Dependency graph → Impacted tests
                         ↘ Dependency file detection → All tests (if dep files changed)
```

1. **Git introspection** identifies which files changed (unstaged edits or branch diff)
2. **Filesystem discovery** maps file paths to Python module names — without importing anything
3. **AST parsing** (via [astroid](https://pylint.pycqa.org/projects/astroid/en/latest/), or the optional Rust extension using [ruff's parser](https://github.com/astral-sh/ruff)) extracts import relationships from source files
4. **Dependency graph** (via [NetworkX](https://networkx.org/)) traces transitive dependencies from changed modules to test modules
5. **Dependency file detection** — if files like `uv.lock`, `requirements.txt`, or `pyproject.toml` changed, all tests are marked as impacted regardless of import analysis
6. **Test filtering** skips tests whose modules are not in the impact set

The philosophy is to **err on the side of caution**: we favor false positives (running a test that didn't need to run) over false negatives (missing a test that should have run).

### Strategy-Based Architecture

Impact analysis is pluggable via a strategy pattern. The default pipeline combines three strategies:

| Strategy | What it does |
|----------|-------------|
| **ASTImpactStrategy** | Traces transitive import dependencies through the dependency graph |
| **PytestImpactStrategy** | Extends AST analysis with pytest-specific knowledge — when a `conftest.py` file changes, **all tests in its directory and subdirectories** are marked as impacted |
| **DependencyFileImpactStrategy** | When dependency files change (`uv.lock`, `requirements.txt`, `pyproject.toml`, etc.), **all tests** are marked as impacted |

All strategies are combined via `CompositeImpactStrategy`, which deduplicates and merges their results. Dependency file detection is enabled by default and can be disabled with `--no-impacted-dep-files`.

#### Custom Strategy Extensions

Third-party packages can register custom strategies as installable plugins via Python entry points. Once installed, they are automatically discovered and composed into the analysis pipeline:

```toml
# In your extension's pyproject.toml
[project.entry-points."pytest_impacted.strategies"]
my_strategy = "my_package.strategy:MyCustomStrategy"
```

```python
from pytest_impacted import ImpactStrategy, ConfigOption, resolve_impacted_tests

class MyCustomStrategy(ImpactStrategy):
    config_options = [
        ConfigOption(name="threshold", help="Min score to consider", type=int, default=80),
    ]

    def __init__(self, threshold: int = 80):
        self.threshold = threshold

    def find_impacted_tests(self, changed_files, impacted_modules, ns_module, *, dep_tree, **kwargs):
        # dep_tree is the pre-built dependency graph (nx.DiGraph), shared across all strategies.
        # resolve_impacted_tests provides standard graph traversal.
        return resolve_impacted_tests(impacted_modules, dep_tree)
```

Users can configure extensions via CLI (`--impacted-ext-my-strategy-threshold 90`) or `pyproject.toml`, and disable them with `--impacted-disable-ext my_strategy`. Extensions can alternatively use duck-typing (any class with a `find_impacted_tests` method — no inheritance required) and can set a `priority` class variable to control execution order. For extensions that need to walk the source tree or reuse the core AST parser, `discover_submodules` and `parse_file_imports` are also exported from `pytest_impacted`.

Strategies may also override three optional **lifecycle hooks** (all with no-op defaults, so existing extensions keep working unchanged):

- `enrich_dep_tree(dep_tree, *, ns_module, tests_package, root_dir, session)` — inject synthetic edges for relationships invisible to static import analysis (DI bindings, codegen, plugin discovery). The hook receives full context so scan-based enrichers can walk the source tree with `discover_submodules` + `parse_file_imports` and add edges the built-in AST traversal will then follow automatically.
- `setup(*, ns_module, tests_package, root_dir, session, dep_tree)` — one-time per-run warm-up; the right place for expensive O(source-tree) indexing instead of lazy-init inside `find_impacted_tests`.
- `teardown()` — release per-run state; always fires, even if `find_impacted_tests` raises.

See the [Extensions guide](https://promptromp.github.io/pytest-impacted/extensions/) for the full reference and worked examples.

You can also supply a custom strategy programmatically via the `get_impacted_tests()` API:

```python
from pytest_impacted.api import get_impacted_tests

impacted = get_impacted_tests(
    impacted_git_mode="branch",
    impacted_base_branch="main",
    root_dir=Path("."),
    ns_module="my_package",
    strategy=MyCustomStrategy(),
)
```

---

## Usage

### Git Modes

| Mode | Flag | What it compares |
|------|------|-----------------|
| **unstaged** (default) | `--impacted-git-mode=unstaged` | Working directory changes + untracked files |
| **branch** | `--impacted-git-mode=branch` | All commits on current branch vs base branch |

The `--impacted-base-branch` flag accepts any valid git ref, including expressions like `HEAD~4`.

### External Tests Directory

When your tests live outside the namespace package (a common layout), use `--impacted-tests-dir` so the dependency graph includes them:

```bash
pytest --impacted \
       --impacted-module=my_package \
       --impacted-tests-dir=tests
```

### Monorepo / src-Layout Support

The plugin works in monorepos where the Python project is nested in a subdirectory (the `.git` directory doesn't need to be in the working directory — parent directories are searched automatically).

For **src-layout** projects (e.g. `src/my_package/`), point `--impacted-module` at the full path including the `src/` prefix:

```bash
# From the project directory (e.g. monorepo/backend/)
pytest --impacted \
       --impacted-module=src/my_package \
       --impacted-tests-dir=tests
```

The plugin automatically detects that `src/` is not a Python package and uses the correct importable module name (`my_package`) for dependency analysis.

### CI Integration

For CI pipelines where git access and test execution happen in separate stages, use the `impacted-tests` CLI to generate the test file list:

```bash
# Stage 1: identify impacted tests
impacted-tests --module=my_package --git-mode=branch --base-branch=main > impacted_tests.txt

# Stage 2: run only those tests
pytest $(cat impacted_tests.txt)
```

### Configuration via `pyproject.toml`

All CLI options can be set as defaults in your `pyproject.toml` (or `pytest.ini`):

```toml
[tool.pytest.ini_options]
impacted = true
impacted_module = "my_package"
impacted_git_mode = "branch"
impacted_base_branch = "main"
impacted_tests_dir = "tests"
# no_impacted_dep_files = true  # uncomment to disable dep file detection
```

CLI flags override these defaults.

### All Options

| Option | Default | Description |
|--------|---------|-------------|
| `--impacted` | `false` | Enable the plugin |
| `--impacted-module` | *(required)* | Top-level Python package to analyze |
| `--impacted-git-mode` | `unstaged` | Git comparison mode: `unstaged` or `branch` |
| `--impacted-base-branch` | *(required for branch mode)* | Base branch/ref for branch-mode comparison |
| `--impacted-tests-dir` | `None` | Directory containing tests outside the package |
| `--no-impacted-dep-files` | `false` | Disable dependency file change detection |
| `--impacted-disable-ext` | `[]` | Disable a strategy extension by name (repeatable) |

---

## Alternatives

| Project | Notes |
|---------|-------|
| [pytest-testmon](https://testmon.org/) | Most popular option. Uses coverage-based granular change tracking. More precise but heavier; may conflict with other plugins. |
| [pytest-picked](https://github.com/anapaulagomes/pytest-picked) | Runs tests from directly modified files only — no transitive dependency analysis. |
| [pytest-affected](https://pypi.org/project/pytest-affected/0.1.6/) | Appears unmaintained, no source repository. |

---

## Performance: Optional Rust Acceleration

For large codebases, install the optional Rust extension to accelerate import parsing by **37-65x**:

```bash
pip install pytest-impacted[fast]
```

This installs `pytest-impacted-rs`, a pre-built Rust extension using [ruff's parser](https://github.com/astral-sh/ruff) and [rayon](https://github.com/rayon-rs/rayon) for parallel file processing. The extension is automatically detected at runtime — no configuration needed. When unavailable, the pure-Python (astroid) implementation is used.

---

## Development

This project uses [uv](https://docs.astral.sh/uv/) for dependency management.

```bash
# Setup
uv sync --all-extras --dev

# Run tests
uv run python -m pytest

# Run tests with coverage
uv run python -m pytest --cov=pytest_impacted --cov-branch tests

# Lint + format + type check
pre-commit run --all-files

# Install with Rust acceleration (pre-built wheels, no Rust toolchain needed)
pip install pytest-impacted[fast]

# Or build from source (requires Rust toolchain)
pip install maturin
cd rust && maturin develop --release

# Run parsing benchmarks
python -m benchmarks.bench_parsing
```

---

## License

[MIT](LICENSE)
