pytest-selective 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_selective/__init__.py +0 -0
- pytest_selective/__main__.py +3 -0
- pytest_selective/ast_imports.py +75 -0
- pytest_selective/ci.py +242 -0
- pytest_selective/hygiene.py +118 -0
- pytest_selective/impact_audit.py +104 -0
- pytest_selective/impact_audit_plugin.py +217 -0
- pytest_selective/impact_cli.py +432 -0
- pytest_selective/impact_declarations.py +123 -0
- pytest_selective/impact_explanation.py +481 -0
- pytest_selective/impact_first_party.py +362 -0
- pytest_selective/impact_graph.py +885 -0
- pytest_selective/impact_models.py +491 -0
- pytest_selective/impact_name_scope.py +498 -0
- pytest_selective/impact_selection.py +242 -0
- pytest_selective/input_policy.py +27 -0
- pytest_selective/local.py +78 -0
- pytest_selective/py.typed +0 -0
- pytest_selective/pytest_expansion.py +535 -0
- pytest_selective/pytest_sharding.py +269 -0
- pytest_selective/test_inventory.py +39 -0
- pytest_selective/typecheck_scope.py +111 -0
- pytest_selective/working_tree.py +43 -0
- pytest_selective-0.1.0.dist-info/METADATA +263 -0
- pytest_selective-0.1.0.dist-info/RECORD +28 -0
- pytest_selective-0.1.0.dist-info/WHEEL +4 -0
- pytest_selective-0.1.0.dist-info/entry_points.txt +3 -0
- pytest_selective-0.1.0.dist-info/licenses/LICENSE +202 -0
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Parse Python for static analysis and extract imports without walking expressions."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import warnings
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_ast(source: str | bytes, filename: str = "<unknown>") -> ast.Module:
|
|
11
|
+
"""Parse source independently of the caller's warning policy.
|
|
12
|
+
|
|
13
|
+
Compilation warnings belong to test execution, not static analysis: promoting
|
|
14
|
+
an invalid escape warning to an error would otherwise erase valid imports.
|
|
15
|
+
Bytes retain Python's encoding-cookie detection, and actual syntax errors
|
|
16
|
+
propagate unchanged. The caller's warning filters are restored on either path.
|
|
17
|
+
"""
|
|
18
|
+
with warnings.catch_warnings():
|
|
19
|
+
warnings.simplefilter("ignore")
|
|
20
|
+
return ast.parse(source, filename=filename)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def iter_imports(tree: ast.Module) -> Iterator[ast.Import | ast.ImportFrom]:
|
|
24
|
+
"""Yield all imports in the same breadth-first order as ``ast.walk``."""
|
|
25
|
+
pending: deque[ast.AST] = deque([tree])
|
|
26
|
+
while pending:
|
|
27
|
+
node = pending.popleft()
|
|
28
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
29
|
+
yield node
|
|
30
|
+
else:
|
|
31
|
+
# Parsed Python expressions cannot contain import statements.
|
|
32
|
+
# Keep the extra level for exception handlers and match cases so
|
|
33
|
+
# pruning expressions preserves ast.walk's finding order.
|
|
34
|
+
pending.extend(
|
|
35
|
+
child
|
|
36
|
+
for child in ast.iter_child_nodes(node)
|
|
37
|
+
if isinstance(child, (ast.stmt, ast.ExceptHandler, ast.match_case))
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def import_candidates(node: ast.Import | ast.ImportFrom, importer_package: str) -> set[str]:
|
|
42
|
+
"""
|
|
43
|
+
Dotted module paths an import statement might load.
|
|
44
|
+
|
|
45
|
+
``from X import Y`` can bind either attribute ``Y`` of module ``X``
|
|
46
|
+
or submodule ``X.Y`` — both are candidates; only ones that resolve
|
|
47
|
+
to real files are judged.
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(node, ast.Import):
|
|
50
|
+
return {alias.name for alias in node.names}
|
|
51
|
+
if node.level:
|
|
52
|
+
package_parts = importer_package.split(".") if importer_package else []
|
|
53
|
+
if node.level - 1 > len(package_parts):
|
|
54
|
+
return set()
|
|
55
|
+
prefix_parts = package_parts[: len(package_parts) - (node.level - 1)]
|
|
56
|
+
base = ".".join([*prefix_parts, node.module] if node.module else prefix_parts)
|
|
57
|
+
else:
|
|
58
|
+
base = node.module or ""
|
|
59
|
+
candidates = {base} if base else set()
|
|
60
|
+
for alias in node.names:
|
|
61
|
+
if alias.name != "*":
|
|
62
|
+
candidates.add(f"{base}.{alias.name}" if base else alias.name)
|
|
63
|
+
return candidates
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def module_file(repo_root: Path, dotted: str) -> Path | None:
|
|
67
|
+
"""
|
|
68
|
+
Resolve a dotted module path to a repo file, if it exists.
|
|
69
|
+
"""
|
|
70
|
+
base = repo_root.joinpath(*dotted.split("."))
|
|
71
|
+
if base.with_suffix(".py").is_file():
|
|
72
|
+
return base.with_suffix(".py")
|
|
73
|
+
if (base / "__init__.py").is_file():
|
|
74
|
+
return base / "__init__.py"
|
|
75
|
+
return None
|
pytest_selective/ci.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Select complete CI lanes while preserving each lane's execution/evidence contract."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from pydantic import Field, field_validator, model_validator
|
|
13
|
+
|
|
14
|
+
from pytest_selective.impact_graph import DependencyDeclarationError, GraphCache, git, matches, within
|
|
15
|
+
from pytest_selective.impact_models import ImpactConfig, ImpactModel
|
|
16
|
+
from pytest_selective.impact_selection import affected
|
|
17
|
+
from pytest_selective.input_policy import DEFAULT_GLOBAL_INPUTS
|
|
18
|
+
|
|
19
|
+
JOB_INPUT = re.compile(r"(?P<path>[^#*?\[\]]+)#(?P<job>[A-Za-z_][A-Za-z0-9_-]*)")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def job_input(value: str) -> tuple[str, str] | None:
|
|
23
|
+
"""`<workflow path>#<job id>` names one GitHub Actions job; any other input is a glob."""
|
|
24
|
+
if "#" not in value:
|
|
25
|
+
return None
|
|
26
|
+
match = JOB_INPUT.fullmatch(value)
|
|
27
|
+
if match is None:
|
|
28
|
+
raise ValueError(f"{value!r}: write a job input as `<workflow path>#<job id>`, without wildcards")
|
|
29
|
+
return match["path"], match["job"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Lane(ImpactModel):
|
|
33
|
+
"""Test roots and additional inputs of one indivisible CI execution."""
|
|
34
|
+
|
|
35
|
+
roots: list[str] = Field(default_factory=list)
|
|
36
|
+
inputs: list[str] = Field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
@field_validator("roots", "inputs")
|
|
39
|
+
@classmethod
|
|
40
|
+
def relative_paths(cls, values: list[str]) -> list[str]:
|
|
41
|
+
return ImpactConfig.relative_paths(values)
|
|
42
|
+
|
|
43
|
+
@field_validator("inputs")
|
|
44
|
+
@classmethod
|
|
45
|
+
def valid_job_inputs(cls, values: list[str]) -> list[str]:
|
|
46
|
+
for value in values:
|
|
47
|
+
job_input(value)
|
|
48
|
+
return values
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LaneConfig(ImpactModel):
|
|
52
|
+
graph: ImpactConfig
|
|
53
|
+
lanes: dict[str, Lane]
|
|
54
|
+
global_inputs: list[str] = Field(default_factory=lambda: [*DEFAULT_GLOBAL_INPUTS, ".github/**"])
|
|
55
|
+
|
|
56
|
+
@field_validator("lanes")
|
|
57
|
+
@classmethod
|
|
58
|
+
def safe_output_names(cls, lanes: dict[str, Lane]) -> dict[str, Lane]:
|
|
59
|
+
if not lanes or any(not re.fullmatch(r"[a-z][a-z0-9_]*", name) for name in lanes):
|
|
60
|
+
raise ValueError("provide lanes named with lowercase letters, digits and underscores")
|
|
61
|
+
if any(not lane.roots and not lane.inputs for lane in lanes.values()):
|
|
62
|
+
raise ValueError("each lane needs test roots or explicit inputs")
|
|
63
|
+
return lanes
|
|
64
|
+
|
|
65
|
+
@model_validator(mode="after")
|
|
66
|
+
def job_inputs_can_narrow(self) -> "LaneConfig":
|
|
67
|
+
# A global input runs every lane before job inputs are consulted, so the
|
|
68
|
+
# job input would silently never narrow anything.
|
|
69
|
+
for lane in self.lanes.values():
|
|
70
|
+
for value in lane.inputs:
|
|
71
|
+
scoped = job_input(value)
|
|
72
|
+
if scoped and matches(scoped[0], self.global_inputs):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"{value!r} cannot narrow: {scoped[0]} matches a global input, which runs every lane; "
|
|
75
|
+
"set global_inputs without it"
|
|
76
|
+
)
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class WorkflowJobs:
|
|
81
|
+
"""Whether one job of a workflow file changed, including the settings every job inherits."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, repo: Path, base: str, head: str) -> None:
|
|
84
|
+
self.repo = repo
|
|
85
|
+
self.base = base
|
|
86
|
+
self.head = head
|
|
87
|
+
self.documents: dict[str, tuple[dict[str, Any] | None, dict[str, Any] | None]] = {}
|
|
88
|
+
self.heads: dict[str, dict[str, Any] | None] = {}
|
|
89
|
+
|
|
90
|
+
def changed(self, path: str, job: str) -> bool:
|
|
91
|
+
if path not in self.documents:
|
|
92
|
+
self.documents[path] = (self.load(self.base, path), self.load(self.head, path))
|
|
93
|
+
before, after = (self.view(document, job) for document in self.documents[path])
|
|
94
|
+
if before[1] is None and after[1] is None:
|
|
95
|
+
raise DependencyDeclarationError(f"{path}#{job}: {path} has no job {job!r} at the base or the head")
|
|
96
|
+
return before != after
|
|
97
|
+
|
|
98
|
+
def require(self, path: str, job: str) -> None:
|
|
99
|
+
"""Reject a misspelled job before the change it should have caught."""
|
|
100
|
+
if path not in self.heads:
|
|
101
|
+
self.heads[path] = self.load(self.head, path)
|
|
102
|
+
if self.view(self.heads[path], job)[1] is None:
|
|
103
|
+
raise DependencyDeclarationError(f"{path}#{job}: {path} has no job {job!r}")
|
|
104
|
+
|
|
105
|
+
def load(self, revision: str, path: str) -> dict[str, Any] | None:
|
|
106
|
+
"""The parsed workflow, or None when the revision does not have the file."""
|
|
107
|
+
try:
|
|
108
|
+
text = git(self.repo, "show", f"{revision}:{path}")
|
|
109
|
+
except subprocess.CalledProcessError:
|
|
110
|
+
return None
|
|
111
|
+
# Malformed YAML raises, so the planner falls back to every lane.
|
|
112
|
+
document = yaml.safe_load(text)
|
|
113
|
+
if not isinstance(document, dict) or not isinstance(document.get("jobs"), dict):
|
|
114
|
+
raise ValueError(f"{path} at {revision} is not a workflow with a jobs mapping")
|
|
115
|
+
return document
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def view(document: dict[str, Any] | None, job: str) -> tuple[dict[str, Any], Any]:
|
|
119
|
+
# Triggers, env, defaults, permissions and concurrency apply to every job.
|
|
120
|
+
if document is None:
|
|
121
|
+
return {}, None
|
|
122
|
+
return {key: value for key, value in document.items() if key != "jobs"}, document["jobs"].get(job)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class LanePlan(ImpactModel):
|
|
126
|
+
lanes: dict[str, bool]
|
|
127
|
+
selected: list[str] = Field(default_factory=list)
|
|
128
|
+
changed: list[str] = Field(default_factory=list)
|
|
129
|
+
reason: str
|
|
130
|
+
lane_reasons: dict[str, list[str]] = Field(default_factory=dict)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def plan(repo: Path, config: LaneConfig, base: str, head: str, *, full: bool = False) -> LanePlan:
|
|
134
|
+
"""A missing proof runs every lane; bad declarations remain actionable failures."""
|
|
135
|
+
|
|
136
|
+
def all_lanes(reason: str, *, selected: list[str] | None = None, changed: list[str] | None = None) -> LanePlan:
|
|
137
|
+
return LanePlan(
|
|
138
|
+
lanes=dict.fromkeys(config.lanes, True),
|
|
139
|
+
selected=selected or [],
|
|
140
|
+
changed=changed or [],
|
|
141
|
+
reason=reason,
|
|
142
|
+
lane_reasons={name: [reason] for name in config.lanes},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if full:
|
|
146
|
+
return all_lanes("explicit full-suite checkpoint")
|
|
147
|
+
cache = None
|
|
148
|
+
try:
|
|
149
|
+
cache = GraphCache(repo / ".test-impact/cache.sqlite3")
|
|
150
|
+
report = affected(repo, base, head, config.graph, cache)
|
|
151
|
+
# Removed tests are absent from report.selected, but their lane still needs
|
|
152
|
+
# completeness checks. Consult the cached base inventory, not filename guesses.
|
|
153
|
+
before = cache.snapshot(repo, base, config.graph)
|
|
154
|
+
affected_tests = set(report.selected) | (set(report.changed) & set(before.tests))
|
|
155
|
+
unmapped = sorted(
|
|
156
|
+
test
|
|
157
|
+
for test in affected_tests
|
|
158
|
+
if not any(within(test, root) for lane in config.lanes.values() for root in lane.roots)
|
|
159
|
+
)
|
|
160
|
+
if unmapped:
|
|
161
|
+
return all_lanes(
|
|
162
|
+
"full-suite fallback: unmapped affected tests: " + ", ".join(unmapped),
|
|
163
|
+
selected=report.selected,
|
|
164
|
+
changed=report.changed,
|
|
165
|
+
)
|
|
166
|
+
global_paths = [path for path in report.changed if matches(path, config.global_inputs)]
|
|
167
|
+
changed = set(report.changed)
|
|
168
|
+
workflows = WorkflowJobs(repo, base, head)
|
|
169
|
+
lanes = {}
|
|
170
|
+
evidence = {}
|
|
171
|
+
# Select whole lanes: validators may require complete conformance evidence.
|
|
172
|
+
for name, lane in config.lanes.items():
|
|
173
|
+
reasons = [f"global input: {path}" for path in global_paths]
|
|
174
|
+
if report.mode == "full":
|
|
175
|
+
reasons.append("all graph test files affected")
|
|
176
|
+
reasons.extend(
|
|
177
|
+
f"affected test: {test}" for test in report.selected if any(within(test, root) for root in lane.roots)
|
|
178
|
+
)
|
|
179
|
+
reasons.extend(
|
|
180
|
+
f"changed root input: {path}"
|
|
181
|
+
for path in report.changed
|
|
182
|
+
if path not in report.selected and any(within(path, root) for root in lane.roots)
|
|
183
|
+
)
|
|
184
|
+
globs = [value for value in lane.inputs if job_input(value) is None]
|
|
185
|
+
reasons.extend(f"explicit input: {path}" for path in report.changed if matches(path, globs))
|
|
186
|
+
unchanged_jobs = []
|
|
187
|
+
for value in lane.inputs:
|
|
188
|
+
scoped = job_input(value)
|
|
189
|
+
if scoped is None:
|
|
190
|
+
continue
|
|
191
|
+
if scoped[0] not in changed:
|
|
192
|
+
workflows.require(*scoped)
|
|
193
|
+
elif workflows.changed(*scoped):
|
|
194
|
+
reasons.append(f"explicit input: {value}")
|
|
195
|
+
else:
|
|
196
|
+
unchanged_jobs.append(f"unchanged job: {value}")
|
|
197
|
+
lanes[name] = bool(reasons)
|
|
198
|
+
evidence[name] = reasons or unchanged_jobs or ["no affected tests or changed inputs"]
|
|
199
|
+
return LanePlan(
|
|
200
|
+
lanes=lanes,
|
|
201
|
+
selected=report.selected,
|
|
202
|
+
changed=report.changed,
|
|
203
|
+
reason="global CI/dependency input" if global_paths else "; ".join(report.reasons),
|
|
204
|
+
lane_reasons=evidence,
|
|
205
|
+
)
|
|
206
|
+
except DependencyDeclarationError:
|
|
207
|
+
raise
|
|
208
|
+
except Exception as error:
|
|
209
|
+
# A broken cache, missing Git history, or unreviewed pytest policy must
|
|
210
|
+
# never turn a failed planner into a passing skipped test job.
|
|
211
|
+
return all_lanes(f"full-suite fallback: {error}")
|
|
212
|
+
finally:
|
|
213
|
+
if cache is not None:
|
|
214
|
+
cache.close()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def main(argv: list[str] | None = None) -> int:
|
|
218
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
219
|
+
parser.add_argument("--config", type=Path, required=True)
|
|
220
|
+
parser.add_argument("--repo", type=Path, default=Path("."))
|
|
221
|
+
parser.add_argument("--base", required=True)
|
|
222
|
+
parser.add_argument("--head", default="HEAD")
|
|
223
|
+
parser.add_argument("--full", action="store_true")
|
|
224
|
+
parser.add_argument("--github-output", type=Path)
|
|
225
|
+
args = parser.parse_args(argv)
|
|
226
|
+
try:
|
|
227
|
+
config = LaneConfig.model_validate_json(args.config.read_text(encoding="utf-8"))
|
|
228
|
+
result = plan(args.repo, config, args.base, args.head, full=args.full)
|
|
229
|
+
except (ValueError, OSError, DependencyDeclarationError) as error:
|
|
230
|
+
print(f"Invalid lane configuration/dependency: {error}", file=sys.stderr)
|
|
231
|
+
return 2
|
|
232
|
+
print(result.model_dump_json(indent=2))
|
|
233
|
+
if args.github_output:
|
|
234
|
+
with args.github_output.open("a", encoding="utf-8") as output:
|
|
235
|
+
for name, enabled in result.lanes.items():
|
|
236
|
+
output.write(f"{name}={str(enabled).lower()}\n")
|
|
237
|
+
output.write(f"lanes={json.dumps(result.lanes)}\n")
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enforce the test-file isolation invariant: test modules never import test modules.
|
|
3
|
+
|
|
4
|
+
A test module (``test_*.py`` / ``*_test.py``) that imports another test module
|
|
5
|
+
couples the two files' behavior, so re-running only the changed file no longer
|
|
6
|
+
proves the unchanged one still passes. Keeping test modules import-isolated is
|
|
7
|
+
what makes file-level CI test selection sound: a diff touching only
|
|
8
|
+
``tests/x/test_y.py`` can re-run exactly that file.
|
|
9
|
+
|
|
10
|
+
Shared code belongs in non-test modules instead — ``conftest.py`` for pytest
|
|
11
|
+
fixtures at the narrowest useful scope, and plainly named helper modules
|
|
12
|
+
(``helpers*.py``, ``fakes*.py``, ``factories*.py``, ...) either inside the test
|
|
13
|
+
directory that owns them or in the repo's shared test-resource packages
|
|
14
|
+
(``tests/helpers/``, ``tests/fakes/``, ``tests/fixtures/``, ``tests/test_data/``).
|
|
15
|
+
|
|
16
|
+
The check is import-graph based, not name based: ``from tests.test_ci.helpers
|
|
17
|
+
import x`` is fine (the imported *module* is ``helpers``) even though a package
|
|
18
|
+
on the path is named ``test_ci``; ``from tests.test_ci.test_foo import x`` is a
|
|
19
|
+
violation because the imported module is the test file ``test_foo``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from functools import cache
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, ConfigDict
|
|
28
|
+
|
|
29
|
+
from pytest_selective.ast_imports import import_candidates, iter_imports, module_file, parse_ast
|
|
30
|
+
from pytest_selective.test_inventory import TEST_FILE_PATTERNS, is_test_module_name
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestImportViolation(BaseModel):
|
|
34
|
+
"""One test module importing another test module."""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(frozen=True)
|
|
37
|
+
|
|
38
|
+
importer: Path
|
|
39
|
+
line: int
|
|
40
|
+
imported_module: str
|
|
41
|
+
|
|
42
|
+
def __str__(self) -> str:
|
|
43
|
+
return f"{self.importer}:{self.line}: imports test module {self.imported_module!r}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_test_module(path: Path) -> bool:
|
|
47
|
+
return is_test_module_name(path.name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def discover_test_modules(test_roots: list[Path]) -> list[Path]:
|
|
51
|
+
discovered: set[Path] = set()
|
|
52
|
+
for root in test_roots:
|
|
53
|
+
if root.is_file():
|
|
54
|
+
if is_test_module(root):
|
|
55
|
+
discovered.add(root)
|
|
56
|
+
continue
|
|
57
|
+
for pattern in TEST_FILE_PATTERNS:
|
|
58
|
+
discovered.update(root.rglob(pattern))
|
|
59
|
+
return sorted(discovered)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def find_test_import_violations(
|
|
63
|
+
repo_root: Path,
|
|
64
|
+
test_roots: list[Path],
|
|
65
|
+
) -> list[TestImportViolation]:
|
|
66
|
+
"""
|
|
67
|
+
Scan every test module under the roots for imports of other test
|
|
68
|
+
modules.
|
|
69
|
+
|
|
70
|
+
``repo_root`` is the directory absolute imports resolve against (the
|
|
71
|
+
pytest rootdir); ``test_roots`` are the directories holding test modules.
|
|
72
|
+
"""
|
|
73
|
+
repo_root = repo_root.resolve()
|
|
74
|
+
test_modules = discover_test_modules([repo_root / root for root in test_roots])
|
|
75
|
+
# Only modules under the scanned roots count as test modules: production
|
|
76
|
+
# code is free to end in `_test.py`-shaped names (e.g. a `stress_test`
|
|
77
|
+
# command) without tripping the invariant.
|
|
78
|
+
test_module_files = {test_file.resolve() for test_file in test_modules}
|
|
79
|
+
|
|
80
|
+
# Module resolution depends only on this scan's root and dotted name.
|
|
81
|
+
# Keep the cache local so subsequent scans see added/removed files.
|
|
82
|
+
@cache
|
|
83
|
+
def resolved_module(dotted: str) -> Path | None:
|
|
84
|
+
imported = module_file(repo_root, dotted)
|
|
85
|
+
return imported.resolve() if imported is not None else None
|
|
86
|
+
|
|
87
|
+
violations: list[TestImportViolation] = []
|
|
88
|
+
for test_file in test_modules:
|
|
89
|
+
resolved_test_file = test_file.resolve()
|
|
90
|
+
importer_package = ".".join(resolved_test_file.relative_to(repo_root).parent.parts)
|
|
91
|
+
tree = parse_ast(test_file.read_bytes(), filename=str(test_file))
|
|
92
|
+
for node in iter_imports(tree):
|
|
93
|
+
for dotted in sorted(import_candidates(node, importer_package)):
|
|
94
|
+
imported = resolved_module(dotted)
|
|
95
|
+
if imported is None or imported == resolved_test_file:
|
|
96
|
+
continue
|
|
97
|
+
if imported in test_module_files:
|
|
98
|
+
violations.append(
|
|
99
|
+
TestImportViolation(
|
|
100
|
+
importer=test_file.relative_to(repo_root),
|
|
101
|
+
line=node.lineno,
|
|
102
|
+
imported_module=dotted,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return violations
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def assert_test_file_isolation(repo_root: Path, test_roots: list[Path]) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Raise with every violation listed; the natural single-call consumer
|
|
111
|
+
API."""
|
|
112
|
+
violations = find_test_import_violations(repo_root, test_roots)
|
|
113
|
+
if violations:
|
|
114
|
+
summary = "\n".join(str(violation) for violation in violations)
|
|
115
|
+
raise AssertionError(
|
|
116
|
+
"test modules must not import other test modules "
|
|
117
|
+
f"(move shared code to conftest.py or a non-test helper module):\n{summary}"
|
|
118
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compare the files tests actually read with what the dependency graph lets them reach.
|
|
3
|
+
|
|
4
|
+
The graph sees imports and declarations only. A test that reads a repository file as data, runs a
|
|
5
|
+
script or loads a module by path is invisible to selection unless the input is declared, and
|
|
6
|
+
nothing else notices the omission: the test simply stops running when that input changes. The
|
|
7
|
+
``impact_audit_plugin`` records each test file's reads during the selected run; this module fails
|
|
8
|
+
on every read the HEAD graph cannot reach from that test. A new test is always selected in the
|
|
9
|
+
change that adds it, so its reads are audited when it is introduced, including reads in helpers.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pytest_selective.impact_declarations import match_inputs
|
|
15
|
+
from pytest_selective.impact_graph import matches, reachable
|
|
16
|
+
from pytest_selective.impact_models import (
|
|
17
|
+
AuditExemption,
|
|
18
|
+
AuditRecord,
|
|
19
|
+
AuditReport,
|
|
20
|
+
ImpactConfig,
|
|
21
|
+
Snapshot,
|
|
22
|
+
UndeclaredRead,
|
|
23
|
+
file_of,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
PLACEMENT_GUIDE = "docs/guides/test-dependency-placement.md"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_records(directory: Path) -> list[AuditRecord]:
|
|
30
|
+
records = [AuditRecord.model_validate_json(path.read_bytes()) for path in sorted(directory.glob("*.json"))]
|
|
31
|
+
if not records:
|
|
32
|
+
raise ValueError(f"no audit records in {directory}; was pytest run with --impact-audit-dir?")
|
|
33
|
+
return records
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _exempt_pairs(snapshot: Snapshot, exemptions: list[AuditExemption]) -> list[tuple[set[str], set[str]]]:
|
|
37
|
+
# The declaration glob language, so a stale exemption is a hard error exactly like a stale declaration.
|
|
38
|
+
files = set(snapshot.files)
|
|
39
|
+
pairs: list[tuple[set[str], set[str]]] = []
|
|
40
|
+
for index, exemption in enumerate(exemptions):
|
|
41
|
+
location = f"{snapshot.tree}:audit_exemptions[{index}]"
|
|
42
|
+
readers = set().union(*(match_inputs(pattern, files, location) for pattern in exemption.readers))
|
|
43
|
+
inputs = set().union(*(match_inputs(pattern, files, location) for pattern in exemption.inputs))
|
|
44
|
+
pairs.append((readers, inputs))
|
|
45
|
+
return pairs
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def audit(snapshot: Snapshot, records: list[AuditRecord], config: ImpactConfig) -> AuditReport:
|
|
49
|
+
"""Every recorded (test, input) pair the test's dependency closure does not contain."""
|
|
50
|
+
exempt = _exempt_pairs(snapshot, config.audit_exemptions)
|
|
51
|
+
reads: dict[str, set[str]] = {}
|
|
52
|
+
for record in records:
|
|
53
|
+
for test, paths in record.inputs.items():
|
|
54
|
+
reads.setdefault(test, set()).update(paths)
|
|
55
|
+
# Only the committed tree can be declared against: a file absent from HEAD has no edge to add.
|
|
56
|
+
reads = {test: paths & snapshot.files.keys() for test, paths in reads.items() if test in snapshot.files}
|
|
57
|
+
# An always-run test is selected by every change, so no input can skip it.
|
|
58
|
+
always = {test for test in reads if matches(test, config.always_run)}
|
|
59
|
+
undeclared: list[UndeclaredRead] = []
|
|
60
|
+
exempted = 0
|
|
61
|
+
for test, paths in sorted(reads.items()):
|
|
62
|
+
# `@contents:` nodes are the non-transitive edges of content declarations.
|
|
63
|
+
closure = {file_of(node) for node in reachable(snapshot.edges, {test})}
|
|
64
|
+
for path in sorted(paths - closure):
|
|
65
|
+
if test in always or any(test in readers and path in inputs for readers, inputs in exempt):
|
|
66
|
+
exempted += 1
|
|
67
|
+
else:
|
|
68
|
+
undeclared.append(UndeclaredRead(test=test, input=path))
|
|
69
|
+
return AuditReport(
|
|
70
|
+
head=snapshot.tree,
|
|
71
|
+
records=len(records),
|
|
72
|
+
audited_tests=len(reads),
|
|
73
|
+
recorded_reads=sum(len(paths) for paths in reads.values()),
|
|
74
|
+
exempted_reads=exempted,
|
|
75
|
+
undeclared=undeclared,
|
|
76
|
+
recorder_errors=sorted({error for record in records for error in record.errors}),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def summary(report: AuditReport) -> str:
|
|
81
|
+
"""Markdown for the GitHub step summary."""
|
|
82
|
+
lines = [
|
|
83
|
+
"### Test-impact read audit",
|
|
84
|
+
"",
|
|
85
|
+
f"{report.audited_tests} test files read {report.recorded_reads} tracked files "
|
|
86
|
+
f"({report.exempted_reads} exempted or always-run) across {report.records} pytest processes; "
|
|
87
|
+
f"{len(report.undeclared)} reads are unreachable in the dependency graph.",
|
|
88
|
+
]
|
|
89
|
+
if report.undeclared:
|
|
90
|
+
lines += [
|
|
91
|
+
"",
|
|
92
|
+
"A change to an undeclared input selects none of its readers. Declare each input on the file "
|
|
93
|
+
"that reads it: `# gv:test-dependency: <path>` for an import edge, or a "
|
|
94
|
+
"`<file>.gv-dependency.yaml` sidecar with `kind: content` for a file read as text. "
|
|
95
|
+
f"See `{PLACEMENT_GUIDE}`. Inputs another lane covers belong in `audit_exemptions`.",
|
|
96
|
+
"",
|
|
97
|
+
"| Test file | Undeclared input |",
|
|
98
|
+
"| --- | --- |",
|
|
99
|
+
*(f"| `{read.test}` | `{read.input}` |" for read in report.undeclared),
|
|
100
|
+
]
|
|
101
|
+
if report.recorder_errors:
|
|
102
|
+
lines += ["", "Recorder errors (the reads these events carried were not audited):", ""]
|
|
103
|
+
lines += [f"- `{error}`" for error in report.recorder_errors]
|
|
104
|
+
return "\n".join(lines) + "\n"
|