pytest-selective 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_selective/__init__.py +0 -0
- pytest_selective/__main__.py +3 -0
- pytest_selective/ast_imports.py +75 -0
- pytest_selective/ci.py +242 -0
- pytest_selective/hygiene.py +118 -0
- pytest_selective/impact_audit.py +104 -0
- pytest_selective/impact_audit_plugin.py +217 -0
- pytest_selective/impact_cli.py +432 -0
- pytest_selective/impact_declarations.py +123 -0
- pytest_selective/impact_explanation.py +481 -0
- pytest_selective/impact_first_party.py +362 -0
- pytest_selective/impact_graph.py +885 -0
- pytest_selective/impact_models.py +491 -0
- pytest_selective/impact_name_scope.py +498 -0
- pytest_selective/impact_selection.py +242 -0
- pytest_selective/input_policy.py +27 -0
- pytest_selective/local.py +78 -0
- pytest_selective/py.typed +0 -0
- pytest_selective/pytest_expansion.py +535 -0
- pytest_selective/pytest_sharding.py +269 -0
- pytest_selective/test_inventory.py +39 -0
- pytest_selective/typecheck_scope.py +111 -0
- pytest_selective/working_tree.py +43 -0
- pytest_selective-0.1.0.dist-info/METADATA +263 -0
- pytest_selective-0.1.0.dist-info/RECORD +28 -0
- pytest_selective-0.1.0.dist-info/WHEEL +4 -0
- pytest_selective-0.1.0.dist-info/entry_points.txt +3 -0
- pytest_selective-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,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
|
+
)
|