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,362 @@
1
+ """
2
+ Characterize a first-party dependency pin move by the modules it actually changes.
3
+
4
+ Consumers declare their lock file and manifest as inputs of the root conftest: the whole pytest
5
+ invocation runs on that installed dependency set, so any change to it can affect any test. That is
6
+ right for third-party upgrades and far too broad for a package whose source this organization owns.
7
+ A backend-common pin moves on every push to a backend-common pull request and once more when it
8
+ merges, and each move re-ran every consumer test, including a post-merge repin between two commits
9
+ with byte-identical package contents.
10
+
11
+ For a declared first-party package this module proves the lock and manifest differ only in that
12
+ package's pin, fetches both pinned commits from the declared repository, and returns the dotted
13
+ names of the package modules that changed or import something that changed. Anything it cannot
14
+ prove raises ``ValueError``, and the caller keeps the lock and manifest as global inputs.
15
+ """
16
+
17
+ import base64
18
+ import hashlib
19
+ import os
20
+ import shutil
21
+ import stat
22
+ import subprocess
23
+ import sys
24
+ import tomllib
25
+ from pathlib import Path, PurePosixPath
26
+ from typing import Any
27
+
28
+ from pytest_selective.impact_graph import (
29
+ GraphCache,
30
+ inventory,
31
+ pytest_policies,
32
+ reachable,
33
+ resolve_graph,
34
+ reverse_edges,
35
+ union_edges,
36
+ within,
37
+ )
38
+ from pytest_selective.impact_models import FirstPartyPackage, ImpactConfig, ImpactModel
39
+
40
+ COMMIT_LENGTH = 40
41
+ #: Commits the cached clone may hold before it is rebuilt from the two a pin move needs.
42
+ MAX_CACHED_COMMITS = 16
43
+ #: Pin keys a uv source may use; a move between any of them is still only a pin move.
44
+ PIN_KEYS = ("rev", "tag", "branch")
45
+
46
+
47
+ class PinMove(ImpactModel):
48
+ """The two commits a first-party pin moved between."""
49
+
50
+ old: str
51
+ new: str
52
+
53
+
54
+ class PackageChange(ImpactModel):
55
+ """What a pin move changes inside the installed package."""
56
+
57
+ #: Installed files that differ between the pinned commits. Empty means the installed
58
+ #: environment is byte-identical, which is what lets lock-triggered lanes skip.
59
+ installed_files: list[str]
60
+ #: Dotted names of package modules that changed or import something that changed.
61
+ modules: list[str]
62
+
63
+
64
+ def _toml(document: bytes, label: str) -> dict[str, Any]:
65
+ try:
66
+ return tomllib.loads(document.decode("utf-8"))
67
+ except (UnicodeDecodeError, tomllib.TOMLDecodeError) as error:
68
+ raise ValueError(f"cannot parse {label}: {error}") from error
69
+
70
+
71
+ def _base_url(source: str) -> str:
72
+ return source.split("#", 1)[0].split("?", 1)[0]
73
+
74
+
75
+ def _normalize(value: Any, repository: str) -> Any:
76
+ """Replace every reference to the first-party repository with the bare repository URL."""
77
+ if isinstance(value, dict):
78
+ return {key: _normalize(item, repository) for key, item in value.items()}
79
+ if isinstance(value, list):
80
+ return [_normalize(item, repository) for item in value]
81
+ if isinstance(value, str) and _base_url(value) == repository:
82
+ return repository
83
+ return value
84
+
85
+
86
+ def _locked_commit(document: dict[str, Any], spec: FirstPartyPackage, label: str) -> str:
87
+ entries = [entry for entry in document.get("package", []) if entry.get("name") == spec.name]
88
+ if len(entries) != 1:
89
+ raise ValueError(f"{label} locks {len(entries)} entries named {spec.name}")
90
+ source = entries[0].get("source", {}).get("git")
91
+ if not isinstance(source, str):
92
+ raise ValueError(f"{label} does not lock {spec.name} from git")
93
+ if _base_url(source) != spec.repository:
94
+ raise ValueError(f"{label} source {_base_url(source)} does not match the declared repository {spec.repository}")
95
+ commit = source.partition("#")[2]
96
+ if len(commit) != COMMIT_LENGTH or any(char not in "0123456789abcdef" for char in commit):
97
+ raise ValueError(f"{label} records no commit for {spec.name}")
98
+ return commit
99
+
100
+
101
+ def _without_package(document: dict[str, Any], spec: FirstPartyPackage) -> dict[str, Any]:
102
+ """
103
+ Drop the package's own lock entry, which describes the package rather than the environment.
104
+
105
+ Its version, dependency list and metadata follow its source; any consequence for the installed
106
+ environment appears as a change to some other entry, which still counts.
107
+ """
108
+ stripped = dict(document)
109
+ stripped["package"] = [entry for entry in document.get("package", []) if entry.get("name") != spec.name]
110
+ return stripped
111
+
112
+
113
+ def _changed_entries(before: dict[str, Any], after: dict[str, Any]) -> list[str]:
114
+ def by_name(document: dict[str, Any]) -> dict[str, Any]:
115
+ return {entry.get("name", "?"): entry for entry in document.get("package", [])}
116
+
117
+ old, new = by_name(before), by_name(after)
118
+ changed = sorted(name for name in old.keys() | new.keys() if old.get(name) != new.get(name))
119
+ top = sorted(key for key in before.keys() | after.keys() if key != "package" and before.get(key) != after.get(key))
120
+ return changed + top
121
+
122
+
123
+ def _unpinned_manifest(document: dict[str, Any], spec: FirstPartyPackage) -> dict[str, Any]:
124
+ normalized = _normalize(document, spec.repository)
125
+ source = normalized.get("tool", {}).get("uv", {}).get("sources", {}).get(spec.name)
126
+ if isinstance(source, dict):
127
+ for key in PIN_KEYS:
128
+ source.pop(key, None)
129
+ return normalized
130
+
131
+
132
+ def first_party_pin_move(
133
+ spec: FirstPartyPackage,
134
+ *,
135
+ before_lock: bytes,
136
+ after_lock: bytes,
137
+ before_manifest: bytes | None,
138
+ after_manifest: bytes | None,
139
+ ) -> PinMove:
140
+ """
141
+ Prove the lock and manifest differ only in ``spec``'s pin, and name both commits.
142
+
143
+ Raises ``ValueError`` describing whatever else changed, or why the pin cannot be read.
144
+ """
145
+ before, after = _toml(before_lock, spec.lock), _toml(after_lock, spec.lock)
146
+ move = PinMove(
147
+ old=_locked_commit(before, spec, f"base {spec.lock}"),
148
+ new=_locked_commit(after, spec, f"head {spec.lock}"),
149
+ )
150
+ old_environment = _normalize(_without_package(before, spec), spec.repository)
151
+ new_environment = _normalize(_without_package(after, spec), spec.repository)
152
+ if old_environment != new_environment:
153
+ raise ValueError(f"{spec.lock} also changes {', '.join(_changed_entries(old_environment, new_environment))}")
154
+ if (before_manifest is None) != (after_manifest is None):
155
+ raise ValueError(f"{spec.manifest} was added or removed")
156
+ if before_manifest is not None and after_manifest is not None:
157
+ old_manifest = _unpinned_manifest(_toml(before_manifest, spec.manifest), spec)
158
+ new_manifest = _unpinned_manifest(_toml(after_manifest, spec.manifest), spec)
159
+ if old_manifest != new_manifest:
160
+ raise ValueError(f"{spec.manifest} changes more than the {spec.name} pin")
161
+ return move
162
+
163
+
164
+ def refuse_pytest_plugin(
165
+ spec: FirstPartyPackage, repo: Path, files: dict[str, str], extra_files: tuple[str, ...] = ()
166
+ ) -> None:
167
+ """
168
+ Refuse to narrow a package that the consumer's pytest configuration names.
169
+
170
+ `-p backend_common.x` loads a module into every test without any Python file importing it, so
171
+ import analysis cannot see its changes. Any mention is refused, not only `-p`: a false refusal
172
+ costs one complete suite, a missed plugin silently skips tests.
173
+ """
174
+ for path, policy in pytest_policies(repo, files, extra_files).items():
175
+ if spec.import_prefix in policy:
176
+ raise ValueError(
177
+ f"the pytest configuration in {path} names {spec.import_prefix}, so its changes reach every test"
178
+ )
179
+
180
+
181
+ def _git_environment(spec: FirstPartyPackage, token: str | None) -> dict[str, str]:
182
+ environment = dict(os.environ, GIT_TERMINAL_PROMPT="0")
183
+ if token and spec.repository.startswith("https://"):
184
+ host = spec.repository.split("/", 3)[2]
185
+ credential = base64.b64encode(f"x-access-token:{token}".encode()).decode()
186
+ # Scoped to this child process and this host. Never written to any git config.
187
+ environment |= {
188
+ "GIT_CONFIG_COUNT": "1",
189
+ "GIT_CONFIG_KEY_0": f"http.https://{host}/.extraheader",
190
+ "GIT_CONFIG_VALUE_0": f"AUTHORIZATION: basic {credential}",
191
+ }
192
+ return environment
193
+
194
+
195
+ def _remove_tree(path: Path) -> None:
196
+ """Delete a clone; git marks object files read-only, which Windows refuses to delete."""
197
+
198
+ def writable(function, target, _error): # noqa: ANN001 - shutil's callback contract
199
+ os.chmod(target, stat.S_IWRITE)
200
+ function(target)
201
+
202
+ if sys.version_info >= (3, 12):
203
+ shutil.rmtree(path, onexc=writable)
204
+ else:
205
+ shutil.rmtree(path, onerror=writable)
206
+
207
+
208
+ def _available_commits(repo: Path, commits: set[str]) -> set[str]:
209
+ """Prove all requested objects are commits with one Git process."""
210
+ if not commits:
211
+ return set()
212
+ ordered = sorted(commits)
213
+ probe = subprocess.run(
214
+ ["git", "-C", str(repo), "cat-file", "--batch-check=%(objecttype)"],
215
+ input="".join(f"{commit}^{{commit}}\n" for commit in ordered).encode(),
216
+ capture_output=True,
217
+ check=True,
218
+ )
219
+ return {commit for commit, kind in zip(ordered, probe.stdout.splitlines(), strict=True) if kind == b"commit"}
220
+
221
+
222
+ def fetch_commits(spec: FirstPartyPackage, move: PinMove, cache_dir: Path, token: str | None) -> Path:
223
+ """Fetch both pinned commits into a cached bare repository, from the declared URL only."""
224
+ repo = cache_dir / hashlib.sha256(spec.repository.encode()).hexdigest()[:16]
225
+ environment = _git_environment(spec, token)
226
+ try:
227
+ # The clone rides inside the `.test-impact` actions cache, which is deliberately bounded.
228
+ # Every fetch is shallow, so its size is the commits in `shallow`; past the bound, start over.
229
+ shallow = repo / "shallow"
230
+ if shallow.exists() and len(shallow.read_text().split()) + 2 > MAX_CACHED_COMMITS:
231
+ _remove_tree(repo)
232
+ if not (repo / "HEAD").exists():
233
+ repo.mkdir(parents=True, exist_ok=True)
234
+ subprocess.run(["git", "init", "-q", "--bare", str(repo)], capture_output=True, check=True)
235
+ wanted = {move.old, move.new}
236
+ available = _available_commits(repo, wanted)
237
+ missing = sorted(wanted - available)
238
+ if missing:
239
+ # This shallow cache is bounded and rebuilt above; automatic maintenance
240
+ # needlessly starts another process after every tiny fetch on Windows.
241
+ # The older alias also supports our documented Git 2.24 minimum.
242
+ subprocess.run(
243
+ [
244
+ "git",
245
+ "-C",
246
+ str(repo),
247
+ "fetch",
248
+ "-q",
249
+ "--no-auto-gc",
250
+ "--no-tags",
251
+ "--depth=1",
252
+ spec.repository,
253
+ *missing,
254
+ ],
255
+ capture_output=True,
256
+ check=True,
257
+ env=environment,
258
+ timeout=120,
259
+ )
260
+ # Existing objects were already proved; only fetched objects need another probe.
261
+ available |= _available_commits(repo, set(missing))
262
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as error:
263
+ detail = getattr(error, "stderr", b"") or b""
264
+ message = detail.decode(errors="replace").strip().splitlines()[-1:] if detail else [type(error).__name__]
265
+ raise ValueError(
266
+ f"could not compare {spec.name} {move.old[:12]}..{move.new[:12]}: fetch failed ({message[0] if message else ''})"
267
+ ) from error
268
+ for commit in (move.old, move.new):
269
+ if commit not in available:
270
+ raise ValueError(f"could not compare {spec.name}: {commit[:12]} is not in {spec.repository}")
271
+ return repo
272
+
273
+
274
+ def _module_name(path: str, spec: FirstPartyPackage) -> str:
275
+ parts = list(PurePosixPath(path).relative_to(spec.source_root).with_suffix("").parts)
276
+ if parts and parts[-1] == "__init__":
277
+ parts.pop()
278
+ return ".".join(parts)
279
+
280
+
281
+ def _owning_subsystem(path: str, spec: FirstPartyPackage) -> str:
282
+ """
283
+ Directory treated as reading a changed non-Python file: the package's first-level subpackage.
284
+
285
+ Nothing imports a markdown fragment or a shell script, so there is no edge to follow. The
286
+ subsystem that ships it is the conservative owner; a file directly in the package root owns all.
287
+ """
288
+ parts = PurePosixPath(path).relative_to(spec.source_root).parts
289
+ depth = 2 if len(parts) > 2 else 1
290
+ return "/".join([spec.source_root, *parts[:depth]])
291
+
292
+
293
+ def package_change(
294
+ spec: FirstPartyPackage,
295
+ move: PinMove,
296
+ *,
297
+ cache: GraphCache,
298
+ cache_dir: Path,
299
+ token: str | None,
300
+ ) -> PackageChange:
301
+ """
302
+ Installed files that differ between the pins, and the modules they reach inside the package.
303
+
304
+ An identical pair of commits, or commits whose installed source is byte-identical, returns no
305
+ change without resolving any graph.
306
+ """
307
+ if move.old == move.new:
308
+ return PackageChange(installed_files=[], modules=[])
309
+ repo = fetch_commits(spec, move, cache_dir, token)
310
+ trees = [inventory(repo, commit) for commit in (move.old, move.new)]
311
+ installed = [{path: oid for path, oid in tree.files.items() if within(path, spec.source_root)} for tree in trees]
312
+ before, after = installed
313
+ changed = {path for path in before.keys() | after.keys() if before.get(path) != after.get(path)}
314
+ if not changed:
315
+ return PackageChange(installed_files=[], modules=[])
316
+ python = {path for tree in installed for path in tree if path.endswith(".py")}
317
+ seeds = {path for path in changed if path.endswith(".py")}
318
+ for path in changed - seeds:
319
+ owned = {candidate for candidate in python if within(candidate, _owning_subsystem(path, spec))}
320
+ # A data-only subpackage has no module to follow, and any module may read it through
321
+ # `importlib.resources` by name; with no provable owner, the whole package owns it.
322
+ seeds |= owned or python
323
+ # Resolved inside the installed source only: the package's own tests and tools never reach a
324
+ # consumer. Old and new edges are unioned, so deleted or renamed modules keep their importers.
325
+ config = ImpactConfig(module_roots=[spec.source_root], test_roots=[spec.source_root], test_patterns=[])
326
+ try:
327
+ snapshots = [
328
+ resolve_graph(
329
+ tree.tree,
330
+ files,
331
+ cache.parse_python(repo, files),
332
+ config,
333
+ [link for link in tree.links if within(link, spec.source_root)],
334
+ )
335
+ for tree, files in zip(trees, installed, strict=True)
336
+ ]
337
+ except ValueError as error:
338
+ raise ValueError(f"could not compare {spec.name}: {error}") from error
339
+ reverse = reverse_edges(union_edges(*snapshots))
340
+ affected = reachable(reverse, seeds) & python
341
+ return PackageChange(
342
+ installed_files=sorted(changed),
343
+ modules=sorted({_module_name(path, spec) for path in affected}),
344
+ )
345
+
346
+
347
+ def importers(candidates_by_file: dict[str, list[str]], prefix: str, modules: set[str]) -> set[str]:
348
+ """
349
+ Consumer files naming any changed module, or a module whose ancestor package changed.
350
+
351
+ Importing ``a.b.c`` executes ``a`` and ``a.b`` first, so a change to either reaches it.
352
+ """
353
+ selected: set[str] = set()
354
+ for path, candidates in candidates_by_file.items():
355
+ for candidate in candidates:
356
+ if candidate != prefix and not candidate.startswith(prefix + "."):
357
+ continue
358
+ parts = candidate.split(".")
359
+ if any(".".join(parts[:end]) in modules for end in range(1, len(parts) + 1)):
360
+ selected.add(path)
361
+ break
362
+ return selected