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,242 @@
1
+ """File-level selection using both sides of a change, with broad fallbacks."""
2
+
3
+ import time
4
+ from pathlib import Path, PurePosixPath
5
+
6
+ from pydantic import ValidationError
7
+
8
+ from pytest_selective.impact_first_party import (
9
+ first_party_pin_move,
10
+ importers,
11
+ package_change,
12
+ refuse_pytest_plugin,
13
+ )
14
+ from pytest_selective.impact_graph import (
15
+ DependencyDeclarationError,
16
+ GraphCache,
17
+ blobs,
18
+ changed_paths,
19
+ inventory,
20
+ matches,
21
+ reachable,
22
+ reverse_edges,
23
+ seed_nodes,
24
+ union_edges,
25
+ )
26
+ from pytest_selective.impact_models import FirstPartyPackage, ImpactConfig, ImpactModel, ImpactReport, Snapshot
27
+ from pytest_selective.impact_name_scope import NotNarrowed, narrow_name_scoped_change
28
+
29
+
30
+ class NarrowedPin(ImpactModel):
31
+ """A proven first-party pin move and what it reaches in the consumer."""
32
+
33
+ reached: list[str]
34
+ reason: str
35
+ #: The pinned commits install byte-identical files.
36
+ installed_unchanged: bool
37
+
38
+
39
+ def _blob(repo: Path, snapshot: Snapshot, path: str) -> bytes | None:
40
+ oid = snapshot.files.get(path)
41
+ return blobs(repo, {oid})[oid] if oid else None
42
+
43
+
44
+ def _content_readers(snapshots: tuple[Snapshot, Snapshot], inputs: set[str]) -> set[str]:
45
+ """
46
+ Files that read a narrowed lock or manifest themselves, rather than through a conftest.
47
+
48
+ A conftest declares these to say the whole pytest run uses the installed dependency set; that is
49
+ the claim the first-party proof replaces. Any other declaring file reads the file as content
50
+ (a pin-consistency test, for one) and must still run whenever it changes.
51
+ """
52
+ markers = seed_nodes(sorted(inputs))
53
+ return {
54
+ path
55
+ for snapshot in snapshots
56
+ for path, deps in snapshot.edges.items()
57
+ if PurePosixPath(path).name != "conftest.py" and markers & set(deps)
58
+ }
59
+
60
+
61
+ def _narrow_first_party_pin(
62
+ spec: FirstPartyPackage,
63
+ repo: Path,
64
+ snapshots: tuple[Snapshot, Snapshot],
65
+ cache: GraphCache,
66
+ dependency_cache: Path,
67
+ dependency_token: str | None,
68
+ pytest_config_files: tuple[str, ...] = (),
69
+ ) -> NarrowedPin:
70
+ """Prove the pin move, then return the consumer files it reaches and why."""
71
+ before, after = snapshots
72
+ before_lock, after_lock = _blob(repo, before, spec.lock), _blob(repo, after, spec.lock)
73
+ if before_lock is None or after_lock is None:
74
+ raise ValueError(f"{spec.lock} was added or removed")
75
+ move = first_party_pin_move(
76
+ spec,
77
+ before_lock=before_lock,
78
+ after_lock=after_lock,
79
+ before_manifest=_blob(repo, before, spec.manifest),
80
+ after_manifest=_blob(repo, after, spec.manifest),
81
+ )
82
+ refuse_pytest_plugin(spec, repo, after.files, pytest_config_files)
83
+ change = package_change(spec, move, cache=cache, cache_dir=dependency_cache, token=dependency_token)
84
+ candidates: dict[str, list[str]] = {}
85
+ for snapshot in snapshots:
86
+ for path, names in snapshot.unresolved.items():
87
+ candidates.setdefault(path, []).extend(names)
88
+ reached = importers(candidates, spec.import_prefix, set(change.modules))
89
+ return NarrowedPin(
90
+ reached=sorted(reached),
91
+ reason=(
92
+ f"first-party pin {spec.name} {move.old[:12]}..{move.new[:12]}: "
93
+ f"{len(change.installed_files)} installed files changed; "
94
+ f"{len(change.modules)} changed modules reach {len(reached)} files"
95
+ ),
96
+ installed_unchanged=not change.installed_files,
97
+ )
98
+
99
+
100
+ def base_snapshot(repo: Path, base: str, config: ImpactConfig, cache: GraphCache, config_path: str | None) -> Snapshot:
101
+ """
102
+ The base graph under the base's own configuration, or an operational failure.
103
+
104
+ The adoption commit must retain the old global edges, so the base is read with the config it
105
+ carried at `config_path`: a new startup check cannot be required to exist in the tree preceding
106
+ its introduction. With no config of its own there (moved, kept outside the repo, adopted in this
107
+ change) the head's declarations are all there is, and they may name files the base does not
108
+ have. Either way a failure here is about a tree this change cannot edit, so it is never a
109
+ `DependencyDeclarationError` (exit 2, no fallback) but a missing proof: the complete suite.
110
+ """
111
+ # Other base failures (a changed pytest policy, an unsupported symlink) keep their own messages;
112
+ # they were already operational.
113
+ try:
114
+ before_config = config
115
+ if config_path and (oid := inventory(repo, base).files.get(config_path)):
116
+ before_config = ImpactConfig.model_validate_json(blobs(repo, {oid})[oid])
117
+ return cache.snapshot(repo, base, before_config)
118
+ except (DependencyDeclarationError, ValidationError) as error:
119
+ raise RuntimeError(f"base {base} cannot be described by its test-impact configuration: {error}") from error
120
+
121
+
122
+ def affected(
123
+ repo: Path,
124
+ base: str,
125
+ head: str,
126
+ config: ImpactConfig,
127
+ cache: GraphCache,
128
+ extra_files: list[str] | None = None,
129
+ config_path: str | None = None,
130
+ dependency_cache: Path | None = None,
131
+ dependency_token: str | None = None,
132
+ ) -> ImpactReport:
133
+ start = time.perf_counter()
134
+ # Head first: its declaration errors are the only ones this change can fix.
135
+ after = cache.snapshot(repo, head, config)
136
+ before = base_snapshot(repo, base, config, cache, config_path)
137
+ changed = changed_paths(before, after)
138
+ if extra_files:
139
+ changed = sorted(set(changed) | set(extra_files))
140
+ # Declarations/imports are stronger evidence than historical path exclusions.
141
+ # Unreferenced files naturally reach no tests without extension-based rules.
142
+ relevant = changed
143
+ parse_errors = sorted(
144
+ {
145
+ f"{path}: {reason}"
146
+ for snapshot in (before, after)
147
+ for path, issues in snapshot.opaque.items()
148
+ for reason in issues
149
+ if reason.startswith("cannot parse:")
150
+ }
151
+ )
152
+ # Invalid source cannot prove scope. Use the existing hard-error exit path,
153
+ # never the operational fallback that allocates a complete test suite.
154
+ if parse_errors:
155
+ raise DependencyDeclarationError("Unparseable Python dependency: " + "; ".join(parse_errors))
156
+ selected: set[str] = set()
157
+ reasons: list[str] = []
158
+ # A lock or manifest is a global input only because it can change any installed package. When
159
+ # the sole change is a declared first-party pin, replace it with the files the moved modules
160
+ # actually reach. Every failure to prove that leaves the global input in place.
161
+ narrowed: set[str] = set()
162
+ reached: set[str] = set()
163
+ name_readers: set[str] = set()
164
+ # Proven only by a declaration: with nothing declared the environment is unknown, not unchanged.
165
+ dependencies_unchanged = bool(config.first_party_packages)
166
+ for spec in config.first_party_packages:
167
+ pinned = {spec.lock, spec.manifest} & set(changed)
168
+ if not pinned:
169
+ continue
170
+ try:
171
+ pin = _narrow_first_party_pin(
172
+ spec,
173
+ repo,
174
+ (before, after),
175
+ cache,
176
+ dependency_cache or repo / ".test-impact" / "dependencies",
177
+ dependency_token,
178
+ tuple(config.pytest_config_files),
179
+ )
180
+ except ValueError as error:
181
+ reasons.append(f"{spec.name} pin not narrowed: {error}; {spec.lock} and {spec.manifest} stay global inputs")
182
+ dependencies_unchanged = False
183
+ continue
184
+ narrowed |= pinned
185
+ reached |= set(pin.reached) | _content_readers((before, after), pinned)
186
+ reasons.append(pin.reason)
187
+ dependencies_unchanged = dependencies_unchanged and pin.installed_unchanged
188
+ # Only proven first-party pin moves replace an input's global effect.
189
+ # Match original changes, never unchanged readers introduced by narrowing.
190
+ global_paths = [path for path in changed if path not in narrowed and matches(path, config.global_inputs)]
191
+ # A name-scoped module's importers depend only on the names they read. When a change only
192
+ # rebinds names, replace the module with the files that read them; anything unproven keeps its edges.
193
+ for module in config.name_scoped_modules:
194
+ if module.file not in changed:
195
+ continue
196
+ try:
197
+ scoped = narrow_name_scoped_change(repo, (before, after), config, module)
198
+ except NotNarrowed as error:
199
+ reasons.append(f"{module.file} not narrowed: {error}; every file that loads it stays selected")
200
+ else:
201
+ narrowed.add(module.file)
202
+ name_readers |= scoped.readers
203
+ reasons.append(scoped.reason)
204
+ if narrowed:
205
+ relevant = sorted((set(relevant) - narrowed) | reached | name_readers)
206
+ if global_paths:
207
+ selected.update(after.tests)
208
+ reasons.extend(f"global input: {path}" for path in global_paths)
209
+ if relevant:
210
+ reverse = reverse_edges(union_edges(before, after))
211
+ # A name-scoped reader's own file did not change, only a name it reads, so files that read its
212
+ # contents are unaffected: it seeds its importers, not its content readers. Without this, a
213
+ # narrowed change could select tests the module's ordinary dependents never reach.
214
+ unchanged_readers = name_readers - set(changed)
215
+ impacted = reachable(reverse, seed_nodes(sorted(set(relevant) - unchanged_readers)) | unchanged_readers)
216
+ # Only changed inputs seed selection. Helpers use ordinary import edges;
217
+ # the graph already includes pytest's implicit ancestor-conftest edges.
218
+ # Non-import inputs must be declared rather than making every unknown
219
+ # reader a root on every push.
220
+ selected.update(impacted & set(after.tests))
221
+ selected.update(test for test in after.tests if matches(test, config.always_run))
222
+ reasons.append(f"dependency closure: {len(impacted & set(after.tests))} test files")
223
+ return ImpactReport(
224
+ # A global invalidation also admits lanes whose work has no Python test
225
+ # files (external conformance, generated checks, subprocess validators).
226
+ mode="full"
227
+ if global_paths or (selected and len(selected) == len(after.tests))
228
+ else "scoped"
229
+ if selected
230
+ else "none",
231
+ dependencies_unchanged=dependencies_unchanged,
232
+ base=before.tree,
233
+ head=after.tree,
234
+ changed=changed,
235
+ seeds=sorted(relevant),
236
+ selected=sorted(selected),
237
+ total_files=len(after.tests),
238
+ reasons=reasons,
239
+ elapsed_seconds=time.perf_counter() - start,
240
+ parsed_blobs=cache.parsed_blobs,
241
+ snapshot_hits=cache.snapshot_hits,
242
+ )
@@ -0,0 +1,27 @@
1
+ """Default environment inputs shared by file selection and whole-lane routing."""
2
+
3
+ # Match at the repository root and in nested projects with fnmatch's path rules.
4
+ # Consumers can replace the list when they own a narrower environment contract.
5
+ _ENVIRONMENT_FILES = (
6
+ "pyproject.toml",
7
+ "uv.lock",
8
+ "poetry.lock",
9
+ "pdm.lock",
10
+ "pixi.lock",
11
+ "Pipfile",
12
+ "Pipfile.lock",
13
+ "*requirements*.txt",
14
+ "*requirements*.in",
15
+ "requirements*.lock",
16
+ "pylock*.toml",
17
+ "requirements/**",
18
+ "*constraints*.txt",
19
+ "setup.py",
20
+ "setup.cfg",
21
+ "tox.ini",
22
+ "pytest.ini",
23
+ ".pytest.ini",
24
+ "pytest.toml",
25
+ ".pytest.toml",
26
+ )
27
+ DEFAULT_GLOBAL_INPUTS = tuple(pattern for name in _ENVIRONMENT_FILES for pattern in (name, f"**/{name}"))
@@ -0,0 +1,78 @@
1
+ """Run affected local pytest files, avoiding collection of unrelated modules."""
2
+
3
+ import argparse
4
+ import errno
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from pydantic import ValidationError
11
+
12
+ from pytest_selective.impact_graph import DependencyDeclarationError, GraphCache, git
13
+ from pytest_selective.impact_models import ImpactConfig
14
+ from pytest_selective.impact_selection import affected
15
+ from pytest_selective.working_tree import snapshot_working_tree
16
+
17
+
18
+ def main(argv: list[str] | None = None) -> int:
19
+ parser = argparse.ArgumentParser(description=__doc__)
20
+ parser.add_argument("--repo", type=Path, default=Path("."))
21
+ parser.add_argument("--config", type=Path, required=True)
22
+ parser.add_argument("--base", default="HEAD")
23
+ parser.add_argument("--full", action="store_true")
24
+ parser.add_argument("pytest_args", nargs=argparse.REMAINDER, help="pytest arguments after --")
25
+ args = parser.parse_args(argv)
26
+ repo = args.repo.resolve()
27
+ try:
28
+ config = ImpactConfig.model_validate_json(args.config.read_text(encoding="utf-8"))
29
+ except (OSError, ValidationError) as error:
30
+ print(f"Invalid test configuration: {error}", file=sys.stderr)
31
+ return 2
32
+ selected = config.test_roots
33
+ cache = None
34
+ try:
35
+ if not args.full:
36
+ head = snapshot_working_tree(repo)
37
+ # Git's private directory never enters a working-tree snapshot, even
38
+ # before the consumer has added our cache directory to .gitignore.
39
+ cache_path = git(repo, "rev-parse", "--git-path", "pytest-selective/cache.sqlite3").decode().strip()
40
+ cache = GraphCache(repo / cache_path)
41
+ config_path = args.config.resolve()
42
+ report = affected(
43
+ repo,
44
+ args.base,
45
+ head,
46
+ config,
47
+ cache,
48
+ config_path=config_path.relative_to(repo).as_posix() if config_path.is_relative_to(repo) else None,
49
+ dependency_token=os.environ.get("IMPACT_DEPENDENCY_TOKEN") or None,
50
+ )
51
+ if report.mode != "full" and not report.selected:
52
+ print("No affected tests; pytest was not started.")
53
+ return 0
54
+ selected = config.test_roots if report.mode == "full" else report.selected
55
+ print(f"Running {len(report.selected)} affected test files of {report.total_files}.", flush=True)
56
+ except DependencyDeclarationError as error:
57
+ print(f"Invalid test dependency: {error}", file=sys.stderr)
58
+ return 2
59
+ except Exception as error:
60
+ print(f"Test selection full-suite fallback: {error}", file=sys.stderr)
61
+ finally:
62
+ if cache is not None:
63
+ cache.close()
64
+ extra = args.pytest_args[1:] if args.pytest_args[:1] == ["--"] else args.pytest_args
65
+ # No shell splitting: paths with spaces remain one argument. In particular,
66
+ # an empty successful plan never becomes bare pytest (which runs everything).
67
+ try:
68
+ return subprocess.run([sys.executable, "-m", "pytest", *selected, *extra], cwd=repo).returncode
69
+ except OSError as error:
70
+ if selected == config.test_roots or (error.errno != errno.E2BIG and getattr(error, "winerror", None) != 206):
71
+ raise
72
+ # Ask the OS rather than guessing a limit: environment size and quoting
73
+ # also consume the available command line. Retry broader roots only once.
74
+ print(
75
+ "Test selection full-suite fallback: pytest command line is too long; running configured roots.",
76
+ file=sys.stderr,
77
+ )
78
+ return subprocess.run([sys.executable, "-m", "pytest", *config.test_roots, *extra], cwd=repo).returncode
File without changes