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
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Record which tracked repository files each test file reads, for ``gv-test-impact audit``.
|
|
3
|
+
|
|
4
|
+
Load it for one run from the command line or ``PYTEST_ADDOPTS``, never from pytest configuration:
|
|
5
|
+
|
|
6
|
+
pytest -p pytest_selective.impact_audit_plugin --impact-audit-dir impact-audit
|
|
7
|
+
|
|
8
|
+
A consumer's pytest configuration naming ``backend_common`` stops first-party pin narrowing, and the
|
|
9
|
+
recorder cannot change a test's outcome, so it needs no graph edge. Every pytest process (each shard
|
|
10
|
+
and each xdist worker) writes one ``AuditRecord`` file into the directory at session end.
|
|
11
|
+
|
|
12
|
+
A read is an ``open`` audit event (``open``, ``Path.read_text``/``read_bytes`` and ``io.open_code``,
|
|
13
|
+
which imports and path-loaded modules use) or a subprocess argument naming a tracked file. It
|
|
14
|
+
belongs to the test file being collected or run at the time. Reads outside both, such as conftest
|
|
15
|
+
import or coverage reporting, belong to every test through the graph's conftest and global edges
|
|
16
|
+
and are not recorded. What a subprocess itself reads is out of reach: the script's own declarations
|
|
17
|
+
own those inputs.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import uuid
|
|
25
|
+
from collections.abc import Generator, Iterable
|
|
26
|
+
from contextlib import contextmanager
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import pytest
|
|
30
|
+
|
|
31
|
+
from pytest_selective.impact_models import AuditRecord
|
|
32
|
+
|
|
33
|
+
#: The positions of the program and its arguments in each process-launch audit event.
|
|
34
|
+
_COMMAND_ARGUMENTS = {
|
|
35
|
+
"subprocess.Popen": (0, 1),
|
|
36
|
+
"os.system": (0,),
|
|
37
|
+
"os.exec": (0, 1),
|
|
38
|
+
"os.posix_spawn": (0, 1),
|
|
39
|
+
"os.spawn": (1, 2),
|
|
40
|
+
}
|
|
41
|
+
#: Splits shell command strings and ``--option=path`` arguments into candidate paths.
|
|
42
|
+
_WORD_SEPARATORS = re.compile(r"[\s=;|&<>()'\"]+")
|
|
43
|
+
#: Library code that reads the source of every module already imported in the process for its
|
|
44
|
+
#: own heuristics. What it reads depends on what earlier tests imported, never on the test.
|
|
45
|
+
#: Hypothesis seeds its generators with constants parsed from local modules (6.131+).
|
|
46
|
+
_PROCESS_WIDE_READERS = frozenset({"hypothesis.internal.constants_ast"})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _key(path: str) -> str:
|
|
50
|
+
"""The comparable spelling of a path: absolute, normalized and, on Windows, case-folded."""
|
|
51
|
+
return os.path.normcase(os.path.abspath(path))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _write_only(mode: object, flags: object) -> bool:
|
|
55
|
+
# Writing a tracked file is output, not input: the graph ignores write-only opens too.
|
|
56
|
+
if isinstance(mode, str):
|
|
57
|
+
return "+" not in mode and "r" not in mode and any(flag in mode for flag in "wax")
|
|
58
|
+
return mode is None and isinstance(flags, int) and flags & 3 == os.O_WRONLY
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_by_process_wide_reader() -> bool:
|
|
62
|
+
frame = sys._getframe(1)
|
|
63
|
+
while frame is not None:
|
|
64
|
+
if frame.f_globals.get("__name__") in _PROCESS_WIDE_READERS:
|
|
65
|
+
return True
|
|
66
|
+
frame = frame.f_back
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _words(value: object) -> Iterable[str]:
|
|
71
|
+
items = value if isinstance(value, (list, tuple)) else [value]
|
|
72
|
+
for item in items:
|
|
73
|
+
if not isinstance(item, (str, bytes, os.PathLike)):
|
|
74
|
+
continue
|
|
75
|
+
text = os.fsdecode(item)
|
|
76
|
+
yield text
|
|
77
|
+
yield from (word for word in _WORD_SEPARATORS.split(text) if word and word != text)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ReadRecorder:
|
|
81
|
+
"""Attributes tracked-file reads to the test file that is current when they happen."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, root: Path, tracked: Iterable[str]) -> None:
|
|
84
|
+
# One dictionary lookup per event decides membership; nothing outside the repository matches.
|
|
85
|
+
self._tracked = {_key(os.path.join(root, path)): path for path in tracked}
|
|
86
|
+
self._owner: str | None = None
|
|
87
|
+
self._inputs: dict[str, set[str]] = {}
|
|
88
|
+
self._errors: set[str] = set()
|
|
89
|
+
|
|
90
|
+
@contextmanager
|
|
91
|
+
def attributed(self, path: Path) -> Generator[None]:
|
|
92
|
+
"""Attribute reads inside the block to ``path``; an untracked path records nothing."""
|
|
93
|
+
previous = self._owner
|
|
94
|
+
self._owner = self._tracked.get(_key(str(path)))
|
|
95
|
+
try:
|
|
96
|
+
yield
|
|
97
|
+
finally:
|
|
98
|
+
self._owner = previous
|
|
99
|
+
|
|
100
|
+
def audit(self, event: str, args: tuple[object, ...]) -> None:
|
|
101
|
+
"""
|
|
102
|
+
The ``sys.addaudithook`` callback.
|
|
103
|
+
|
|
104
|
+
It runs for every audit event in the process, most of them never a read, so it returns
|
|
105
|
+
before any work when no test is current. An exception raised here would propagate into
|
|
106
|
+
the audited call and fail the test, so every failure is recorded instead.
|
|
107
|
+
"""
|
|
108
|
+
owner = self._owner
|
|
109
|
+
if owner is None:
|
|
110
|
+
return
|
|
111
|
+
try:
|
|
112
|
+
if event == "open" and len(args) == 3:
|
|
113
|
+
path, mode, flags = args
|
|
114
|
+
if not _write_only(mode, flags):
|
|
115
|
+
self._add(owner, path, None)
|
|
116
|
+
elif event in _COMMAND_ARGUMENTS:
|
|
117
|
+
self._command(owner, event, args)
|
|
118
|
+
except Exception as error: # noqa: BLE001 - see the docstring
|
|
119
|
+
self._errors.add(f"{event}: {error!r}")
|
|
120
|
+
|
|
121
|
+
def _command(self, owner: str, event: str, args: tuple[object, ...]) -> None:
|
|
122
|
+
cwd = args[2] if event == "subprocess.Popen" and len(args) > 2 else None
|
|
123
|
+
directory = os.fsdecode(cwd) if isinstance(cwd, (str, bytes, os.PathLike)) else None
|
|
124
|
+
for index in _COMMAND_ARGUMENTS[event]:
|
|
125
|
+
if index < len(args):
|
|
126
|
+
for word in _words(args[index]):
|
|
127
|
+
self._add(owner, word, directory)
|
|
128
|
+
|
|
129
|
+
def _add(self, owner: str, value: object, directory: str | None) -> None:
|
|
130
|
+
if not isinstance(value, (str, bytes, os.PathLike)):
|
|
131
|
+
return
|
|
132
|
+
text = os.fsdecode(value)
|
|
133
|
+
relative = self._tracked.get(_key(os.path.join(directory, text) if directory else text))
|
|
134
|
+
# The stack is inspected only for tracked files, a small fraction of all opens.
|
|
135
|
+
if relative is not None and not _read_by_process_wide_reader():
|
|
136
|
+
self._inputs.setdefault(owner, set()).add(relative)
|
|
137
|
+
|
|
138
|
+
def record(self) -> AuditRecord:
|
|
139
|
+
return AuditRecord(
|
|
140
|
+
inputs={owner: sorted(paths) for owner, paths in sorted(self._inputs.items())},
|
|
141
|
+
errors=sorted(self._errors),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def tracked_files(root: Path) -> list[str]:
|
|
146
|
+
return [path for path in _git(root, "ls-files", "-z").split("\0") if path]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def repository_root(start: Path) -> Path:
|
|
150
|
+
return Path(_git(start, "rev-parse", "--show-toplevel").strip())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _git(directory: Path, *args: str) -> str:
|
|
154
|
+
return subprocess.run(
|
|
155
|
+
["git", "-C", str(directory), *args], check=True, capture_output=True, text=True, encoding="utf-8"
|
|
156
|
+
).stdout
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ImpactAuditPlugin:
|
|
160
|
+
"""Sets the current test file around module collection and each test's run protocol."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, recorder: ReadRecorder, directory: Path) -> None:
|
|
163
|
+
self._recorder = recorder
|
|
164
|
+
self._directory = directory
|
|
165
|
+
|
|
166
|
+
@pytest.hookimpl(wrapper=True)
|
|
167
|
+
def pytest_make_collect_report(
|
|
168
|
+
self, collector: pytest.Collector
|
|
169
|
+
) -> Generator[None, pytest.CollectReport, pytest.CollectReport]:
|
|
170
|
+
# Importing a test module runs its module-level reads; only a Module collector imports one.
|
|
171
|
+
if not isinstance(collector, pytest.Module):
|
|
172
|
+
return (yield)
|
|
173
|
+
with self._recorder.attributed(collector.path):
|
|
174
|
+
return (yield)
|
|
175
|
+
|
|
176
|
+
@pytest.hookimpl(wrapper=True)
|
|
177
|
+
def pytest_runtest_protocol(self, item: pytest.Item) -> Generator[None, object, object]:
|
|
178
|
+
# Setup, call and teardown, including fixtures from conftests the graph already reaches.
|
|
179
|
+
with self._recorder.attributed(item.path):
|
|
180
|
+
return (yield)
|
|
181
|
+
|
|
182
|
+
def pytest_sessionfinish(self) -> None:
|
|
183
|
+
self._directory.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
# One file per process: shards and xdist workers share the directory.
|
|
185
|
+
record = self._directory / f"{uuid.uuid4().hex}.json"
|
|
186
|
+
record.write_text(self._recorder.record().model_dump_json(), encoding="utf-8")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
190
|
+
parser.getgroup("test impact").addoption(
|
|
191
|
+
"--impact-audit-dir",
|
|
192
|
+
dest="impact_audit_dir",
|
|
193
|
+
default=None,
|
|
194
|
+
help="Record the tracked files each test file reads into this directory, for gv-test-impact audit.",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
199
|
+
directory = config.getoption("impact_audit_dir")
|
|
200
|
+
if directory is None:
|
|
201
|
+
return
|
|
202
|
+
try:
|
|
203
|
+
root = repository_root(config.invocation_params.dir)
|
|
204
|
+
recorder = ReadRecorder(root, tracked_files(root))
|
|
205
|
+
except (OSError, subprocess.CalledProcessError) as error:
|
|
206
|
+
# A test that runs pytest in a temporary directory inherits the option through the
|
|
207
|
+
# environment; the recorder must not change that nested run's outcome either.
|
|
208
|
+
config.issue_config_time_warning(
|
|
209
|
+
pytest.PytestConfigWarning(f"impact audit disabled: no Git checkout to know tracked files ({error})"),
|
|
210
|
+
stacklevel=2,
|
|
211
|
+
)
|
|
212
|
+
return
|
|
213
|
+
# Tests may change directory; the record location is fixed at the invocation directory.
|
|
214
|
+
output = Path(config.invocation_params.dir, directory).resolve()
|
|
215
|
+
# An audit hook cannot be removed; it stays inert once no test file is current.
|
|
216
|
+
sys.addaudithook(recorder.audit)
|
|
217
|
+
config.pluginmanager.register(ImpactAuditPlugin(recorder, output), "gv-impact-audit")
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Inspect and use the cached test dependency graph.
|
|
3
|
+
|
|
4
|
+
Run with python -m pytest_selective.impact_cli.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import functools
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pytest_selective.impact_audit import audit, load_records, summary
|
|
18
|
+
from pytest_selective.impact_explanation import dependent_tests, explain
|
|
19
|
+
from pytest_selective.impact_graph import (
|
|
20
|
+
DependencyDeclarationError,
|
|
21
|
+
GraphCache,
|
|
22
|
+
explanation,
|
|
23
|
+
git,
|
|
24
|
+
inventory,
|
|
25
|
+
policy_fingerprints,
|
|
26
|
+
reachable,
|
|
27
|
+
)
|
|
28
|
+
from pytest_selective.impact_models import (
|
|
29
|
+
GraphAnalysisReport,
|
|
30
|
+
ImpactConfig,
|
|
31
|
+
ImpactReport,
|
|
32
|
+
SelectionExplanation,
|
|
33
|
+
)
|
|
34
|
+
from pytest_selective.impact_selection import affected, base_snapshot
|
|
35
|
+
from pytest_selective.pytest_sharding import (
|
|
36
|
+
DEFAULT_SHARD_SECONDS,
|
|
37
|
+
load_durations,
|
|
38
|
+
plan_shard_count,
|
|
39
|
+
)
|
|
40
|
+
from pytest_selective.typecheck_scope import TypecheckScope, scope
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _measured_durations(path: Path) -> dict[str, float]:
|
|
44
|
+
"""
|
|
45
|
+
Read the duration manifest the shard planner will use, or nothing.
|
|
46
|
+
|
|
47
|
+
The manifest is restored from cache beside this step and is routinely
|
|
48
|
+
absent or unreadable: a cold cache, a consumer that does not record
|
|
49
|
+
timings, a truncated download. Sizing treats an empty mapping as
|
|
50
|
+
unmeasured and falls back to counting files, so a bad read must not fail
|
|
51
|
+
planning.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
return load_durations(path)
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _select(
|
|
60
|
+
repo: Path,
|
|
61
|
+
head: str,
|
|
62
|
+
config: ImpactConfig,
|
|
63
|
+
cache: GraphCache,
|
|
64
|
+
config_path: str | None,
|
|
65
|
+
dependency_cache: Path,
|
|
66
|
+
base: str,
|
|
67
|
+
extra_files: Path | None,
|
|
68
|
+
) -> ImpactReport:
|
|
69
|
+
return affected(
|
|
70
|
+
repo,
|
|
71
|
+
base,
|
|
72
|
+
head,
|
|
73
|
+
config,
|
|
74
|
+
cache,
|
|
75
|
+
extra_files.read_text(encoding="utf-8").splitlines() if extra_files else [],
|
|
76
|
+
config_path,
|
|
77
|
+
dependency_cache=dependency_cache,
|
|
78
|
+
# Read from the environment, never argv, so it cannot appear in a process listing.
|
|
79
|
+
dependency_token=os.environ.get("IMPACT_DEPENDENCY_TOKEN") or None,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _smaller_selection(
|
|
84
|
+
select: Callable[[str, Path | None], ImpactReport],
|
|
85
|
+
proven: tuple[str, ImpactReport],
|
|
86
|
+
alternate_base: str,
|
|
87
|
+
alternate_extra: Path | None,
|
|
88
|
+
) -> tuple[str, ImpactReport]:
|
|
89
|
+
"""
|
|
90
|
+
Keep whichever of two proven bases selects fewer tests.
|
|
91
|
+
|
|
92
|
+
Each base alone is sound: the primary is a tree whose selected tests all passed (or the
|
|
93
|
+
PR base itself), and the alternate is the PR base every PR diff is already trusted
|
|
94
|
+
against. They differ in size when main moved: after main is merged into a validated
|
|
95
|
+
PR, the delta since the validated tree carries main's own changes, and a change to a
|
|
96
|
+
module every test imports selects the whole suite for a PR that edited one file.
|
|
97
|
+
|
|
98
|
+
The alternate can only shrink a sound selection, so failing to compute it keeps the
|
|
99
|
+
primary. That includes an alternate its configuration cannot describe: declarations are
|
|
100
|
+
authoring errors only at the head. Unparseable source is a hard error in either tree.
|
|
101
|
+
"""
|
|
102
|
+
base, report = proven
|
|
103
|
+
try:
|
|
104
|
+
candidate = select(alternate_base, alternate_extra)
|
|
105
|
+
except DependencyDeclarationError:
|
|
106
|
+
raise
|
|
107
|
+
except Exception as error:
|
|
108
|
+
report.reasons.append(f"alternate base {alternate_base} not used: {error}")
|
|
109
|
+
return base, report
|
|
110
|
+
if len(candidate.selected) < len(report.selected):
|
|
111
|
+
candidate.reasons.append(
|
|
112
|
+
f"selected from {alternate_base} instead of {base}, which selects {len(report.selected)} files"
|
|
113
|
+
)
|
|
114
|
+
return alternate_base, candidate
|
|
115
|
+
report.reasons.append(f"kept {base}; {alternate_base} selects {len(candidate.selected)} files")
|
|
116
|
+
return base, report
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _merge_base(repo: Path, graph_base: str, head: str) -> str | None:
|
|
120
|
+
"""
|
|
121
|
+
Where the change forked from `graph_base`, the PR's own base, or None when history lacks it.
|
|
122
|
+
|
|
123
|
+
Selection may start from a tree an earlier push already validated, which would hide a graph
|
|
124
|
+
change after the first green push. Floor and widening deltas compare against the whole PR.
|
|
125
|
+
"""
|
|
126
|
+
if not graph_base:
|
|
127
|
+
return None
|
|
128
|
+
try:
|
|
129
|
+
return git(repo, "merge-base", graph_base, head).decode().strip() or None
|
|
130
|
+
except subprocess.CalledProcessError:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _write_explanation(
|
|
135
|
+
args: argparse.Namespace,
|
|
136
|
+
config: ImpactConfig,
|
|
137
|
+
cache: GraphCache,
|
|
138
|
+
config_path: str | None,
|
|
139
|
+
base: str,
|
|
140
|
+
report: ImpactReport,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Explain the final selection beside it, or warn; never change which tests run.
|
|
144
|
+
|
|
145
|
+
The explanation is for the PR author. Any failure to build it must not reach the caller's
|
|
146
|
+
exit status, which the CI action would read as a failed plan and replace with the full suite.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
graph_base = _merge_base(args.repo, args.graph_base, args.head)
|
|
150
|
+
explanation = explain(
|
|
151
|
+
args.repo,
|
|
152
|
+
base_snapshot(args.repo, base, config, cache, config_path),
|
|
153
|
+
cache.snapshot(args.repo, args.head, config),
|
|
154
|
+
report,
|
|
155
|
+
config,
|
|
156
|
+
graph_before=base_snapshot(args.repo, graph_base, config, cache, config_path) if graph_base else None,
|
|
157
|
+
forced_full=args.full,
|
|
158
|
+
)
|
|
159
|
+
except Exception as error: # noqa: BLE001 - reported, and selection stands without it
|
|
160
|
+
print(f"::warning::Test selection explanation unavailable: {error}", file=sys.stderr)
|
|
161
|
+
return
|
|
162
|
+
args.explanation_output.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
args.explanation_output.write_text(explanation.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _typecheck_scope(args: argparse.Namespace) -> int:
|
|
167
|
+
"""
|
|
168
|
+
Write the files to type-check and the mode; every failure is the full check.
|
|
169
|
+
|
|
170
|
+
Unlike test selection, a scope that cannot be computed never fails the step: the full
|
|
171
|
+
check is always a correct answer, only a slower one.
|
|
172
|
+
"""
|
|
173
|
+
cache = None
|
|
174
|
+
try:
|
|
175
|
+
config = ImpactConfig.model_validate_json(args.config.read_text(encoding="utf-8"))
|
|
176
|
+
cache = GraphCache(args.cache)
|
|
177
|
+
result = scope(
|
|
178
|
+
args.repo, args.base, args.head, config, cache, args.checker_config, args.global_inputs, args.max_fraction
|
|
179
|
+
)
|
|
180
|
+
except Exception as error: # noqa: BLE001 - any doubt is the full check, and the reason is reported
|
|
181
|
+
result = TypecheckScope(mode="full", files=[], reason=f"scope unavailable: {error}")
|
|
182
|
+
finally:
|
|
183
|
+
if cache is not None:
|
|
184
|
+
cache.close()
|
|
185
|
+
if args.output:
|
|
186
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
args.output.write_text("".join(f"{path}\n" for path in result.files), encoding="utf-8")
|
|
188
|
+
if args.github_output:
|
|
189
|
+
with args.github_output.open("a", encoding="utf-8") as output:
|
|
190
|
+
output.write(f"mode={result.mode}\nfiles={len(result.files)}\nreason={result.reason}\n")
|
|
191
|
+
print(f"Type-check scope: {result.mode}; {result.reason}")
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _audit(args: argparse.Namespace, config: ImpactConfig, cache: GraphCache) -> int:
|
|
196
|
+
"""
|
|
197
|
+
Fail on reads the head graph cannot reach from the reading test (exit 2, an authoring error).
|
|
198
|
+
|
|
199
|
+
A missing or unreadable record raises, which is operational (exit 1): the audit proved nothing.
|
|
200
|
+
"""
|
|
201
|
+
report = audit(cache.snapshot(args.repo, args.head, config), load_records(args.records), config)
|
|
202
|
+
if args.output:
|
|
203
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
args.output.write_text(report.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
205
|
+
if args.summary:
|
|
206
|
+
with args.summary.open("a", encoding="utf-8") as output:
|
|
207
|
+
output.write(summary(report))
|
|
208
|
+
print(
|
|
209
|
+
f"Test-impact read audit: {len(report.undeclared)} undeclared of {report.recorded_reads} reads "
|
|
210
|
+
f"by {report.audited_tests} test files ({report.exempted_reads} exempted or always-run)"
|
|
211
|
+
)
|
|
212
|
+
for read in report.undeclared:
|
|
213
|
+
print(f"{read.test} -> {read.input}")
|
|
214
|
+
for error in report.recorder_errors:
|
|
215
|
+
print(f"recorder error: {error}", file=sys.stderr)
|
|
216
|
+
return 2 if report.undeclared else 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
#: Files that joined the floor named in the budget error before the rest are counted.
|
|
220
|
+
BUDGET_LISTED_FILES = 10
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _floor_budget(args: argparse.Namespace) -> int:
|
|
224
|
+
"""
|
|
225
|
+
Fail when this PR grows the startup floor past the configured `floor_budget`.
|
|
226
|
+
|
|
227
|
+
The check reads the explanation planning already wrote, so it costs no graph work. A plan that
|
|
228
|
+
fell back to the complete suite wrote no explanation it can trust, so the check passes rather
|
|
229
|
+
than block a PR on an unproven floor. A floor under budget passes with a notice to lower it.
|
|
230
|
+
"""
|
|
231
|
+
config = ImpactConfig.model_validate_json(args.config.read_text(encoding="utf-8"))
|
|
232
|
+
budget = config.floor_budget
|
|
233
|
+
if budget is None:
|
|
234
|
+
return 0
|
|
235
|
+
try:
|
|
236
|
+
explanation = SelectionExplanation.model_validate_json(args.explanation.read_text(encoding="utf-8"))
|
|
237
|
+
except (OSError, ValueError):
|
|
238
|
+
print("::warning::Startup-floor budget not checked: planning produced no explanation")
|
|
239
|
+
return 0
|
|
240
|
+
floor = explanation.floor
|
|
241
|
+
if floor.after > budget:
|
|
242
|
+
joined = floor.added[:BUDGET_LISTED_FILES]
|
|
243
|
+
more = f" and {len(floor.added) - len(joined)} more" if len(floor.added) > len(joined) else ""
|
|
244
|
+
through = f" They enter through {' -> '.join(step.path for step in floor.path)}." if floor.path else ""
|
|
245
|
+
print(
|
|
246
|
+
f"::error::This PR grows the startup floor to {floor.after} files, over its budget of {budget}. "
|
|
247
|
+
f"Joined the floor: {', '.join(joined) or 'none'}{more}.{through} Remove the import that brings "
|
|
248
|
+
f"them in, or raise floor_budget in {args.config} with the reason."
|
|
249
|
+
)
|
|
250
|
+
return 1
|
|
251
|
+
if floor.after < budget:
|
|
252
|
+
print(
|
|
253
|
+
f"::notice::The startup floor is {floor.after} files, under its budget of {budget}. "
|
|
254
|
+
f"Lower floor_budget to {floor.after} in {args.config} to keep it there."
|
|
255
|
+
)
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main(argv: list[str] | None = None) -> int:
|
|
260
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
261
|
+
if argv[:1] == ["run"]:
|
|
262
|
+
from pytest_selective.local import main as run_local
|
|
263
|
+
|
|
264
|
+
return run_local(argv[1:])
|
|
265
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
266
|
+
parser.add_argument(
|
|
267
|
+
"command",
|
|
268
|
+
choices=[
|
|
269
|
+
"affected",
|
|
270
|
+
"audit",
|
|
271
|
+
"dependencies",
|
|
272
|
+
"explain",
|
|
273
|
+
"conftest-impact",
|
|
274
|
+
"fingerprints",
|
|
275
|
+
"floor-budget",
|
|
276
|
+
"warm",
|
|
277
|
+
"typecheck-scope",
|
|
278
|
+
],
|
|
279
|
+
)
|
|
280
|
+
parser.add_argument("--repo", type=Path, default=Path("."))
|
|
281
|
+
parser.add_argument("--config", type=Path, required=True)
|
|
282
|
+
parser.add_argument("--cache", type=Path, default=Path(".test-impact/cache.sqlite3"))
|
|
283
|
+
parser.add_argument("--base", default=None)
|
|
284
|
+
parser.add_argument("--head", default="HEAD")
|
|
285
|
+
parser.add_argument(
|
|
286
|
+
"--working-tree", action="store_true", help="analyze staged, unstaged and untracked local edits"
|
|
287
|
+
)
|
|
288
|
+
parser.add_argument("--test", default="")
|
|
289
|
+
parser.add_argument("--source", default="")
|
|
290
|
+
parser.add_argument("--output", type=Path)
|
|
291
|
+
# affected: why the selection is this large, for the PR sticky (never changes the selection).
|
|
292
|
+
parser.add_argument("--explanation-output", type=Path)
|
|
293
|
+
# affected: the PR's base, which floor and widening deltas compare against (its merge base).
|
|
294
|
+
parser.add_argument("--graph-base", default="")
|
|
295
|
+
parser.add_argument("--extra-files", type=Path)
|
|
296
|
+
parser.add_argument("--github-output", type=Path)
|
|
297
|
+
parser.add_argument("--full", action="store_true")
|
|
298
|
+
parser.add_argument("--minimum-files", type=int, default=0)
|
|
299
|
+
parser.add_argument("--max-shards", type=int, default=8)
|
|
300
|
+
parser.add_argument("--durations", type=Path, default=Path(".test_durations"))
|
|
301
|
+
parser.add_argument("--shard-seconds", type=float, default=DEFAULT_SHARD_SECONDS)
|
|
302
|
+
# Bare clones of declared first-party packages, cached with the parsed graph.
|
|
303
|
+
parser.add_argument("--dependency-cache", type=Path, default=Path(".test-impact/dependencies"))
|
|
304
|
+
# typecheck-scope: the checker's config file, inputs that change every verdict, and the
|
|
305
|
+
# scope size (as a fraction of checked files) above which the full check is no slower.
|
|
306
|
+
parser.add_argument("--checker-config", default="pyrightconfig.json")
|
|
307
|
+
parser.add_argument("--global-input", action="append", default=[], dest="global_inputs")
|
|
308
|
+
parser.add_argument("--max-fraction", type=float, default=0.6)
|
|
309
|
+
# A second proven base, with its own extra files; the one that selects fewer tests wins.
|
|
310
|
+
parser.add_argument("--alternate-base", default="")
|
|
311
|
+
parser.add_argument("--alternate-extra-files", type=Path)
|
|
312
|
+
# audit: the directory of pytest read records, and a markdown summary (GITHUB_STEP_SUMMARY) to append to.
|
|
313
|
+
parser.add_argument("--records", type=Path, default=Path("impact-audit"))
|
|
314
|
+
parser.add_argument("--summary", type=Path)
|
|
315
|
+
# floor-budget: the explanation `affected --explanation-output` wrote for this plan.
|
|
316
|
+
parser.add_argument("--explanation", type=Path, default=Path("test-impact-explanation.json"))
|
|
317
|
+
args = parser.parse_args(argv)
|
|
318
|
+
args.base = args.base or ("HEAD" if args.working_tree else "HEAD^")
|
|
319
|
+
if args.working_tree and args.command not in {"affected", "warm", "dependencies", "explain", "conftest-impact"}:
|
|
320
|
+
parser.error("--working-tree is supported for affected, warm, dependencies, explain and conftest-impact")
|
|
321
|
+
if args.command == "typecheck-scope":
|
|
322
|
+
return _typecheck_scope(args)
|
|
323
|
+
if args.command == "floor-budget":
|
|
324
|
+
return _floor_budget(args)
|
|
325
|
+
cache = None
|
|
326
|
+
try:
|
|
327
|
+
config = ImpactConfig.model_validate_json(args.config.read_text(encoding="utf-8"))
|
|
328
|
+
if args.working_tree:
|
|
329
|
+
from pytest_selective.working_tree import snapshot_working_tree
|
|
330
|
+
|
|
331
|
+
args.head = snapshot_working_tree(args.repo)
|
|
332
|
+
cache = GraphCache(args.cache)
|
|
333
|
+
if args.command == "audit":
|
|
334
|
+
return _audit(args, config, cache)
|
|
335
|
+
if args.command == "fingerprints":
|
|
336
|
+
result = json.dumps(
|
|
337
|
+
policy_fingerprints(args.repo, inventory(args.repo, args.head), tuple(config.pytest_config_files)),
|
|
338
|
+
indent=2,
|
|
339
|
+
)
|
|
340
|
+
elif args.command == "affected":
|
|
341
|
+
config_path = (
|
|
342
|
+
args.config.resolve().relative_to(args.repo.resolve()).as_posix()
|
|
343
|
+
if args.config.resolve().is_relative_to(args.repo.resolve())
|
|
344
|
+
else None
|
|
345
|
+
)
|
|
346
|
+
select = functools.partial(_select, args.repo, args.head, config, cache, config_path, args.dependency_cache)
|
|
347
|
+
base = args.base
|
|
348
|
+
report = select(base, args.extra_files)
|
|
349
|
+
if args.alternate_base and args.alternate_base != base and not args.full:
|
|
350
|
+
base, report = _smaller_selection(
|
|
351
|
+
select, (base, report), args.alternate_base, args.alternate_extra_files
|
|
352
|
+
)
|
|
353
|
+
if args.full:
|
|
354
|
+
snapshot = cache.snapshot(args.repo, args.head, config)
|
|
355
|
+
report.selected = snapshot.tests
|
|
356
|
+
report.mode = "full"
|
|
357
|
+
report.reasons.append("explicit full-suite event/label")
|
|
358
|
+
if report.selected and len(report.selected) < args.minimum_files:
|
|
359
|
+
snapshot = cache.snapshot(args.repo, args.head, config)
|
|
360
|
+
extras = [test for test in snapshot.tests if test not in report.selected]
|
|
361
|
+
report.selected = sorted(report.selected + extras[: args.minimum_files - len(report.selected)])
|
|
362
|
+
report.reasons.append(f"minimum file count for fixed shards: {args.minimum_files}")
|
|
363
|
+
result = report.model_dump_json(indent=2)
|
|
364
|
+
if args.explanation_output:
|
|
365
|
+
_write_explanation(args, config, cache, config_path, base, report)
|
|
366
|
+
if args.github_output:
|
|
367
|
+
shards = plan_shard_count(
|
|
368
|
+
report.selected,
|
|
369
|
+
_measured_durations(args.durations),
|
|
370
|
+
max_shards=args.max_shards,
|
|
371
|
+
shard_seconds=args.shard_seconds,
|
|
372
|
+
)
|
|
373
|
+
with args.github_output.open("a", encoding="utf-8") as output:
|
|
374
|
+
output.write(f"base={base}\n")
|
|
375
|
+
output.write(f"mode={report.mode}\n")
|
|
376
|
+
output.write(f"dependencies_unchanged={str(report.dependencies_unchanged).lower()}\n")
|
|
377
|
+
output.write(
|
|
378
|
+
f"test_roots={' '.join(config.test_roots if report.mode == 'full' else report.selected)}\n"
|
|
379
|
+
)
|
|
380
|
+
output.write(f"shard_count={shards}\ngroups={json.dumps(list(range(1, shards + 1)))}\n")
|
|
381
|
+
reason = (
|
|
382
|
+
f"Selected {len(report.selected)}/{report.total_files} test files; {'; '.join(report.reasons)}"
|
|
383
|
+
)
|
|
384
|
+
# One output per line: a multi-line reason (a quoted validation error) would
|
|
385
|
+
# truncate this value and corrupt every output GitHub parses after it.
|
|
386
|
+
output.write(f"reason={' '.join(reason.split())}\n")
|
|
387
|
+
else:
|
|
388
|
+
start = time.perf_counter()
|
|
389
|
+
snapshot = cache.snapshot(args.repo, args.head, config)
|
|
390
|
+
if args.command == "warm":
|
|
391
|
+
result = GraphAnalysisReport(
|
|
392
|
+
head=snapshot.tree,
|
|
393
|
+
total_files=len(snapshot.tests),
|
|
394
|
+
elapsed_seconds=time.perf_counter() - start,
|
|
395
|
+
parsed_blobs=cache.parsed_blobs,
|
|
396
|
+
snapshot_hits=cache.snapshot_hits,
|
|
397
|
+
).model_dump_json(indent=2)
|
|
398
|
+
elif args.command == "dependencies":
|
|
399
|
+
result = json.dumps(sorted(reachable(snapshot.edges, {args.test})), indent=2)
|
|
400
|
+
elif args.command == "explain":
|
|
401
|
+
result = json.dumps(explanation(snapshot, args.test, args.source), indent=2)
|
|
402
|
+
else:
|
|
403
|
+
reach = dependent_tests(snapshot)
|
|
404
|
+
result = json.dumps(
|
|
405
|
+
{
|
|
406
|
+
path: reach.get(path, 0).bit_count()
|
|
407
|
+
for path in sorted(snapshot.files)
|
|
408
|
+
if path.endswith("conftest.py")
|
|
409
|
+
},
|
|
410
|
+
indent=2,
|
|
411
|
+
)
|
|
412
|
+
if args.output:
|
|
413
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
414
|
+
args.output.write_text(result + "\n", encoding="utf-8")
|
|
415
|
+
else:
|
|
416
|
+
print(result)
|
|
417
|
+
return 0
|
|
418
|
+
except DependencyDeclarationError as error:
|
|
419
|
+
print(f"Invalid test dependency: {error}", file=sys.stderr)
|
|
420
|
+
return 2
|
|
421
|
+
except Exception as error:
|
|
422
|
+
# Exit nonzero: composite action falls back to its complete configured
|
|
423
|
+
# roots. Never turn a missing ref or corrupt cache into an empty suite.
|
|
424
|
+
print(f"Test impact planning failed: {error}", file=sys.stderr)
|
|
425
|
+
return 1
|
|
426
|
+
finally:
|
|
427
|
+
if cache is not None:
|
|
428
|
+
cache.close()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
if __name__ == "__main__":
|
|
432
|
+
raise SystemExit(main())
|