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(),
)
get_impacted_tests() returns the impacted test files, or None when nothing is impacted. It raises pytest_impacted.git.GitUnavailableError when git cannot run, because then the changes are unknown — treat that as "run everything", never as "nothing to run".
The strategy you pass replaces the default pipeline — the AST, conftest and dependency-file strategies included, and watch_dep_files / invalidate_all_patterns / conftest_imports then have no effect. To add to the defaults instead, pass CompositeImpactStrategy([*get_default_strategies(), MyCustomStrategy()]) (both from pytest_impacted.strategies).
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.
changed_files lists every path git reports as changed, relative to the project root — except files living outside it (a monorepo whose git root sits above the pytest rootdir), which arrive as absolute paths. It includes deleted files: a removed conftest.py or lockfile still matters. So resolve an entry against root_dir only when it is relative, and check that it exists before reading it. Paths always use / separators, on every platform.
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.31"]
[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; the lifecycle hooks (enrich_dep_tree, setup, teardown) are optional and simply skipped when absent:
# 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 [...]
When the entry point is loaded, validate_strategy_class checks that it resolves to a class whose find_impacted_tests accepts changed_files, impacted_modules and ns_module (or **kwargs). The pipeline also passes tests_package, root_dir, session and dep_tree by keyword, so accept those too (or **kwargs) — a missing one fails the run with a TypeError, deliberately loudly. StrategyProtocol is a runtime-checkable typing.Protocol describing the same shape, handy for isinstance assertions in your own tests.
impacted_modules starts with the graph nodes the changed .py files resolve to, by each node's path, in the order of changed_files — each a node of dep_tree as it was before your enrich_dep_tree ran. Besides modules of ns_module and the tests dir, that includes the conftests above both, which are neither application code nor tests: conftest, backend.conftest, or, when every importable name is taken by another file, a name with a leading dot (.mysite.conftest), which no import spells. If you parse such a file with parse_file_imports, pass the name without the dot (name.removeprefix(".")), or its relative imports resolve to nothing. It also includes (since 0.33), flagged external=True, modules outside both directories that an analysed module imports (testing.factories), and, for a changed file the cached graph lacks — a deleted module, typically — a node added to this run's copy, linked to whatever still imports it by name. Check a module's file name, or its path attribute, rather than rebuilding a path from the dotted name.
Then (since 0.34), sorted, come the other nodes inside the package of each changed __init__.py — edited, added or deleted (pytest_impacted.graph.package_members()) — because importing any of them runs the __init__.py first: a sub-package's __init__.py brings in each module beneath it, and the package root's, the whole package. These are found after your enrich_dep_tree ran, so a node you add counts too: by its path inside the package, or by its name when its file is elsewhere (generated, say). A node that is not a string, or has no path (or one that is not a string or path), is never one, so every module in impacted_modules still has a path. A module importing a missing module inside a package (try: import app.core.fast) is not a member but a dependent: the run's graph has an edge to it from the changed __init__.py (one inside the rootdir; never on the cached graph, where an edit to anything the __init__.py imports would then reach it), flagged runs_init=True. Such an edge only reaches: the built-in rules do not count it when deciding whether application code depends on a module, so it never turns a conftest rule opt-in — they look for it only when dep_tree.graph["runs_init"] is set, so set that too if you add one. The same goes (since 0.35) for an edge flagged assumed_root=True: an import the core graph resolved only from a directory above the analysed ones that no module name is rooted at (a package itself, like src/ holding an __init__.py, or inside one) — that directory may or may not be on sys.path. dep_tree.graph["assumed_roots"] maps each such directory to the names leading from it to an analysed directory (gateways) and whether any import spells one (active). pytest_impacted.resolve_impacted_tests() walks the graph once from all of them together; if you traverse it yourself, start from them all at once too (pytest_impacted.graph.reached_from(impacted_modules, dep_tree)), not one walk per module — a package root's __init__.py can make them thousands.
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
Discovered extensions are listed in the pytest report header (including ones disabled with --impacted-disable-ext):
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. For many modules at once — impacted_modules can be thousands after a package root's __init__.py changes — use pytest_impacted.graph.reached_from(modules, dep_tree), one multi-source traversal.
Extension utilities
Beyond resolve_impacted_tests, three 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, root_dir=None)— walks a Python package and returns a{module_name: file_path}dict of absolute paths, the package's own__init__.pyincluded when it has one, keyed by the package's importable name (appforsrc/app). Uses the same filesystem-based discovery pytest-impacted uses internally (handles src-layout and sub-directories without__init__.py, which import as namespace packages, and LRU-caches results). Passrequire_init=Falsefor test directories that may not have__init__.pyfiles at all, so module names follow the directory path, and pass theroot_diryour hook received so the scan does not depend on the working directory. Preferdiscover_project_modulesbelow when you need the package and the tests dir together. -
discover_project_modules(package, tests_package=None, root_dir=None)(since 0.32) — the package and tests dir together, plus the conftests above them (pytest loads those too), as the core graph sees them, returned as aProjectModules(modules, aliases)named tuple.modulesmaps each file's one canonical name to its path;aliasesmaps every other name the same file can be imported under to that canonical name — a tests dir inside the package is walked as bothapp.tests.xandtests.x, andsrc/company/appmay be imported asapp.xorcompany.app.x. Graph nodes carry canonical names only, so map an import throughaliases.get(name, name)before adding an edge; the graph'sdep_tree.graph["aliases"]holds this mapping plus the names its own lookups add. Those lookups are where a name rooted at a package above the analysed directory comes from —app.xforsrc/app/x.pybelow asrc/__init__.py, whose canonical name issrc.app.x: it is indep_tree.graph["aliases"]once some module imports it, and never indiscover_project_modules().aliases. For a name no source file spells (one from your own configuration, say),pytest_impacted.graph.nodes_named(names, dep_tree)(since 0.35) returns the nodes it may mean, looking on disk as well. One exception: when two conftests above the package could both be imported under one name (y.conftestfory/conftest.pyand, withx/onsys.path, forx/y/conftest.py), the core graph links an import of that name to each of them, because which file it means depends onsys.pathand the import mode. That mapping is not public, so an edge built fromaliasesalone reaches only the file that holds the name. This also means a test module in a tests dir inside the package is namedapp.tests.test_xinimpacted_modulesand in the graph, nottests.test_x. The conftests above the two directories (conftest,backend.conftest; since 0.32.1) are inmodulestoo, though they are neither application code nor tests: check for the file nameconftest.pybefore classifying a module. One with no free importable name is keyed with a leading dot (.mysite.conftest); parse it undername.removeprefix("."). Inside a hook, the graph you receive has these files as nodes, each with itspath, at no cost — plus theexternalmodules they import and, for a run, the changed files linked in; use this function outside one. -
parse_file_imports(file_path, module_name, is_package=False)— AST-parses a Python file and returns a sortedlist[str]of candidate module names. Forfrom pkg import nameit returns bothpkgandpkg.name: without importingpkg(which never happens at analysis time) the parser cannot tell a submodule from a symbol, so map each entry through the aliases and keep it only if it is a node ofdep_tree(discover_project_modules()lacks theexternalmodules the graph follows), asbuild_dep_tree()does, before treating it as a module. Filtering againstdiscover_submodules()alone drops imports of tests-dir modules, of the conftests above the package, and of alias spellings such ascompany.app.x. Relative imports are resolved to absolute names; conditional imports (if TYPE_CHECKING,try/except,match, function and class bodies) are included;from pkg import *contributes onlypkg. Passis_package=Truefor an__init__.py— relative imports in a package resolve against the package itself, not its parent, anddiscover_submoduleshands you__init__.pypaths for every regular package (a namespace package has no file, so it is not listed; its modules are).
Example: a strategy that enumerates all source files and scans them for a custom pattern:
from pytest_impacted import ImpactStrategy, parse_file_imports
class MyScanningStrategy(ImpactStrategy):
def find_impacted_tests(self, changed_files, impacted_modules, ns_module, *, dep_tree, **kwargs):
# Every file the core graph knows is a node with its ``path``; other spellings are aliases
aliases = dep_tree.graph["aliases"]
for module_name, file_path in dep_tree.nodes(data="path"):
if file_path is None:
continue # a node an extension added without a file
candidates = parse_file_imports(
file_path, module_name.removeprefix("."), is_package=file_path.endswith("__init__.py")
)
imports = {aliases.get(name, name) for name in candidates if aliases.get(name, name) in dep_tree}
# ... do something with imports ...
return []
Tip
discover_submodules is LRU-cached by (package, require_init, canonical_root(root_dir)) — the root is resolved to an absolute, symlink-free path before the lookup, so None, "." and an absolute path to the same project share one entry while two different projects can never collide. The cache is cleared by pytest_impacted.strategies.clear_dep_tree_cache() (or pytest_impacted.traversal.clear_discovery_cache() for the discovery cache alone).
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, 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 = {}
aliases = dep_tree.graph["aliases"]
for module_name, file_path in dep_tree.nodes(data="path"):
if file_path is None:
continue
candidates = parse_file_imports(
file_path, module_name.removeprefix("."), is_package=file_path.endswith("__init__.py")
)
self._index[module_name] = {aliases.get(n, n) for n in candidates if aliases.get(n, n) in dep_tree}
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 setup is not passed (both are final by then — see the ordering under enrich_dep_tree). 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) and the graph itself already names every file the core knows — each node carries its path, and dep_tree.graph["aliases"] maps the other spellings — so you can scan them (with parse_file_imports when you need a file's 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
# 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 = {name: path for name, path in dep_tree.nodes(data="path") if path}
# 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)
# 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 → link in the changed files the graph lacks, and each changed __init__.py to the importers of missing modules inside its package → resolve the changed files to its nodes → enrich_dep_tree(all strategies) → add the modules inside the package of a changed __init__.py (together, impacted_modules) → 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 (maxsize=8) by (ns_module, tests_package, canonical_root(root_dir)). 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 hands enrich_dep_tree a pytest_impacted.strategies.run_copy() of the cached graph — graph-level values such as graph["aliases"] included, which a plain DiGraph.copy() would share — so the graph you mutate is yours for this run only. Take a run_copy too if you call cached_build_dep_tree yourself and mutate the result.
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.
Node attributes (since 0.31). Every node the built-in graph discovers carries its absolute source file in a path attribute (dep_tree.nodes["mypkg.core"]["path"]). Set it on nodes you add — dep_tree.add_node("mypkg.generated", path="/abs/path/generated.py") — whenever the node has a file. Built-in strategies use it to place a module on disk (for example, to find the tests under a conftest.py, or the modules inside a changed package). Without it (or with a path that is not a string or path), the conftest rule falls back to rebuilding the path from the dotted name, which cannot see a src-layout src/ prefix; package membership has no such fallback, so the node is never a member of a changed package. Modules pytest loads as plugins through pytest_plugins carry pytest_plugin=True; a change reaching such a node marks every test impacted, because pytest registers plugins for the whole session — set it on plugin nodes you add. Conftests are graph nodes too (ones above the analysed packages are named conftest, backend.conftest, …), but is_test_module() is false for them: they hold fixtures, never tests. Modules no walk finds (since 0.33) carry external=True: ones outside the analysed directories that an analysed module imports, found on disk under the rootdir, and changed files a run links in (see impacted_modules above). Whether a node is a test module to run is its test attribute when set, else its name — or, for an external node, its name or its file's (graph.is_test_node(dep_tree, node)). Built-in external modules carry test, judged by their path from the rootdir, and false for a deleted file linked in for a run. The built-in conftest rules count an external module as application code when application code depends on it, and as test code otherwise. For a node you add for a file outside both directories, set external=True to get the same treatment, and test=True if it is a test module to run.
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. - Missing required options: A
ConfigOption(required=True)with no value skips the extension with a warning. - Lifecycle hooks: Exceptions from
enrich_dep_tree,setuporteardownare logged and that phase is skipped for the strategy; the others carry on.
A failure to load an entry point (an import error, or an entry point that is not a strategy class) happens while pytest registers options, before it configures logging, so it is printed to stderr on every run (an import error with its traceback). The other warnings are log records on the pytest_impacted.extensions / pytest_impacted.strategies loggers emitted during collection, not shown in pytest's terminal output by default (use --log-cli-level=WARNING to see them).
An exception from find_impacted_tests is not caught: it propagates and fails the run, because silently dropping a strategy's answer could skip tests that should run. Handle your own errors there.
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") # the header is written with or without --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.