Extensions
pytest-impacted is built around a strategy-based architecture (see Impact Analysis Strategies in the Usage Guide). Anywhere the built-in strategies aren't enough — runtime DI bindings, codegen outputs, plugin discovery, custom heuristics — you can extend the pipeline with your own strategy.
There are two ways to do that:
| Approach | When to use | How it's wired |
|---|---|---|
| Programmatic | One-off in-process customization, or driving impact analysis from your own tooling | Pass strategy= to get_impacted_tests() |
| Packaged extension | Reusable, distributable, auto-discovered alongside built-in strategies | Register via the pytest_impacted.strategies entry point |
Both approaches share the same ImpactStrategy base class and the same lifecycle hooks, dependency graph, and utility helpers documented below.
Custom strategies (programmatic)
The simplest way to extend impact analysis is to subclass ImpactStrategy and pass an instance to the get_impacted_tests() API:
from pathlib import Path
from pytest_impacted.api import get_impacted_tests
from pytest_impacted.strategies import ImpactStrategy
class MyCustomStrategy(ImpactStrategy):
def find_impacted_tests(self, changed_files, impacted_modules, ns_module, **kwargs):
# your logic here
...
impacted = get_impacted_tests(
impacted_git_mode="branch",
impacted_base_branch="main",
root_dir=Path("."),
ns_module="my_package",
strategy=MyCustomStrategy(),
)
This is the right entry point for one-off integrations or for driving impact analysis from your own test runner. For reusable, auto-discovered strategies that ship as their own package, see the packaged extension system below.
Packaged extensions
Third-party packages can register custom strategies as installable plugins. Once installed, they are automatically discovered and composed into the analysis pipeline alongside the built-in strategies.
An extension is a standard Python package that registers a strategy class via the pytest_impacted.strategies entry point group.
Minimal extension (no configuration)
# my_extension/strategy.py
from pytest_impacted import ImpactStrategy, resolve_impacted_tests
class MyStrategy(ImpactStrategy):
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 strategies
return resolve_impacted_tests(impacted_modules, dep_tree)
# pyproject.toml for the extension package
[project]
name = "pytest-impacted-my-extension"
dependencies = ["pytest-impacted>=0.19"]
[project.entry-points."pytest_impacted.strategies"]
my_extension = "my_extension.strategy:MyStrategy"
The entry point name (my_extension) is the user-facing identifier used for enabling/disabling the extension.
Extension with configuration
Extensions can declare configuration options that are automatically registered as CLI flags and ini settings:
from pytest_impacted import ImpactStrategy, ConfigOption
class CoverageStrategy(ImpactStrategy):
config_options = [
ConfigOption(name="coverage_file", help="Path to .coverage file", default=".coverage"),
ConfigOption(name="threshold", help="Minimum coverage %% to consider", type=int, default=80),
]
priority = 50 # Lower = runs earlier (default is 100)
def __init__(self, coverage_file: str = ".coverage", threshold: int = 80):
self.coverage_file = coverage_file
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; use self.coverage_file and self.threshold
...
Config options are automatically namespaced to avoid collisions:
- CLI flag:
--impacted-ext-{extension_name}-{option_name}(hyphens) - ini setting:
impacted_ext_{extension_name}_{option_name}(underscores)
For the example above:
[tool.pytest.ini_options]
impacted_ext_coverage_threshold = "90"
impacted_ext_coverage_coverage_file = ".coverage.ci"
Tip
The ConfigOption dataclass supports str, bool, int, and float types. Values from config files are automatically coerced to the declared type.
Duck-typed extensions (zero dependency)
Extensions don't need to inherit from ImpactStrategy. Any class with a find_impacted_tests method works:
# No import from pytest_impacted at all!
class MyLightweightStrategy:
def find_impacted_tests(self, changed_files, impacted_modules, ns_module, *, dep_tree, **kwargs):
# dep_tree is an nx.DiGraph supplied by the pipeline
return [...]
This is validated at runtime using the StrategyProtocol (a typing.Protocol).
Using extensions
Once installed, extensions are automatically discovered and added to the strategy pipeline. No additional configuration is needed beyond installing the package.
Disabling extensions
To disable a specific extension, use --impacted-disable-ext (repeatable):
Or in pyproject.toml:
Viewing loaded extensions
Extensions are listed in the pytest report header:
Extension priority
Extensions can declare a priority class variable to control execution order. Lower values run earlier. The default priority is 100. Built-in strategies always run first, followed by extensions sorted by priority.
class EarlyStrategy(ImpactStrategy):
priority = 10 # Runs before other extensions
...
class LateStrategy(ImpactStrategy):
priority = 200 # Runs after other extensions
...
Note
Since CompositeImpactStrategy unions all results, execution order rarely matters for correctness. Priority is mainly useful if an extension needs to set up shared state or log information before others run.
Dependency graph access
All strategies receive the pre-built dependency graph as a required keyword-only argument dep_tree: nx.DiGraph. The graph is built once by the orchestration layer and passed through CompositeImpactStrategy to all sub-strategies, so the expensive graph construction is shared across the pipeline.
The resolve_impacted_tests utility is exported from the package root for extensions that want standard graph traversal:
from pytest_impacted import ImpactStrategy, resolve_impacted_tests
class MyStrategy(ImpactStrategy):
def find_impacted_tests(self, changed_files, impacted_modules, ns_module, *, dep_tree, **kwargs):
# Standard traversal: find test modules that transitively depend on changed modules
base_tests = resolve_impacted_tests(impacted_modules, dep_tree)
# Add custom logic on top...
return base_tests
The dependency graph uses inverted edge direction: edges point from imported module to its dependents (e.g. core -> api -> test_api). This means nx.dfs_preorder_nodes(dep_tree, source="core") finds all modules that transitively depend on core.
Extension utilities
Beyond resolve_impacted_tests, two additional helpers are exported from the package root for extensions that need to do their own file or import analysis:
-
discover_submodules(package, require_init=True)— walks a Python package and returns a{module_name: file_path}dict. Uses the same filesystem-based discovery pytest-impacted uses internally (handles src-layout, namespace packages, and LRU-caches results). Passrequire_init=Falsefor test directories that may not have__init__.pyfiles. This is the right primitive for any extension that needs to scan the full source tree. -
parse_file_imports(file_path, module_name, is_package=False)— AST-parses a Python file and returns alist[str]of the modules it imports. Uses pytest-impacted's own astroid-based parser, so extensions that call it will interpret imports the same way the core does (including relative imports, star imports, and conditional imports insideif TYPE_CHECKINGblocks). No module execution — imports are extracted from the AST without running code.
Example: a strategy that enumerates all source files and scans them for a custom pattern:
from pytest_impacted import ImpactStrategy, discover_submodules, parse_file_imports
class MyScanningStrategy(ImpactStrategy):
def find_impacted_tests(self, changed_files, impacted_modules, ns_module, *, dep_tree, **kwargs):
# Walk every source file in the package
modules = discover_submodules(ns_module)
for module_name, file_path in modules.items():
imports = parse_file_imports(file_path, module_name)
# ... do something with imports ...
return []
Tip
discover_submodules is LRU-cached by (package, require_init) so calling it multiple times within a single pytest run is cheap. The cache is cleared by clear_dep_tree_cache() alongside the dependency graph cache.
Lifecycle hooks
ImpactStrategy exposes three optional lifecycle methods that run once per pytest invocation: enrich_dep_tree, setup, and teardown. They give extensions proper places to hang one-time work and — critically — to inject synthetic dependency edges that the built-in AST traversal will then follow automatically.
setup and teardown — one-time work per run
from pytest_impacted import ImpactStrategy, discover_submodules, parse_file_imports
class IndexingStrategy(ImpactStrategy):
def setup(self, *, ns_module, tests_package=None, root_dir=None, session=None, dep_tree):
# One-time O(source-tree) work happens here, not in find_impacted_tests
self._index = {}
for module_name, file_path in discover_submodules(ns_module).items():
self._index[module_name] = parse_file_imports(file_path, module_name)
def teardown(self):
# Release per-run state. Fires even if find_impacted_tests raises.
self._index = None
def find_impacted_tests(self, changed_files, impacted_modules, ns_module, *, dep_tree, **kwargs):
# Cheap lookups against self._index — no scanning.
...
return []
When they fire. pytest_impacted.api.get_impacted_tests invokes strategy.setup(...) immediately before strategy.find_impacted_tests(...), and strategy.teardown() in a finally block immediately after. Teardown always runs, even if find_impacted_tests raises — so strategies can allocate resources in setup with confidence they will be released.
Signature. setup takes only keyword arguments: ns_module, tests_package, root_dir, session, dep_tree. These are the same context kwargs as find_impacted_tests minus changed_files / impacted_modules (which are not known at setup time). Both hooks have no-op default implementations on ImpactStrategy, so existing strategies adopt the new lifecycle without any changes.
Composition and ordering. CompositeImpactStrategy propagates setup to its children in list order and teardown in reverse order (LIFO, matching the convention used by context managers and ExitStack). If any sub-strategy's setup or teardown raises, the composite logs a warning on the pytest_impacted.strategies logger and continues with the remaining sub-strategies — one misbehaving extension cannot prevent the others from running.
Tip
If you find yourself writing a lazy-init guard at the top of find_impacted_tests (if self._index is None: ...), that's the signal to move the work into setup. The hook also makes timing and profiling cleaner — you can measure setup cost independently from per-call work.
enrich_dep_tree — inject synthetic edges
Some dependency relationships are invisible to static import analysis: runtime DI bindings, codegen outputs, plugin discovery, config-driven wiring. The enrich_dep_tree hook lets an extension add those relationships as explicit edges in the shared dependency graph before any strategy runs its impact analysis. The built-in AST strategy then traverses those synthetic edges exactly as if they had been real imports.
Most real extensions need to look at the actual source code to decide which edges to add. enrich_dep_tree receives the same context kwargs as setup (ns_module, tests_package, root_dir, session) so you can walk the tree with discover_submodules and parse_file_imports from inside the hook — a scan-then-enrich pattern that keeps all the logic in one place.
import re
from pathlib import Path
import networkx as nx
from pytest_impacted import ImpactStrategy, discover_submodules, parse_file_imports
# Finds @binding("key") decorators used by microcosm-style DI frameworks.
_BINDING_RE = re.compile(r'@binding\(["\']([^"\']+)["\']\)')
class DIBindingStrategy(ImpactStrategy):
"""Bridge runtime DI bindings into the static dependency graph.
Walks the source tree once per run, finds ``@binding("key")``
producers and ``graph.key`` consumers, and adds a synthetic edge
from every producer module to every consumer module. The built-in
AST strategy then picks up those edges automatically.
"""
def enrich_dep_tree(
self,
dep_tree: nx.DiGraph,
*,
ns_module: str,
tests_package: str | None = None,
root_dir: Path | None = None,
session=None,
) -> None:
# 1. Enumerate every source file the core knows about.
modules = dict(discover_submodules(ns_module))
if tests_package:
modules.update(discover_submodules(tests_package, require_init=False))
# 2. Scan each file for producers and consumers.
producers: dict[str, str] = {} # binding_key -> producer module
consumers: dict[str, set[str]] = {} # binding_key -> {consumer modules}
for module_name, file_path in modules.items():
try:
source = Path(file_path).read_text(encoding="utf-8")
except OSError:
continue
for match in _BINDING_RE.finditer(source):
producers[match.group(1)] = module_name
for match in re.finditer(r"\bgraph\.(\w+)\b", source):
consumers.setdefault(match.group(1), set()).add(module_name)
# Reuse the core import parser to stay consistent with AST strategy.
parse_file_imports(file_path, module_name)
# 3. Add producer → consumer edges. The graph uses inverted
# direction, so "producer points at impacted consumer"
# matches how the AST strategy reads its own import edges.
for key, producer in producers.items():
for consumer in consumers.get(key, ()):
if producer != consumer:
dep_tree.add_edge(producer, consumer)
def find_impacted_tests(self, *args, **kwargs):
# Often unnecessary — the AST strategy already traverses the
# edges you added above. Return [] to contribute nothing extra.
return []
When it fires. enrich_dep_tree runs once per pytest invocation, on a per-run copy of the LRU-cached base graph, before any strategy's setup is called. The ordering is: build cached graph → copy → enrich_dep_tree(all strategies) → setup(all strategies) → find_impacted_tests(all strategies) → teardown(all strategies).
Per-run copy matters. pytest_impacted.strategies.cached_build_dep_tree is LRU-cached by (ns_module, tests_package). Without the copy, enrichment from one run would accumulate into every subsequent run within the same process (e.g. pytester-driven test suites). The orchestrator calls .copy() on the cached graph before handing it to enrich_dep_tree, so the graph you mutate is yours for this run only.
Propagation and ordering. CompositeImpactStrategy calls enrich_dep_tree on its children in list order, forwarding all context kwargs unchanged. Because the graph is mutated in place, edges added by one child are immediately visible to every later child's enrich_dep_tree call. Exceptions are logged at WARNING on pytest_impacted.strategies and swallowed — the fault-tolerance contract applies here too.
Tip
Prefer enrich_dep_tree over doing your own DFS inside find_impacted_tests when the relationships you're modeling can be expressed as edges. You get the built-in traversal, deduplication, and transitive closure for free, and the edges are visible to every other strategy in the pipeline — not just yours.
Persisting state across runs
Extensions that build an expensive index — for example, scanning every .py file for @binding decorators, symbol tables, or any other codebase-wide fingerprint — can easily amortize that cost across pytest runs. pytest-impacted does not ship a built-in cache service for extensions, but there is a recommended filesystem convention so every extension does not need to reinvent it.
Recommended layout. Store per-extension state under .pytest-impacted-cache/<extension-name>/ in the project root, next to pytest's own .pytest_cache/. This keeps extension state easy to discover, easy to clear (rm -rf .pytest-impacted-cache/), and out of the way of unrelated tooling. Extensions should add this directory to .gitignore.
Expose the path as a ConfigOption. Users may want to override the location — to move it onto faster storage, share it across CI workers, or point at an absolute path that survives tmp-style workdirs. Declare it as a config option so it is auto-registered as a CLI flag (--impacted-ext-<name>-cache-dir) and ini value.
from pathlib import Path
from pytest_impacted import ConfigOption, ImpactStrategy
class MyExtension(ImpactStrategy):
config_options = [
ConfigOption(
name="cache_dir",
help="Directory for persisted extension state (default: .pytest-impacted-cache/my_ext)",
type=str,
default=".pytest-impacted-cache/my_ext",
),
]
def __init__(self, cache_dir: str = ".pytest-impacted-cache/my_ext"):
self._cache_dir = Path(cache_dir)
Invalidate on mtime. The simplest invalidation strategy that is also correct-by-default: hash the mtimes of every file the extension scans, compare against a stored manifest, rebuild when anything is newer. This catches code edits, git checkouts, and merges without any special integration with git.
import json
from pathlib import Path
def _mtime_fingerprint(paths: list[Path]) -> str:
"""Stable hash of (path, mtime_ns) pairs for cache invalidation."""
import hashlib
h = hashlib.sha256()
for p in sorted(paths):
try:
mtime = p.stat().st_mtime_ns
except FileNotFoundError:
continue
h.update(str(p).encode())
h.update(str(mtime).encode())
return h.hexdigest()
def load_or_build(cache_dir: Path, scanned_files: list[Path], build_index):
cache_dir.mkdir(parents=True, exist_ok=True)
manifest_path = cache_dir / "manifest.json"
index_path = cache_dir / "index.json"
fingerprint = _mtime_fingerprint(scanned_files)
if manifest_path.exists() and index_path.exists():
manifest = json.loads(manifest_path.read_text())
if manifest.get("fingerprint") == fingerprint:
return json.loads(index_path.read_text())
index = build_index()
index_path.write_text(json.dumps(index))
manifest_path.write_text(json.dumps({"fingerprint": fingerprint}))
return index
Call this from your setup or enrich_dep_tree hook so the expensive build_index() runs only when something on disk has actually changed.
Note
This is a convention, not an API. pytest-impacted does not validate or manage .pytest-impacted-cache/ — extensions own their own state. A future release may introduce an integrated Cache service that handles invalidation automatically; until then, the filesystem convention above is the recommended pattern and keeps extensions consistent with each other.
Warning
Do not commit .pytest-impacted-cache/ to version control. Add it to .gitignore in the extension's project template, and document the recommendation in your extension's README so users know what it is when they see it appear.
Error handling
The extension system is designed to be fault-tolerant:
- Import errors: If an extension package fails to import, it is skipped with a warning log. Other extensions and built-in strategies continue to work.
- Instantiation errors: If a strategy's
__init__raises an exception, the extension is skipped. - Invalid classes: If an entry point resolves to a class without
find_impacted_tests, it is skipped with a warning.
Extensions never prevent the core pytest-impacted functionality from working.
Testing extensions
Extensions are just Python classes, so they can be unit-tested in isolation. Two patterns work well:
Unit-testing find_impacted_tests directly
Construct a small networkx.DiGraph by hand and invoke the strategy's method directly. This is fast, hermetic, and doesn't require a real project layout.
import networkx as nx
from my_extension.strategy import MyStrategy
def test_my_strategy_returns_impacted_tests():
# Build a minimal dep graph: edges point from imported module to its dependents
dep_tree = nx.DiGraph()
dep_tree.add_edge("mypkg.core", "mypkg.api")
dep_tree.add_edge("mypkg.api", "tests.test_api")
strategy = MyStrategy()
impacted = strategy.find_impacted_tests(
changed_files=["mypkg/core.py"],
impacted_modules=["mypkg.core"],
ns_module="mypkg",
dep_tree=dep_tree,
)
assert "tests.test_api" in impacted
Tip
The graph's inverted edge direction (imported module → dependents) is what makes resolve_impacted_tests(["mypkg.core"], dep_tree) return everything that transitively depends on mypkg.core. A handful of add_edge() calls is usually enough to cover your strategy's branches.
Integration testing with pytester
For end-to-end coverage — including entry-point discovery and CLI flag registration — use pytest's built-in pytester fixture. Patch importlib.metadata.entry_points to inject your strategy without having to pip install it.
from unittest.mock import MagicMock, patch
from pytest_impacted.extensions import clear_extension_cache
from my_extension.strategy import MyStrategy
@patch("pytest_impacted.extensions.importlib.metadata.entry_points")
def test_extension_discovered_by_plugin(mock_eps, pytester):
ep = MagicMock()
ep.name = "my_extension"
ep.load.return_value = MyStrategy
mock_eps.return_value = [ep]
clear_extension_cache()
pytester.makepyfile(test_smoke="def test_ok(): pass")
result = pytester.runpytest("-v", "--impacted", "--impacted-module=pytest_impacted")
result.stdout.fnmatch_lines(["*extensions=my_extension*"])
See tests/test_extensions.py and tests/test_extension_integration.py in the pytest-impacted repo for the canonical patterns used by the built-in test suite.