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.
@@ -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())