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,885 @@
1
+ """
2
+ Import graphs from immutable Git trees; no imports execute during planning.
3
+
4
+ Blob parsing and resolved graphs have distinct cache keys. Every new tree
5
+ resolves all saved candidates again, including previously unresolved names.
6
+ """
7
+
8
+ import ast
9
+ import configparser
10
+ import fnmatch
11
+ import hashlib
12
+ import io
13
+ import json
14
+ import os
15
+ import re
16
+ import sqlite3
17
+ import subprocess
18
+ import sys
19
+ import tokenize
20
+ import tomllib
21
+ import unicodedata
22
+ from collections import deque
23
+ from pathlib import Path, PurePosixPath
24
+ from typing import TypeVar
25
+
26
+ from pytest_selective.ast_imports import import_candidates, iter_imports, parse_ast
27
+ from pytest_selective.impact_declarations import (
28
+ DependencyDeclarationError,
29
+ is_declaration,
30
+ manifest_edges,
31
+ match_inputs,
32
+ )
33
+ from pytest_selective.impact_models import (
34
+ DeferredRoute,
35
+ DependencyComment,
36
+ EdgeKind,
37
+ GitTree,
38
+ ImpactConfig,
39
+ ImportSpec,
40
+ ParsedSource,
41
+ PathStep,
42
+ Snapshot,
43
+ contents_node,
44
+ file_of,
45
+ )
46
+
47
+ VERSION = "19"
48
+ OPAQUE_CALLS = {
49
+ "__import__",
50
+ "import_module",
51
+ "import_string",
52
+ "spec_from_file_location",
53
+ "read_text",
54
+ "read_bytes",
55
+ "open",
56
+ "Popen",
57
+ "check_call",
58
+ "check_output",
59
+ "exec",
60
+ "eval",
61
+ "run_module",
62
+ "run_path",
63
+ "walk",
64
+ "rglob",
65
+ "glob",
66
+ }
67
+ MODULE_STRING_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.")
68
+
69
+
70
+ def is_module_string(value: str) -> bool:
71
+ """
72
+ Whether a string literal may name a module and so creates a dependency edge.
73
+
74
+ Re-exports, patch targets, pytest_plugins and framework registrations commonly contain
75
+ literal dotted names instead of import statements.
76
+ """
77
+ return "." in value and len(value) < 300 and set(value) <= MODULE_STRING_CHARS
78
+
79
+
80
+ def matches(path: str, patterns: list[str]) -> bool:
81
+ return any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns)
82
+
83
+
84
+ def within(path: str, root: str) -> bool:
85
+ return root == "." or path == root or path.startswith(root.rstrip("/") + "/")
86
+
87
+
88
+ def git(repo: Path, *args: str, data: bytes | None = None) -> bytes:
89
+ return subprocess.run(
90
+ ["git", "-C", str(repo), *args],
91
+ input=data,
92
+ capture_output=True,
93
+ check=True,
94
+ env=git_environment(),
95
+ ).stdout
96
+
97
+
98
+ def git_environment() -> dict[str, str]:
99
+ """Explicit repository paths win over location variables inherited from hooks."""
100
+ locations = {
101
+ "GIT_DIR",
102
+ "GIT_WORK_TREE",
103
+ "GIT_INDEX_FILE",
104
+ "GIT_COMMON_DIR",
105
+ "GIT_OBJECT_DIRECTORY",
106
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
107
+ "GIT_PREFIX",
108
+ "GIT_SHALLOW_FILE",
109
+ "GIT_GRAFT_FILE",
110
+ }
111
+ return {name: value for name, value in os.environ.items() if name not in locations}
112
+
113
+
114
+ CacheKey = TypeVar("CacheKey")
115
+ CacheValue = TypeVar("CacheValue")
116
+ FULL_OBJECT_ID = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
117
+ CACHED_OBJECTS = 1024
118
+ # One plan lists the same revisions several times, and each Git process costs ~40 ms on
119
+ # Windows. Both maps are content-addressed: a commit id names one tree, a tree one listing.
120
+ # Commit ids stay per repository, so an object another repository lacks still fails there.
121
+ _commit_trees: dict[tuple[str, str], str] = {}
122
+ _tree_listings: dict[str, bytes] = {}
123
+
124
+
125
+ def _remember(cache: dict[CacheKey, CacheValue], key: CacheKey, value: CacheValue) -> None:
126
+ if len(cache) >= CACHED_OBJECTS:
127
+ cache.clear()
128
+ cache[key] = value
129
+
130
+
131
+ def inventory(repo: Path, revision: str) -> GitTree:
132
+ commit_key = (str(repo), revision)
133
+ tree = _commit_trees.get(commit_key)
134
+ if tree is None:
135
+ tree = git(repo, "rev-parse", "--verify", "--end-of-options", f"{revision}^{{tree}}").decode().strip()
136
+ if FULL_OBJECT_ID.fullmatch(revision):
137
+ _remember(_commit_trees, commit_key, tree)
138
+ listing = _tree_listings.get(tree)
139
+ if listing is None:
140
+ listing = git(repo, "ls-tree", "-rz", tree)
141
+ _remember(_tree_listings, tree, listing)
142
+ files: dict[str, str] = {}
143
+ links: list[str] = []
144
+ for record in listing.split(b"\0"):
145
+ if not record:
146
+ continue
147
+ metadata, name = record.split(b"\t", 1)
148
+ mode, kind, oid = metadata.decode().split()
149
+ relative_name = name.decode("utf-8")
150
+ if mode == "120000" and not PurePosixPath(relative_name).suffix:
151
+ links.append(relative_name)
152
+ if kind != "blob" or (mode not in {"100644", "100755"} and name.endswith(b".py")):
153
+ raise ValueError(f"unsupported Git entry {name!r}: {mode} (symlink/submodule)")
154
+ files[name.decode("utf-8")] = oid
155
+ return GitTree(tree=tree, files=files, links=links)
156
+
157
+
158
+ def blobs(repo: Path, ids: set[str]) -> dict[str, bytes]:
159
+ if not ids:
160
+ return {}
161
+ output = git(repo, "cat-file", "--batch", data=("\n".join(sorted(ids)) + "\n").encode())
162
+ result: dict[str, bytes] = {}
163
+ offset = 0
164
+ for _ in ids:
165
+ end = output.index(b"\n", offset)
166
+ oid, kind, size = output[offset:end].split()
167
+ if kind != b"blob":
168
+ raise ValueError("Git object is not a blob")
169
+ offset = end + 1
170
+ length = int(size)
171
+ result[oid.decode()] = output[offset : offset + length]
172
+ offset += length + 1
173
+ return result
174
+
175
+
176
+ def pytest_policies(repo: Path, files: dict[str, str], extra_files: tuple[str, ...] = ()) -> dict[str, str]:
177
+ """
178
+ Canonical JSON of the pytest configuration in each file pytest reads it from.
179
+
180
+ One owner for which files carry pytest configuration and how each is read, shared by the
181
+ review fingerprints and by any rule that must inspect what that configuration loads.
182
+ """
183
+ # Collection can choose a nested/hidden config, even an empty one. Track all
184
+ # conventional candidates; caller-supplied -c files are opaque global policy.
185
+ if missing := set(extra_files) - files.keys():
186
+ raise ValueError(f"declared pytest configuration missing from committed tree: {sorted(missing)}")
187
+ names = {"pyproject.toml", "pytest.ini", ".pytest.ini", "pytest.toml", ".pytest.toml", "setup.cfg", "tox.ini"}
188
+ paths = {path: oid for path, oid in files.items() if PurePosixPath(path).name in names or path in extra_files}
189
+ contents = blobs(repo, set(paths.values()))
190
+ policies: dict[str, str] = {}
191
+ for path, oid in paths.items():
192
+ source = contents[oid].decode("utf-8-sig")
193
+ if path in extra_files:
194
+ # No format guess for pytest -c: every byte may affect a plugin.
195
+ policies[path] = json.dumps(source)
196
+ continue
197
+ if path.endswith(".toml"):
198
+ document = tomllib.loads(source)
199
+ policy = (
200
+ document.get("tool", {}).get("pytest", {})
201
+ if PurePosixPath(path).name == "pyproject.toml"
202
+ else document.get("pytest", {})
203
+ )
204
+ canonical = json.dumps(policy, sort_keys=True)
205
+ else:
206
+ parser = configparser.ConfigParser(interpolation=None)
207
+ parser.read_string(source)
208
+ policy_sections = {
209
+ section: dict(parser[section]) for section in parser.sections() if section in {"pytest", "tool:pytest"}
210
+ }
211
+ canonical = json.dumps(policy_sections, sort_keys=True)
212
+ policies[path] = canonical
213
+ return policies
214
+
215
+
216
+ def policy_fingerprints(repo: Path, tree: GitTree, extra_files: tuple[str, ...] = ()) -> dict[str, str]:
217
+ return {
218
+ path: hashlib.sha256(canonical.encode()).hexdigest()
219
+ for path, canonical in pytest_policies(repo, tree.files, extra_files).items()
220
+ }
221
+
222
+
223
+ def dependency_comment(text: str, line: int) -> DependencyComment | None:
224
+ text = text.removeprefix("#").strip()
225
+ return DependencyComment(line=line, text=text) if text.startswith("gv:test-dependency") else None
226
+
227
+
228
+ def dependency_comments(source: bytes) -> list[DependencyComment]:
229
+ comments: list[DependencyComment] = []
230
+ try:
231
+ encoding, _ = tokenize.detect_encoding(io.BytesIO(source).readline)
232
+ text = source.decode(encoding)
233
+ # Most files have no declarations. Decode before the cheap prefilter so
234
+ # source encoding cookies cannot conceal a real marker from analysis.
235
+ if "gv:test-dependency" not in text:
236
+ return comments
237
+ for token in tokenize.generate_tokens(io.StringIO(text).readline):
238
+ if token.type == tokenize.COMMENT:
239
+ if comment := dependency_comment(token.string, token.start[0]):
240
+ comments.append(comment)
241
+ except (tokenize.TokenError, SyntaxError, UnicodeError):
242
+ # The AST parse diagnostic owns malformed Python. Keep any actual
243
+ # declarations found before the tokenization error for validation.
244
+ pass
245
+ return comments
246
+
247
+
248
+ def add_sidecar_edges(repo: Path, tree: str, files: dict[str, str], snapshot: Snapshot) -> None:
249
+ sidecars = {path: oid for path, oid in files.items() if is_declaration(path)}
250
+ contents = blobs(repo, set(sidecars.values()))
251
+ for path, oid in sorted(sidecars.items()):
252
+ for reader, dependencies in manifest_edges(path, contents[oid], tree, set(files)).items():
253
+ snapshot.edges[reader] = sorted(set(snapshot.edges.get(reader, [])) | dependencies)
254
+
255
+
256
+ def declared_inputs(comment: DependencyComment, files: dict[str, str], location: str) -> set[str]:
257
+ prefix = "gv:test-dependency:"
258
+ if not comment.text.startswith(prefix):
259
+ raise DependencyDeclarationError(
260
+ f"{location}:{comment.line}: invalid test dependency {comment.text!r}; expected one repository-relative path or glob"
261
+ )
262
+ return match_inputs(comment.text.removeprefix(prefix).strip(), set(files), f"{location}:{comment.line}")
263
+
264
+
265
+ def parse_source(source: bytes) -> ParsedSource:
266
+ dependencies = dependency_comments(source)
267
+ try:
268
+ tree = parse_ast(source)
269
+ except (SyntaxError, ValueError) as error:
270
+ return ParsedSource(
271
+ imports=[], strings=[], opaque=[f"cannot parse: {error}"], pytest_plugin=False, dependencies=dependencies
272
+ )
273
+ # Reuse one breadth-first traversal for every fact extracted from this blob.
274
+ # Large expression subtrees used to be walked repeatedly for assignments,
275
+ # imports, function names and call/string dependencies.
276
+ nodes = list(ast.walk(tree))
277
+ import_nodes = [node for node in nodes if isinstance(node, (ast.Import, ast.ImportFrom))]
278
+ # Django stores this module name; assigning it does not execute its routes.
279
+ # Keep the default graph conservative unless the consumer supplies the policy.
280
+ registrations = [
281
+ node
282
+ for node in tree.body
283
+ if isinstance(node, ast.Assign)
284
+ and len(node.targets) == 1
285
+ and isinstance(node.targets[0], ast.Name)
286
+ and node.targets[0].id == "ROOT_URLCONF"
287
+ and isinstance(node.value, ast.Constant)
288
+ and isinstance(node.value.value, str)
289
+ ]
290
+ writes = sum(
291
+ isinstance(node, ast.Name) and node.id == "ROOT_URLCONF" and isinstance(node.ctx, ast.Store) for node in nodes
292
+ )
293
+ registration = registrations[0].value if len(registrations) == writes == 1 else None
294
+ imports = [
295
+ ImportSpec(module="", names=[alias.name for alias in node.names])
296
+ if isinstance(node, ast.Import)
297
+ else ImportSpec(module=node.module or "", names=[alias.name for alias in node.names], level=node.level)
298
+ for node in import_nodes
299
+ ]
300
+ strings: set[str] = set()
301
+ opaque: set[str] = set()
302
+ plugin = False
303
+ aliases = {
304
+ alias.asname or alias.name: alias.name
305
+ for node in import_nodes
306
+ if isinstance(node, ast.Import)
307
+ for alias in node.names
308
+ }
309
+ for statement in import_nodes:
310
+ if isinstance(statement, ast.ImportFrom):
311
+ for alias in statement.names:
312
+ aliases[alias.asname or alias.name] = f"{statement.module}.{alias.name}"
313
+ local_functions = {node.name for node in nodes if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))}
314
+ for node in nodes:
315
+ if isinstance(node, ast.Constant) and isinstance(node.value, str) and node is not registration:
316
+ if is_module_string(node.value):
317
+ strings.add(node.value)
318
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("pytest_"):
319
+ plugin = True
320
+ if not isinstance(node, ast.Call):
321
+ continue
322
+ function = node.func
323
+ attributes: list[str] = []
324
+ while isinstance(function, ast.Attribute):
325
+ attributes.insert(0, function.attr)
326
+ function = function.value
327
+ owner = aliases.get(function.id, function.id) if isinstance(function, ast.Name) else ""
328
+ qualified = ".".join([owner, *attributes])
329
+ name = qualified.rsplit(".", 1)[-1]
330
+ is_ast_walk = (
331
+ name == "walk"
332
+ and isinstance(node.func, ast.Attribute)
333
+ and isinstance(node.func.value, ast.Name)
334
+ and node.func.value.id == "ast"
335
+ )
336
+ is_ast_walk = is_ast_walk or (name == "walk" and isinstance(node.func, ast.Name) and name in local_functions)
337
+ is_builtin_code = name in {"exec", "eval"} and isinstance(node.func, ast.Name)
338
+ subprocess_run = qualified.startswith("subprocess.") or qualified in {"os.system", "os.popen"}
339
+ mode = (
340
+ node.args[1]
341
+ if len(node.args) > 1
342
+ else next((keyword.value for keyword in node.keywords if keyword.arg == "mode"), None)
343
+ )
344
+ write_only = (
345
+ qualified in {"open", "builtins.open", "io.open"}
346
+ and isinstance(mode, ast.Constant)
347
+ and isinstance(mode.value, str)
348
+ and "+" not in mode.value
349
+ and any(flag in mode.value for flag in "wax")
350
+ )
351
+ if write_only:
352
+ continue
353
+ if (
354
+ (name in OPAQUE_CALLS or subprocess_run)
355
+ and not is_ast_walk
356
+ and (name not in {"exec", "eval"} or is_builtin_code)
357
+ ):
358
+ literal_import = (
359
+ name in {"__import__", "import_module", "import_string"}
360
+ and node.args
361
+ and isinstance(node.args[0], ast.Constant)
362
+ and isinstance(node.args[0].value, str)
363
+ )
364
+ if literal_import:
365
+ argument = node.args[0]
366
+ assert isinstance(argument, ast.Constant)
367
+ strings.add(str(argument.value))
368
+ if str(argument.value).startswith("."):
369
+ opaque.add(f"relative dynamic import at line {node.lineno}")
370
+ else:
371
+ opaque.add(f"{name} at line {node.lineno}")
372
+ return ParsedSource(
373
+ django_urlconf=str(registration.value) if isinstance(registration, ast.Constant) else None,
374
+ imports=imports,
375
+ strings=sorted(strings),
376
+ opaque=sorted(opaque),
377
+ pytest_plugin=plugin,
378
+ dependencies=dependencies,
379
+ )
380
+
381
+
382
+ def _cache_prefix() -> str:
383
+ """Namespace for cache records: an analyzer or grammar change must never reuse old parses."""
384
+ return f"{VERSION}:{sys.version_info[:2]}:"
385
+
386
+
387
+ class GraphCache:
388
+ """Optional performance state, never evidence that a test passed."""
389
+
390
+ def __init__(self, path: Path):
391
+ path.parent.mkdir(parents=True, exist_ok=True)
392
+ self.connection = sqlite3.connect(path)
393
+ self.connection.execute("CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, digest TEXT)")
394
+ self.parsed_blobs = 0
395
+ self.snapshot_hits = 0
396
+
397
+ def close(self) -> None:
398
+ # Bound transport cost independently of repository age or push count.
399
+ removed = self.connection.execute(
400
+ "DELETE FROM cache WHERE key LIKE '%:graph:%' AND rowid NOT IN "
401
+ "(SELECT rowid FROM cache WHERE key LIKE '%:graph:%' ORDER BY rowid DESC LIMIT 8)"
402
+ ).rowcount
403
+ removed += self.connection.execute(
404
+ "DELETE FROM cache WHERE key LIKE '%:blob:%' AND rowid NOT IN "
405
+ "(SELECT rowid FROM cache WHERE key LIKE '%:blob:%' ORDER BY rowid DESC LIMIT 10000)"
406
+ ).rowcount
407
+ self.connection.commit()
408
+ if removed:
409
+ self.connection.execute("VACUUM")
410
+ self.connection.close()
411
+
412
+ def get(self, key: str) -> str | None:
413
+ row = self.connection.execute("SELECT value, digest FROM cache WHERE key=?", (key,)).fetchone()
414
+ if row is None:
415
+ return None
416
+ value, digest = row
417
+ return value if hashlib.sha256(value.encode()).hexdigest() == digest else None
418
+
419
+ def put(self, key: str, value: str) -> None:
420
+ self.connection.execute(
421
+ "INSERT OR REPLACE INTO cache VALUES (?, ?, ?)",
422
+ (key, value, hashlib.sha256(value.encode()).hexdigest()),
423
+ )
424
+
425
+ def parse_python(self, repo: Path, files: dict[str, str]) -> dict[str, ParsedSource]:
426
+ """
427
+ Parse every Python blob in ``files``, reusing checksummed records keyed by blob ID.
428
+
429
+ The single parsing path for every repository the selector reads, consumer and first-party
430
+ package alike, so both share one cache, one key scheme and one ``parsed_blobs`` count.
431
+ """
432
+ prefix = _cache_prefix()
433
+ parsed: dict[str, ParsedSource] = {}
434
+ missing: set[str] = set()
435
+ for path, oid in files.items():
436
+ if not path.endswith(".py"):
437
+ continue
438
+ raw = self.get(f"{prefix}blob:{oid}")
439
+ if raw is not None:
440
+ try:
441
+ parsed[oid] = ParsedSource.model_validate_json(raw)
442
+ continue
443
+ except ValueError:
444
+ pass
445
+ missing.add(oid)
446
+ for oid, source in blobs(repo, missing).items():
447
+ parsed[oid] = parse_source(source)
448
+ self.put(f"{prefix}blob:{oid}", parsed[oid].model_dump_json())
449
+ self.parsed_blobs += 1
450
+ return parsed
451
+
452
+ def snapshot(self, repo: Path, revision: str, config: ImpactConfig) -> Snapshot:
453
+ git_tree = inventory(repo, revision)
454
+ tree, files = git_tree.tree, git_tree.files
455
+ # Exemptions change no edge; editing them must not discard the resolved graph.
456
+ signature = hashlib.sha256(
457
+ config.model_dump_json(
458
+ exclude={"audit_exemptions", "floor_budget", "name_scoped_modules", "global_inputs"}
459
+ ).encode()
460
+ ).hexdigest()
461
+ prefix = _cache_prefix()
462
+ key = f"{prefix}graph:{tree}:{signature}"
463
+ cached = self.get(key)
464
+ if cached is not None:
465
+ try:
466
+ snapshot = Snapshot.model_validate_json(cached)
467
+ if snapshot.tree == tree and snapshot.files == files:
468
+ self.snapshot_hits += 1
469
+ return snapshot
470
+ except ValueError:
471
+ pass
472
+ parsed = self.parse_python(repo, files)
473
+ deferred = deferred_routes(repo, tree, files, config)
474
+ snapshot = resolve_graph(tree, files, parsed, config, git_tree.links, deferred)
475
+ add_sidecar_edges(repo, tree, files, snapshot)
476
+ # Declaration errors are actionable authoring failures, even when the
477
+ # same tree also needs a collection-policy review/full-suite fallback.
478
+ if policy_fingerprints(repo, git_tree, tuple(config.pytest_config_files)) != config.pytest_policy:
479
+ raise ValueError("pytest configuration changed; review collection policy and refresh its fingerprints")
480
+ self.put(key, snapshot.model_dump_json())
481
+ self.connection.commit()
482
+ return snapshot
483
+
484
+
485
+ def _single_assignment(tree: ast.Module, assignment: str) -> ast.expr | None:
486
+ """The value of the module's one top-level assignment to `assignment`, if it is assigned exactly once."""
487
+ statements = [
488
+ node
489
+ for node in tree.body
490
+ if isinstance(node, (ast.Assign, ast.AnnAssign))
491
+ and any(
492
+ isinstance(target, ast.Name) and target.id == assignment
493
+ for target in (node.targets if isinstance(node, ast.Assign) else [node.target])
494
+ )
495
+ ]
496
+ writes = sum(
497
+ isinstance(node, ast.Name) and node.id == assignment and isinstance(node.ctx, ast.Store)
498
+ for node in ast.walk(tree)
499
+ )
500
+ return statements[0].value if len(statements) == 1 and writes == 1 else None
501
+
502
+
503
+ def assignment_strings(source: bytes, assignment: str, deferred: frozenset[str] = frozenset()) -> set[str] | None:
504
+ """
505
+ Module names that occur only inside the file's single top-level assignment to `assignment`.
506
+
507
+ None when the file doesn't parse, when the name is assigned anywhere other than exactly once at
508
+ the top level (a computed or reassigned value can't be read without running the file), or when
509
+ the assignment names no modules. A name that also occurs elsewhere in the file stays an
510
+ ordinary edge, so the result can be empty. Elsewhere excludes the file's other `deferred`
511
+ assignments: a task both routed and scheduled is deferred by both rules, not kept by each
512
+ because the other holds it.
513
+ """
514
+ try:
515
+ tree = parse_ast(source)
516
+ except (SyntaxError, ValueError):
517
+ return None
518
+ value = _single_assignment(tree, assignment)
519
+ if value is None:
520
+ return None
521
+ inside = {id(node) for node in ast.walk(value)}
522
+ siblings = (_single_assignment(tree, other) for other in deferred - {assignment})
523
+ held = {id(node) for sibling in siblings if sibling is not None for node in ast.walk(sibling)}
524
+ found: set[str] = set()
525
+ elsewhere: set[str] = set()
526
+ for node in ast.walk(tree):
527
+ if isinstance(node, ast.Constant) and isinstance(node.value, str) and is_module_string(node.value):
528
+ if id(node) in inside:
529
+ found.add(node.value)
530
+ elif id(node) not in held:
531
+ elsewhere.add(node.value)
532
+ return found - elsewhere if found else None
533
+
534
+
535
+ def deferred_routes(repo: Path, tree: str, files: dict[str, str], config: ImpactConfig) -> list[DeferredRoute]:
536
+ """Resolve each `DeferredStringsRule` against one tree, failing on a rule it can't honor."""
537
+ if not config.deferred_strings:
538
+ return []
539
+ python_files = {path for path in files if path.endswith(".py")}
540
+ marked = {marker for rule in config.deferred_strings for marker in rule.consumer_markers}
541
+ texts = (
542
+ {path: text.decode("utf-8", "replace") for path, text in read_sources(repo, files, python_files).items()}
543
+ if marked
544
+ else {}
545
+ )
546
+ deferred: dict[str, frozenset[str]] = {}
547
+ for rule in config.deferred_strings:
548
+ deferred[rule.file] = deferred.get(rule.file, frozenset()) | set(rule.assignments)
549
+ routes = []
550
+ for rule in config.deferred_strings:
551
+ location = f"{tree}:{rule.file}"
552
+ oid = files.get(rule.file)
553
+ source = blobs(repo, {oid})[oid] if oid and rule.file.endswith(".py") else None
554
+ strings: set[str] = set()
555
+ for assignment in rule.assignments:
556
+ found = assignment_strings(source, assignment, deferred[rule.file]) if source is not None else None
557
+ if found is None:
558
+ raise DependencyDeclarationError(
559
+ f"{location}: deferred_strings needs one top-level {assignment} assignment naming modules"
560
+ )
561
+ strings |= found
562
+ consumers: set[str] = set()
563
+ for pattern in rule.consumers:
564
+ consumers |= match_inputs(pattern, python_files, f"{location}:deferred_strings")
565
+ undeclared = sorted(
566
+ path
567
+ for path, text in texts.items()
568
+ if path not in consumers and path != rule.file and any(marker in text for marker in rule.consumer_markers)
569
+ )
570
+ if undeclared:
571
+ raise DependencyDeclarationError(
572
+ f"{location}: these files contain a consumer marker of {', '.join(rule.assignments)} but are not consumers: "
573
+ + ", ".join(undeclared)
574
+ )
575
+ routes.append(DeferredRoute(file=rule.file, strings=sorted(strings), consumers=sorted(consumers)))
576
+ return routes
577
+
578
+
579
+ def read_sources(repo: Path, files: dict[str, str], paths: set[str]) -> dict[str, bytes]:
580
+ """The contents of ``paths``, read from the tree ``files`` describes."""
581
+ contents = blobs(repo, {files[path] for path in paths})
582
+ return {path: contents[files[path]] for path in paths}
583
+
584
+
585
+ def source_candidates(source: ParsedSource, package: str) -> set[str]:
586
+ candidates: set[str] = set()
587
+ for spec in source.imports:
588
+ names = [ast.alias(name=name) for name in spec.names]
589
+ node = (
590
+ ast.Import(names=names)
591
+ if not spec.module and not spec.level
592
+ else ast.ImportFrom(module=spec.module, names=names, level=spec.level)
593
+ )
594
+ candidates.update(import_candidates(node, package))
595
+ return candidates
596
+
597
+
598
+ class ModuleIndex:
599
+ """Resolve against a complete Git inventory without filesystem probes."""
600
+
601
+ def __init__(self, python_files: set[str], roots: list[str], links: list[str]) -> None:
602
+ self.modules: dict[str, set[str]] = {}
603
+ self.packages: dict[str, set[str]] = {}
604
+ self.names: dict[str, set[str]] = {}
605
+ for path in python_files:
606
+ for root in roots:
607
+ if not within(path, root):
608
+ continue
609
+ relative = path if root == "." else path[len(root.rstrip("/")) + 1 :]
610
+ parts = relative.removesuffix(".py").split("/")
611
+ is_package = parts[-1] == "__init__"
612
+ if is_package:
613
+ parts.pop()
614
+ dotted = unicodedata.normalize("NFKC", ".".join(parts)).casefold()
615
+ self.modules.setdefault(dotted, set()).add(path)
616
+ package = dotted if is_package else dotted.rpartition(".")[0]
617
+ self.packages.setdefault(path, set()).add(package)
618
+ self.names.setdefault(path, set()).add(dotted)
619
+
620
+ self.linked_modules = {
621
+ unicodedata.normalize(
622
+ "NFKC", (link if root == "." else link[len(root.rstrip("/")) + 1 :]).replace("/", ".")
623
+ ).casefold()
624
+ for link in links
625
+ for root in roots
626
+ if within(link, root)
627
+ }
628
+
629
+ def resolve(self, candidate: str) -> set[str]:
630
+ # Union ambiguous roots/self.modules conservatively. Ancestor initializers
631
+ # execute for every submodule import, even when never named explicitly.
632
+ normalized = unicodedata.normalize("NFKC", candidate).casefold()
633
+ if any(normalized == link or normalized.startswith(link + ".") for link in self.linked_modules):
634
+ raise ValueError(f"import through unsupported Git symlink: {candidate}")
635
+ result: set[str] = set()
636
+ parts = unicodedata.normalize("NFKC", candidate).casefold().split(".")
637
+ for end in range(1, len(parts) + 1):
638
+ prefix = ".".join(parts[:end])
639
+ result.update(self.modules.get(prefix, ()))
640
+ return result
641
+
642
+
643
+ def resolve_graph(
644
+ tree: str,
645
+ files: dict[str, str],
646
+ parsed: dict[str, ParsedSource],
647
+ config: ImpactConfig,
648
+ links: list[str],
649
+ deferred: list[DeferredRoute] | None = None,
650
+ ) -> Snapshot:
651
+ python_files = {path for path in files if path.endswith(".py")}
652
+ index = ModuleIndex(python_files, config.module_roots, links)
653
+
654
+ edges: dict[str, set[str]] = {path: set() for path in files}
655
+ unresolved: dict[str, list[str]] = {}
656
+ opaque: dict[str, list[str]] = {}
657
+ plugins: set[str] = set()
658
+ tests = sorted(
659
+ path
660
+ for path in python_files
661
+ if any(within(path, root) for root in config.test_roots)
662
+ and matches(PurePosixPath(path).name, config.test_patterns)
663
+ )
664
+ deferred = deferred or []
665
+ # Several rules may defer different assignments of one file; each file holds the union.
666
+ held: dict[str, set[str]] = {}
667
+ for route in deferred:
668
+ held.setdefault(route.file, set()).update(route.strings)
669
+ for path in python_files:
670
+ source = parsed[files[path]]
671
+ for comment in source.dependencies:
672
+ edges[path].update(declared_inputs(comment, files, f"{tree}:{path}"))
673
+ # A deferred name is not an edge of the file that only stores it; its consumers get it below.
674
+ candidates = set(source.strings) - held.get(path, set())
675
+ if source.django_urlconf and (config.django_urlconf is None or path != config.django_urlconf.settings_file):
676
+ candidates.add(source.django_urlconf)
677
+ for package in index.packages.get(path, {""}):
678
+ candidates.update(source_candidates(source, package))
679
+ for name in index.names.get(path, ()):
680
+ edges[path].update(index.resolve(name))
681
+ missing: list[str] = []
682
+ for candidate in sorted(candidates):
683
+ resolved = index.resolve(candidate)
684
+ edges[path].update(resolved)
685
+ if candidate not in index.modules:
686
+ missing.append(candidate)
687
+ if missing:
688
+ unresolved[path] = missing
689
+ if source.opaque:
690
+ opaque[path] = source.opaque
691
+ if (
692
+ PurePosixPath(path).name == "conftest.py"
693
+ and source.pytest_plugin
694
+ and config.scoped_hooks.get(path) != files[path]
695
+ ):
696
+ plugins.add(path)
697
+ edges[path].discard(path)
698
+
699
+ for rule in config.dependencies:
700
+ inputs = {path for path in files if matches(path, rule.inputs)}
701
+ for reader in python_files:
702
+ if matches(reader, rule.readers):
703
+ edges[reader].update(
704
+ inputs - {reader} if rule.kind == "import" else {contents_node(path) for path in inputs}
705
+ )
706
+ # A reviewed declaration specifies all inputs of opaque reads.
707
+ if rule.reviewed_readers.get(reader) == files[reader] and not any(
708
+ reason.startswith("cannot parse:") for reason in opaque.get(reader, [])
709
+ ):
710
+ opaque.pop(reader, None)
711
+
712
+ if policy := config.django_urlconf:
713
+ settings_source = parsed.get(files.get(policy.settings_file, ""))
714
+ module = settings_source.django_urlconf if settings_source else None
715
+ if module is None or module.casefold() not in index.modules:
716
+ raise DependencyDeclarationError(
717
+ f"{tree}:{policy.settings_file}: ROOT_URLCONF must be one literal assignment to a tracked module"
718
+ )
719
+ # Reattach the deferred routes to every declared HTTP consumer and startup
720
+ # check. Explicit imports/declarations above remain untouched.
721
+ for pattern in policy.http_tests + policy.startup_tests:
722
+ for test in match_inputs(pattern, set(tests), f"{tree}:django_urlconf"):
723
+ edges[test].update(index.resolve(module))
724
+
725
+ for route in deferred:
726
+ targets = set().union(*(index.resolve(name) for name in route.strings))
727
+ for consumer in route.consumers:
728
+ edges[consumer].update(targets - {consumer})
729
+
730
+ globals_set = plugins | global_module_files(index, config)
731
+ conftests = {path for path in python_files if PurePosixPath(path).name == "conftest.py"}
732
+ for test in tests:
733
+ edges[test].update(globals_set - {test})
734
+ for conftest in conftests:
735
+ parent = str(PurePosixPath(conftest).parent)
736
+ if within(test, parent):
737
+ edges[test].add(conftest)
738
+ return Snapshot(
739
+ tree=tree,
740
+ files=files,
741
+ edges={path: sorted(deps) for path, deps in edges.items() if deps},
742
+ tests=tests,
743
+ opaque=opaque,
744
+ unresolved=unresolved,
745
+ )
746
+
747
+
748
+ def changed_paths(before: Snapshot, after: Snapshot) -> list[str]:
749
+ """Paths whose blob differs between two trees, including additions and deletions."""
750
+ return sorted(
751
+ path for path in before.files.keys() | after.files.keys() if before.files.get(path) != after.files.get(path)
752
+ )
753
+
754
+
755
+ def union_edges(*snapshots: Snapshot) -> dict[str, list[str]]:
756
+ """
757
+ Every tree's dependency edges, for traversal.
758
+
759
+ Removed imports, deleted files and renamed modules must keep their former dependents.
760
+ """
761
+ edges: dict[str, set[str]] = {}
762
+ for snapshot in snapshots:
763
+ for path, deps in snapshot.edges.items():
764
+ edges.setdefault(path, set()).update(deps)
765
+ return {path: sorted(deps) for path, deps in edges.items()}
766
+
767
+
768
+ def seed_nodes(paths: list[str]) -> set[str]:
769
+ """Graph nodes a changed path stands for: the file itself and readers of its contents."""
770
+ return set(paths) | {contents_node(path) for path in paths}
771
+
772
+
773
+ def global_module_files(index: ModuleIndex, config: ImpactConfig) -> set[str]:
774
+ """Files every test depends on because the configuration names them as startup modules."""
775
+ files: set[str] = set()
776
+ for module in config.global_modules:
777
+ files |= index.resolve(module)
778
+ return files
779
+
780
+
781
+ def reachable(edges: dict[str, list[str]], starts: set[str]) -> set[str]:
782
+ seen = set(starts)
783
+ queue = deque(starts)
784
+ while queue:
785
+ for target in edges.get(queue.popleft(), ()):
786
+ if target not in seen:
787
+ seen.add(target)
788
+ queue.append(target)
789
+ return seen
790
+
791
+
792
+ def reverse_edges(edges: dict[str, list[str]]) -> dict[str, list[str]]:
793
+ reverse: dict[str, list[str]] = {}
794
+ for reader, targets in edges.items():
795
+ for target in targets:
796
+ reverse.setdefault(target, []).append(reader)
797
+ return reverse
798
+
799
+
800
+ def shortest_path(edges: dict[str, list[str]], starts: list[str], goals: set[str]) -> list[str]:
801
+ """The shortest dependency path from any start to any goal node, reader first; empty if none."""
802
+ parents: dict[str, str | None] = dict.fromkeys(starts)
803
+ queue = deque(parents)
804
+ while queue:
805
+ node = queue.popleft()
806
+ if node in goals:
807
+ path = [node]
808
+ while (parent := parents[path[-1]]) is not None:
809
+ path.append(parent)
810
+ return path[::-1]
811
+ for target in edges.get(node, ()):
812
+ if target not in parents:
813
+ parents[target] = node
814
+ queue.append(target)
815
+ return []
816
+
817
+
818
+ def explanation(snapshot: Snapshot, test: str, source: str) -> list[str]:
819
+ return [file_of(node) for node in shortest_path(snapshot.edges, [test], seed_nodes([source]))]
820
+
821
+
822
+ class EdgeEvidence:
823
+ """
824
+ Why one edge of a snapshot exists, re-read from the reader's source only for reported paths.
825
+
826
+ The rules and their order are `resolve_graph`'s: import statements, then module strings
827
+ (`is_module_string`), then the reader's own package initializers; a test's ancestor conftest,
828
+ then startup modules and plugin conftests; anything else came from a declaration.
829
+ """
830
+
831
+ def __init__(self, repo: Path, snapshot: Snapshot, config: ImpactConfig) -> None:
832
+ self.repo = repo
833
+ self.snapshot = snapshot
834
+ python_files = {path for path in snapshot.files if path.endswith(".py")}
835
+ self.index = ModuleIndex(python_files, config.module_roots, inventory(repo, snapshot.tree).links)
836
+ self.globals = global_module_files(self.index, config)
837
+ self.tests = set(snapshot.tests)
838
+
839
+ def _names(self, candidates: set[str], dependency: str) -> bool:
840
+ return any(dependency in self.index.resolve(candidate) for candidate in candidates)
841
+
842
+ def _line(self, reader: str, dependency: str) -> PathStep | None:
843
+ oid = self.snapshot.files.get(reader)
844
+ if not reader.endswith(".py") or not oid:
845
+ return None
846
+ tree = parse_ast(blobs(self.repo, {oid})[oid])
847
+ packages = self.index.packages.get(reader, {""})
848
+ # The first line in the file, when several create the same edge.
849
+ imports = [
850
+ node.lineno
851
+ for node in iter_imports(tree)
852
+ if any(self._names(import_candidates(node, package), dependency) for package in packages)
853
+ ]
854
+ if imports:
855
+ return PathStep(path=reader, kind="import", line=min(imports))
856
+ strings = [
857
+ node.lineno
858
+ for node in ast.walk(tree)
859
+ if isinstance(node, ast.Constant)
860
+ and isinstance(node.value, str)
861
+ and is_module_string(node.value)
862
+ and self._names({node.value}, dependency)
863
+ ]
864
+ return PathStep(path=reader, kind="string", line=min(strings)) if strings else None
865
+
866
+ def step(self, reader: str, dependency: str) -> PathStep:
867
+ dependency = file_of(dependency)
868
+ if found := self._line(reader, dependency):
869
+ return found
870
+ kind: EdgeKind = "declared"
871
+ if self._names(self.index.names.get(reader, set()), dependency):
872
+ kind = "package"
873
+ elif reader in self.tests and PurePosixPath(dependency).name == "conftest.py":
874
+ # An ancestor supplies fixtures to its descendants; any other conftest is a global plugin.
875
+ kind = "conftest" if within(reader, str(PurePosixPath(dependency).parent)) else "startup"
876
+ elif reader in self.tests and dependency in self.globals:
877
+ kind = "startup"
878
+ return PathStep(path=reader, kind=kind)
879
+
880
+ def path(self, nodes: list[str]) -> list[PathStep]:
881
+ """Each step of a reader-first path, and the file it ends at."""
882
+ files = [file_of(node) for node in nodes]
883
+ return [self.step(reader, dependency) for reader, dependency in zip(files, files[1:])] + (
884
+ [PathStep(path=files[-1])] if files else []
885
+ )