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,123 @@
|
|
|
1
|
+
"""Reader-owned declarations: one path language and one YAML rule boundary."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from pytest_selective.impact_models import DependencyManifest, contents_node
|
|
9
|
+
|
|
10
|
+
SIDECAR_SUFFIX = ".gv-dependency.yaml"
|
|
11
|
+
DIRECTORY_MANIFEST = ".gv-dependencies.yaml"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DependencyDeclarationError(ValueError):
|
|
15
|
+
"""An authoring error which must never become a successful full fallback."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_declaration(path: str) -> bool:
|
|
19
|
+
return path.endswith(SIDECAR_SUFFIX) or PurePosixPath(path).name == DIRECTORY_MANIFEST
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _path_pattern(pattern: str) -> re.Pattern[str]:
|
|
23
|
+
"""Compile our validated *, ?, ** language on every supported Python.
|
|
24
|
+
|
|
25
|
+
A slash is never matched by an ordinary wildcard; a whole ** segment can
|
|
26
|
+
consume zero or more directories. Hidden files are ordinary tracked inputs.
|
|
27
|
+
"""
|
|
28
|
+
segments = pattern.split("/")
|
|
29
|
+
expression = ""
|
|
30
|
+
for index, segment in enumerate(segments):
|
|
31
|
+
last = index == len(segments) - 1
|
|
32
|
+
if segment == "**":
|
|
33
|
+
expression += ".*" if last else "(?:[^/]+/)*"
|
|
34
|
+
else:
|
|
35
|
+
expression += "".join("[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c) for c in segment)
|
|
36
|
+
if not last:
|
|
37
|
+
expression += "/"
|
|
38
|
+
return re.compile(expression, re.DOTALL)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def match_inputs(pattern: str, files: set[str], location: str, directory: str = ".") -> set[str]:
|
|
42
|
+
parts = pattern.split("/")
|
|
43
|
+
if (
|
|
44
|
+
any(part in {"", ".", ".."} for part in parts)
|
|
45
|
+
or any(char in pattern for char in "\\:[]{}")
|
|
46
|
+
or any("**" in part and part != "**" for part in parts)
|
|
47
|
+
):
|
|
48
|
+
raise DependencyDeclarationError(f"{location}: invalid repository-relative dependency pattern {pattern!r}")
|
|
49
|
+
rooted = pattern if directory == "." else f"{directory}/{pattern}"
|
|
50
|
+
matcher = _path_pattern(rooted)
|
|
51
|
+
targets = {path for path in files if matcher.fullmatch(path)}
|
|
52
|
+
if not targets:
|
|
53
|
+
raise DependencyDeclarationError(f"{location}: test dependency {pattern!r} matches no tracked files")
|
|
54
|
+
return targets
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _check_yaml_keys(node: yaml.Node, seen: set[int]) -> None:
|
|
58
|
+
# Reject aliases and duplicate keys: neither may silently hide a rule.
|
|
59
|
+
if id(node) in seen:
|
|
60
|
+
raise DependencyDeclarationError("YAML aliases are not supported")
|
|
61
|
+
seen.add(id(node))
|
|
62
|
+
if isinstance(node, yaml.MappingNode):
|
|
63
|
+
keys: set[str] = set()
|
|
64
|
+
for key, value in node.value:
|
|
65
|
+
if not isinstance(key, yaml.ScalarNode) or key.tag != "tag:yaml.org,2002:str" or key.value in keys:
|
|
66
|
+
raise DependencyDeclarationError(f"line {key.start_mark.line + 1}: duplicate or non-string YAML key")
|
|
67
|
+
keys.add(key.value)
|
|
68
|
+
_check_yaml_keys(value, seen)
|
|
69
|
+
elif isinstance(node, yaml.SequenceNode):
|
|
70
|
+
for child in node.value:
|
|
71
|
+
_check_yaml_keys(child, seen)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_manifest(source: bytes, location: str) -> DependencyManifest:
|
|
75
|
+
try:
|
|
76
|
+
node = yaml.compose(source.decode("utf-8-sig"), Loader=yaml.SafeLoader)
|
|
77
|
+
if node is not None:
|
|
78
|
+
_check_yaml_keys(node, set())
|
|
79
|
+
return DependencyManifest.model_validate(yaml.safe_load(source))
|
|
80
|
+
except (ValueError, yaml.YAMLError) as error:
|
|
81
|
+
raise DependencyDeclarationError(f"{location}: {error}") from error
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def manifest_edges(path: str, source: bytes, tree: str, files: set[str]) -> dict[str, set[str]]:
|
|
85
|
+
location = f"{tree}:{path}"
|
|
86
|
+
is_directory = PurePosixPath(path).name == DIRECTORY_MANIFEST
|
|
87
|
+
owner = path.removesuffix(SIDECAR_SUFFIX)
|
|
88
|
+
if not is_directory and (owner not in files or is_declaration(owner)):
|
|
89
|
+
raise DependencyDeclarationError(f"{location}: sidecar must belong to an existing non-sidecar tracked file")
|
|
90
|
+
manifest = parse_manifest(source, location)
|
|
91
|
+
directory = str(PurePosixPath(path).parent)
|
|
92
|
+
reader_files = {file for file in files if not is_declaration(file)}
|
|
93
|
+
edges: dict[str, set[str]] = {} if is_directory else {owner: {path}}
|
|
94
|
+
for index, rule in enumerate(manifest.dependencies, 1):
|
|
95
|
+
rule_location = f"{location}:rule {index}"
|
|
96
|
+
if is_directory and not rule.readers:
|
|
97
|
+
raise DependencyDeclarationError(f"{rule_location}: directory rule requires readers")
|
|
98
|
+
if not is_directory and "readers" in rule.model_fields_set:
|
|
99
|
+
raise DependencyDeclarationError(f"{rule_location}: file sidecar must not specify readers")
|
|
100
|
+
readers = {owner}
|
|
101
|
+
if is_directory:
|
|
102
|
+
assert rule.readers is not None
|
|
103
|
+
readers = _expand(rule.readers, reader_files, directory, rule_location)
|
|
104
|
+
inputs = _expand(rule.inputs, files, directory, rule_location)
|
|
105
|
+
for reader in readers:
|
|
106
|
+
dependencies = inputs if rule.kind == "import" else {contents_node(target) for target in inputs}
|
|
107
|
+
edges.setdefault(reader, set()).update(dependencies | {path})
|
|
108
|
+
edges[reader].discard(reader)
|
|
109
|
+
return edges
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _expand(patterns: list[str], files: set[str], directory: str, location: str) -> set[str]:
|
|
113
|
+
targets: set[str] = set()
|
|
114
|
+
for pattern in patterns:
|
|
115
|
+
targets.update(
|
|
116
|
+
match_inputs(
|
|
117
|
+
pattern[2:] if pattern.startswith("//") else pattern,
|
|
118
|
+
files,
|
|
119
|
+
location,
|
|
120
|
+
"." if pattern.startswith("//") else directory,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return targets
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Why a selection is as large as it is, and whether a change widened the dependency graph.
|
|
3
|
+
|
|
4
|
+
Selection answers "which tests can this change affect". An explanation answers the questions
|
|
5
|
+
an author asks next: which few files funnel most of those tests to the change, whether the
|
|
6
|
+
change sits in the startup floor that nearly every test loads, and whether it made some file
|
|
7
|
+
reach many more tests than before. Everything is computed from the same immutable snapshots
|
|
8
|
+
selection used; nothing here can change which tests run.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable, Mapping
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
|
|
14
|
+
from pytest_selective.impact_graph import (
|
|
15
|
+
EdgeEvidence,
|
|
16
|
+
reachable,
|
|
17
|
+
reverse_edges,
|
|
18
|
+
seed_nodes,
|
|
19
|
+
shortest_path,
|
|
20
|
+
union_edges,
|
|
21
|
+
within,
|
|
22
|
+
)
|
|
23
|
+
from pytest_selective.impact_models import (
|
|
24
|
+
BlastRadiusChange,
|
|
25
|
+
ChangedFileReach,
|
|
26
|
+
EdgeKind,
|
|
27
|
+
FloorChange,
|
|
28
|
+
FloorCycle,
|
|
29
|
+
FloorEntry,
|
|
30
|
+
FloorEntryKind,
|
|
31
|
+
ImpactConfig,
|
|
32
|
+
ImpactReport,
|
|
33
|
+
SelectionExplanation,
|
|
34
|
+
SelectionRoute,
|
|
35
|
+
Snapshot,
|
|
36
|
+
file_of,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
#: A Python file is in the startup floor when at least this share of test files depend on it.
|
|
40
|
+
FLOOR_SHARE = 0.9
|
|
41
|
+
#: A file funnels a route when this many selected tests reach the change only through it.
|
|
42
|
+
FUNNEL_MIN_TESTS = 8
|
|
43
|
+
FUNNEL_MIN_SHARE = 0.2
|
|
44
|
+
#: A file's blast radius widened when it gained this many dependent test files.
|
|
45
|
+
WIDENED_MIN_TESTS = 20
|
|
46
|
+
WIDENED_MIN_SHARE = 0.1
|
|
47
|
+
TOP_FUNNELS = 3
|
|
48
|
+
NESTED_FUNNELS = 2
|
|
49
|
+
TOP_WIDENED = 3
|
|
50
|
+
#: Changed files attributed in the explanation, largest share of the selection first.
|
|
51
|
+
TOP_CHANGES = 5
|
|
52
|
+
#: A floor cycle is reported when it holds at least this many floor files and this share of it.
|
|
53
|
+
CYCLE_MIN_FILES = 5
|
|
54
|
+
CYCLE_MIN_SHARE = 0.25
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _strongly_connected(nodes: list[str], edges: dict[str, list[str]]) -> list[list[str]]:
|
|
58
|
+
"""Tarjan's components, iteratively; each is emitted after every component it depends on."""
|
|
59
|
+
index: dict[str, int] = {}
|
|
60
|
+
low: dict[str, int] = {}
|
|
61
|
+
stack: list[str] = []
|
|
62
|
+
on_stack: set[str] = set()
|
|
63
|
+
components: list[list[str]] = []
|
|
64
|
+
for start in nodes:
|
|
65
|
+
if start in index:
|
|
66
|
+
continue
|
|
67
|
+
work = [(start, iter(edges.get(start, ())))]
|
|
68
|
+
index[start] = low[start] = len(index)
|
|
69
|
+
stack.append(start)
|
|
70
|
+
on_stack.add(start)
|
|
71
|
+
while work:
|
|
72
|
+
node, targets = work[-1]
|
|
73
|
+
advanced = False
|
|
74
|
+
for target in targets:
|
|
75
|
+
if target not in index:
|
|
76
|
+
index[target] = low[target] = len(index)
|
|
77
|
+
stack.append(target)
|
|
78
|
+
on_stack.add(target)
|
|
79
|
+
work.append((target, iter(edges.get(target, ()))))
|
|
80
|
+
advanced = True
|
|
81
|
+
break
|
|
82
|
+
if target in on_stack:
|
|
83
|
+
low[node] = min(low[node], index[target])
|
|
84
|
+
if advanced:
|
|
85
|
+
continue
|
|
86
|
+
work.pop()
|
|
87
|
+
if work:
|
|
88
|
+
low[work[-1][0]] = min(low[work[-1][0]], low[node])
|
|
89
|
+
if low[node] == index[node]:
|
|
90
|
+
component = []
|
|
91
|
+
while True:
|
|
92
|
+
member = stack.pop()
|
|
93
|
+
on_stack.discard(member)
|
|
94
|
+
component.append(member)
|
|
95
|
+
if member == node:
|
|
96
|
+
break
|
|
97
|
+
components.append(component)
|
|
98
|
+
return components
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_bits(snapshot: Snapshot) -> dict[str, int]:
|
|
102
|
+
"""Each test file's bit in the masks `dependent_tests` returns: bit i is `snapshot.tests[i]`."""
|
|
103
|
+
return {test: 1 << position for position, test in enumerate(snapshot.tests)}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def dependent_tests(snapshot: Snapshot) -> dict[str, int]:
|
|
107
|
+
"""
|
|
108
|
+
For every file, a bitmask of the test files that depend on it (bit i is `snapshot.tests[i]`).
|
|
109
|
+
|
|
110
|
+
A file read only as content counts as reached by the tests that read it.
|
|
111
|
+
"""
|
|
112
|
+
targets = {target for dependencies in snapshot.edges.values() for target in dependencies}
|
|
113
|
+
nodes = sorted(set(snapshot.edges) | targets | set(snapshot.files))
|
|
114
|
+
components = _strongly_connected(nodes, snapshot.edges)
|
|
115
|
+
owner = {member: position for position, component in enumerate(components) for member in component}
|
|
116
|
+
bits = test_bits(snapshot)
|
|
117
|
+
masks = [0] * len(components)
|
|
118
|
+
# Tarjan emits dependencies first, so the reverse order visits every reader before its targets.
|
|
119
|
+
for position in range(len(components) - 1, -1, -1):
|
|
120
|
+
for member in components[position]:
|
|
121
|
+
masks[position] |= bits.get(member, 0)
|
|
122
|
+
for member in components[position]:
|
|
123
|
+
for target in snapshot.edges.get(member, ()):
|
|
124
|
+
if owner[target] != position:
|
|
125
|
+
masks[owner[target]] |= masks[position]
|
|
126
|
+
reach: dict[str, int] = {}
|
|
127
|
+
for node, position in owner.items():
|
|
128
|
+
reach[file_of(node)] = reach.get(file_of(node), 0) | masks[position]
|
|
129
|
+
return reach
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def startup_floor(snapshot: Snapshot, reach: dict[str, int]) -> set[str]:
|
|
133
|
+
"""Non-test Python files that at least `FLOOR_SHARE` of test files depend on."""
|
|
134
|
+
if not snapshot.tests:
|
|
135
|
+
return set()
|
|
136
|
+
threshold = FLOOR_SHARE * len(snapshot.tests)
|
|
137
|
+
tests = set(snapshot.tests)
|
|
138
|
+
return {
|
|
139
|
+
path
|
|
140
|
+
for path in snapshot.files
|
|
141
|
+
if path.endswith(".py") and path not in tests and reach.get(path, 0).bit_count() >= threshold
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _dominators(root: str, successors: Mapping[str, Iterable[str]]) -> dict[str, str]:
|
|
146
|
+
"""Immediate dominators of every node reachable from `root` (Cooper, Harvey and Kennedy)."""
|
|
147
|
+
order: list[str] = []
|
|
148
|
+
seen = {root}
|
|
149
|
+
work = [(root, iter(sorted(successors.get(root, ()))))]
|
|
150
|
+
while work:
|
|
151
|
+
node, children = work[-1]
|
|
152
|
+
child = next(children, None)
|
|
153
|
+
if child is None:
|
|
154
|
+
order.append(node)
|
|
155
|
+
work.pop()
|
|
156
|
+
elif child not in seen:
|
|
157
|
+
seen.add(child)
|
|
158
|
+
work.append((child, iter(sorted(successors.get(child, ())))))
|
|
159
|
+
postorder = {node: position for position, node in enumerate(order)}
|
|
160
|
+
predecessors: dict[str, list[str]] = {node: [] for node in order}
|
|
161
|
+
for node in order:
|
|
162
|
+
for child in successors.get(node, ()):
|
|
163
|
+
predecessors[child].append(node)
|
|
164
|
+
idom = {root: root}
|
|
165
|
+
changed = True
|
|
166
|
+
while changed:
|
|
167
|
+
changed = False
|
|
168
|
+
for node in reversed(order):
|
|
169
|
+
if node == root:
|
|
170
|
+
continue
|
|
171
|
+
candidates = [pred for pred in predecessors[node] if pred in idom]
|
|
172
|
+
new = candidates[0]
|
|
173
|
+
for pred in candidates[1:]:
|
|
174
|
+
a, b = pred, new
|
|
175
|
+
while a != b:
|
|
176
|
+
while postorder[a] < postorder[b]:
|
|
177
|
+
a = idom[a]
|
|
178
|
+
while postorder[b] < postorder[a]:
|
|
179
|
+
b = idom[b]
|
|
180
|
+
new = a
|
|
181
|
+
if idom.get(node) != new:
|
|
182
|
+
idom[node] = new
|
|
183
|
+
changed = True
|
|
184
|
+
return idom
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _bits_of(reach: dict[str, int], paths: Iterable[str]) -> int:
|
|
188
|
+
mask = 0
|
|
189
|
+
for path in paths:
|
|
190
|
+
mask |= reach.get(path, 0)
|
|
191
|
+
return mask
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _funnels(
|
|
195
|
+
reverse: dict[str, list[str]], seeds: list[str], reached: list[str], tests: list[str]
|
|
196
|
+
) -> list[SelectionRoute]:
|
|
197
|
+
"""
|
|
198
|
+
Files every path from the change to many reached tests passes through, largest first.
|
|
199
|
+
|
|
200
|
+
Dominators of the reverse graph, rooted at the change: a test's dominator chain is exactly
|
|
201
|
+
the files it cannot reach the change without. Each funnel nests inside the nearest enclosing
|
|
202
|
+
funnel on that chain, which is the one closer to the change.
|
|
203
|
+
"""
|
|
204
|
+
root = "<change>"
|
|
205
|
+
idom = _dominators(root, {**reverse, root: sorted(seed_nodes(seeds))})
|
|
206
|
+
counts: dict[str, int] = {}
|
|
207
|
+
for test in reached:
|
|
208
|
+
node = idom[test]
|
|
209
|
+
while node != root:
|
|
210
|
+
counts[node] = counts.get(node, 0) + 1
|
|
211
|
+
node = idom[node]
|
|
212
|
+
excluded = seed_nodes(seeds) | set(tests)
|
|
213
|
+
threshold = max(FUNNEL_MIN_TESTS, FUNNEL_MIN_SHARE * len(reached))
|
|
214
|
+
funnels = {node for node, count in counts.items() if count >= threshold and node not in excluded}
|
|
215
|
+
|
|
216
|
+
def enclosing(node: str) -> str | None:
|
|
217
|
+
ancestor = idom[node]
|
|
218
|
+
while ancestor != root:
|
|
219
|
+
if ancestor in funnels:
|
|
220
|
+
return ancestor
|
|
221
|
+
ancestor = idom[ancestor]
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
parents = {funnel: enclosing(funnel) for funnel in funnels}
|
|
225
|
+
ranked = sorted(funnels, key=lambda node: (-counts[node], node))
|
|
226
|
+
routes: list[SelectionRoute] = []
|
|
227
|
+
for funnel in [node for node in ranked if parents[node] is None][:TOP_FUNNELS]:
|
|
228
|
+
routes.append(SelectionRoute(kind="funnel", tests=counts[funnel], via=file_of(funnel)))
|
|
229
|
+
for inner in [node for node in ranked if parents[node] == funnel][:NESTED_FUNNELS]:
|
|
230
|
+
routes.append(
|
|
231
|
+
SelectionRoute(kind="funnel", tests=counts[inner], via=file_of(inner), parent=file_of(funnel))
|
|
232
|
+
)
|
|
233
|
+
return routes
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _routes(
|
|
237
|
+
edges: dict[str, list[str]],
|
|
238
|
+
seeds: list[str],
|
|
239
|
+
selected: list[str],
|
|
240
|
+
floor_seeds: list[str],
|
|
241
|
+
reach: dict[str, int],
|
|
242
|
+
after: Snapshot,
|
|
243
|
+
) -> list[SelectionRoute]:
|
|
244
|
+
"""Group the selected tests by how they reach the change; top-level routes partition them."""
|
|
245
|
+
reverse = reverse_edges(edges)
|
|
246
|
+
reachable_nodes = reachable(reverse, seed_nodes(seeds))
|
|
247
|
+
reached = [test for test in selected if test in reachable_nodes]
|
|
248
|
+
routes: list[SelectionRoute] = []
|
|
249
|
+
if floor_seeds:
|
|
250
|
+
floor_mask, bits = _bits_of(reach, floor_seeds), test_bits(after)
|
|
251
|
+
routes.append(SelectionRoute(kind="floor", tests=sum(1 for test in reached if floor_mask & bits[test])))
|
|
252
|
+
else:
|
|
253
|
+
seed_set = set(seeds)
|
|
254
|
+
if edited := sum(1 for test in reached if test in seed_set):
|
|
255
|
+
routes.append(SelectionRoute(kind="changed", tests=edited))
|
|
256
|
+
routes += _funnels(reverse, seeds, reached, after.tests)
|
|
257
|
+
if remaining := len(reached) - sum(route.tests for route in routes if route.parent is None):
|
|
258
|
+
routes.append(SelectionRoute(kind="other", tests=remaining))
|
|
259
|
+
if len(selected) > len(reached):
|
|
260
|
+
routes.append(SelectionRoute(kind="unreached", tests=len(selected) - len(reached)))
|
|
261
|
+
return routes
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _startup_roots(snapshot: Snapshot) -> list[str]:
|
|
265
|
+
"""Files at least `FLOOR_SHARE` of test files depend on directly: the floor's entry points."""
|
|
266
|
+
counts: dict[str, int] = {}
|
|
267
|
+
tests = set(snapshot.tests)
|
|
268
|
+
for test in snapshot.tests:
|
|
269
|
+
for dependency in snapshot.edges.get(test, ()):
|
|
270
|
+
if file_of(dependency) not in tests:
|
|
271
|
+
counts[dependency] = counts.get(dependency, 0) + 1
|
|
272
|
+
return sorted(node for node, count in counts.items() if count >= FLOOR_SHARE * len(snapshot.tests))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _entry_kind(path: str, edge: EdgeKind | None) -> FloorEntryKind:
|
|
276
|
+
"""What a test's direct edge to a floor entry says about how to shrink what the entry loads."""
|
|
277
|
+
name = PurePosixPath(path).name
|
|
278
|
+
if name == "__init__.py":
|
|
279
|
+
return "package"
|
|
280
|
+
if edge == "conftest":
|
|
281
|
+
return "conftest"
|
|
282
|
+
if edge == "startup":
|
|
283
|
+
return "hooks" if name == "conftest.py" else "startup"
|
|
284
|
+
if edge in {"import", "string"}:
|
|
285
|
+
return "import"
|
|
286
|
+
return "declared"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _floor_entries(snapshot: Snapshot, roots: list[str], floor: set[str], evidence: EdgeEvidence) -> list[FloorEntry]:
|
|
290
|
+
"""
|
|
291
|
+
Each startup root as a file, how tests load it, and its floor share.
|
|
292
|
+
|
|
293
|
+
The edge is read from a test outside the root's directory when there is one: a conftest its
|
|
294
|
+
own descendants load is an ancestor to them, and only an outside reader shows its hooks made
|
|
295
|
+
it global.
|
|
296
|
+
"""
|
|
297
|
+
entries: dict[str, FloorEntry] = {}
|
|
298
|
+
for root in roots:
|
|
299
|
+
path = file_of(root)
|
|
300
|
+
readers = [test for test in snapshot.tests if root in snapshot.edges.get(test, ())]
|
|
301
|
+
reader = _reader_outside(snapshot, path, readers)
|
|
302
|
+
pulled = {file_of(node) for node in reachable(snapshot.edges, {root})} & floor
|
|
303
|
+
entry = FloorEntry(path=path, kind=_entry_kind(path, evidence.step(reader, root).kind), floor_files=len(pulled))
|
|
304
|
+
if path not in entries or entry.floor_files > entries[path].floor_files:
|
|
305
|
+
entries[path] = entry
|
|
306
|
+
return sorted(entries.values(), key=lambda entry: (-entry.floor_files, entry.path))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _floor_cycle(snapshot: Snapshot, floor: set[str]) -> FloorCycle | None:
|
|
310
|
+
"""The floor's largest import cycle, when it holds enough of the floor to be the thing to fix."""
|
|
311
|
+
inside = {path: [target for target in snapshot.edges.get(path, ()) if target in floor] for path in floor}
|
|
312
|
+
largest = set(max(_strongly_connected(sorted(floor), inside), key=len, default=[]))
|
|
313
|
+
if len(largest) < max(CYCLE_MIN_FILES, CYCLE_MIN_SHARE * len(floor)):
|
|
314
|
+
return None
|
|
315
|
+
readers: dict[str, int] = {}
|
|
316
|
+
for member in largest:
|
|
317
|
+
for target in inside[member]:
|
|
318
|
+
if target in largest:
|
|
319
|
+
readers[target] = readers.get(target, 0) + 1
|
|
320
|
+
hub = min(readers, key=lambda path: (-readers[path], path))
|
|
321
|
+
return FloorCycle(size=len(largest), hub=hub, hub_readers=readers[hub])
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _reader_outside(snapshot: Snapshot, path: str, readers: list[str]) -> str:
|
|
325
|
+
"""A test that loads `path` from outside its directory when there is one, else the first."""
|
|
326
|
+
directory = str(PurePosixPath(path).parent)
|
|
327
|
+
return next((test for test in readers if not within(test, directory)), readers[0])
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _changed_reach(
|
|
331
|
+
snapshot: Snapshot,
|
|
332
|
+
seeds: list[str],
|
|
333
|
+
selected: list[str],
|
|
334
|
+
reach: dict[str, int],
|
|
335
|
+
roots: list[str],
|
|
336
|
+
evidence: EdgeEvidence,
|
|
337
|
+
) -> list[ChangedFileReach]:
|
|
338
|
+
"""
|
|
339
|
+
Attribute the selection to the changed files: how many selected tests load each one, and how.
|
|
340
|
+
|
|
341
|
+
A test can load several changed files, so the counts overlap; each says what that file alone
|
|
342
|
+
selects. Hubs are searched without the startup floor: every test depends on its entries, so
|
|
343
|
+
naming one would say nothing about this change.
|
|
344
|
+
"""
|
|
345
|
+
bits = test_bits(snapshot)
|
|
346
|
+
chosen = sum(bits[test] for test in selected if test in bits)
|
|
347
|
+
shares = sorted(((reach.get(seed, 0) & chosen).bit_count(), seed) for seed in seeds if reach.get(seed, 0) & chosen)[
|
|
348
|
+
::-1
|
|
349
|
+
][:TOP_CHANGES]
|
|
350
|
+
if not shares:
|
|
351
|
+
return []
|
|
352
|
+
entries = set(roots)
|
|
353
|
+
own_edges = {reader: [] if reader in entries else list(deps) for reader, deps in snapshot.edges.items()}
|
|
354
|
+
own_snapshot = snapshot.model_copy(update={"edges": own_edges})
|
|
355
|
+
assert isinstance(own_snapshot, Snapshot)
|
|
356
|
+
own_reach = dependent_tests(own_snapshot)
|
|
357
|
+
reverse = reverse_edges(own_edges)
|
|
358
|
+
attributed = []
|
|
359
|
+
for count, seed in shares:
|
|
360
|
+
loaders = [test for test in selected if bits.get(test, 0) & own_reach.get(seed, 0)]
|
|
361
|
+
nodes = seed_nodes([seed])
|
|
362
|
+
direct = [test for test in loaders if nodes & set(snapshot.edges.get(test, ()))]
|
|
363
|
+
ancestors = reachable(reverse, nodes) - nodes
|
|
364
|
+
hubs: dict[str, list[str]] = {}
|
|
365
|
+
for test in loaders:
|
|
366
|
+
if test in direct:
|
|
367
|
+
continue
|
|
368
|
+
for dependency in snapshot.edges.get(test, ()):
|
|
369
|
+
if dependency in ancestors and dependency not in entries and file_of(dependency) not in bits:
|
|
370
|
+
hubs.setdefault(dependency, []).append(test)
|
|
371
|
+
entry = ChangedFileReach(path=seed, tests=count, direct=len(direct), own=len(loaders))
|
|
372
|
+
if hubs:
|
|
373
|
+
hub = min(hubs, key=lambda node: (-len(hubs[node]), node))
|
|
374
|
+
reader = _reader_outside(snapshot, file_of(hub), hubs[hub])
|
|
375
|
+
kind = _entry_kind(file_of(hub), evidence.step(reader, hub).kind)
|
|
376
|
+
entry = entry.model_copy(update={"hub": file_of(hub), "hub_tests": len(hubs[hub]), "hub_kind": kind})
|
|
377
|
+
attributed.append(entry)
|
|
378
|
+
return attributed
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _mask(snapshot: Snapshot, tests: set[str]) -> int:
|
|
382
|
+
return sum(bit for test, bit in test_bits(snapshot).items() if test in tests)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _widened(
|
|
386
|
+
before: Snapshot, after: Snapshot, reach_before: dict[str, int], reach_after: dict[str, int], floor_added: set[str]
|
|
387
|
+
) -> list[tuple[str, int, int]]:
|
|
388
|
+
"""
|
|
389
|
+
Files that more existing test files depend on than before.
|
|
390
|
+
|
|
391
|
+
Only test files present in both trees count: a PR that adds tests importing a module did not
|
|
392
|
+
widen the graph, it tested more.
|
|
393
|
+
"""
|
|
394
|
+
threshold = max(WIDENED_MIN_TESTS, WIDENED_MIN_SHARE * len(after.tests))
|
|
395
|
+
common = set(before.tests) & set(after.tests)
|
|
396
|
+
common_before, common_after = _mask(before, common), _mask(after, common)
|
|
397
|
+
after_tests = set(after.tests)
|
|
398
|
+
rows = []
|
|
399
|
+
for path in after.files.keys() & before.files.keys():
|
|
400
|
+
if not path.endswith(".py") or path in after_tests or path in floor_added:
|
|
401
|
+
continue
|
|
402
|
+
old = (reach_before.get(path, 0) & common_before).bit_count()
|
|
403
|
+
new = (reach_after.get(path, 0) & common_after).bit_count()
|
|
404
|
+
if new - old >= threshold:
|
|
405
|
+
rows.append((path, old, new))
|
|
406
|
+
return sorted(rows, key=lambda row: (row[1] - row[2], row[0]))[:TOP_WIDENED]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _cause(before: Snapshot, after: Snapshot, changed: set[str], target: str) -> list[str]:
|
|
410
|
+
"""A dependency this change added, from a changed file, whose target leads to `target`."""
|
|
411
|
+
for reader in sorted(changed):
|
|
412
|
+
added = sorted(set(after.edges.get(reader, ())) - set(before.edges.get(reader, ())))
|
|
413
|
+
for dependency in added:
|
|
414
|
+
onward = shortest_path(after.edges, [dependency], seed_nodes([target]))
|
|
415
|
+
if onward:
|
|
416
|
+
return [reader, *onward]
|
|
417
|
+
return []
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def explain(
|
|
421
|
+
repo: Path,
|
|
422
|
+
before: Snapshot,
|
|
423
|
+
after: Snapshot,
|
|
424
|
+
report: ImpactReport,
|
|
425
|
+
config: ImpactConfig,
|
|
426
|
+
*,
|
|
427
|
+
graph_before: Snapshot | None = None,
|
|
428
|
+
forced_full: bool = False,
|
|
429
|
+
) -> SelectionExplanation:
|
|
430
|
+
"""
|
|
431
|
+
Explain `report`, which selection produced from `before` and `after`.
|
|
432
|
+
|
|
433
|
+
Routes follow the selection itself. Floor and widening deltas compare `after` with
|
|
434
|
+
`graph_before`, the PR's merge base, when there is one: selection may have started from a tree
|
|
435
|
+
an earlier push already validated, which would make a graph change vanish after that push.
|
|
436
|
+
"""
|
|
437
|
+
graph_before = graph_before or before
|
|
438
|
+
reach_after, reach_before = dependent_tests(after), dependent_tests(graph_before)
|
|
439
|
+
floor_after, floor_before = startup_floor(after, reach_after), startup_floor(graph_before, reach_before)
|
|
440
|
+
floor_seeds = sorted(set(report.seeds) & floor_after)
|
|
441
|
+
roots = _startup_roots(after)
|
|
442
|
+
evidence = EdgeEvidence(repo, after, config)
|
|
443
|
+
added = sorted(floor_after - floor_before)
|
|
444
|
+
changed = set(report.changed)
|
|
445
|
+
explanation = SelectionExplanation(
|
|
446
|
+
base=report.base,
|
|
447
|
+
head=report.head,
|
|
448
|
+
selected=len(report.selected),
|
|
449
|
+
total=report.total_files,
|
|
450
|
+
forced_full=forced_full,
|
|
451
|
+
graph_base=graph_before.tree,
|
|
452
|
+
floor_seeds=floor_seeds,
|
|
453
|
+
routes=_routes(union_edges(before, after), report.seeds, report.selected, floor_seeds, reach_after, after),
|
|
454
|
+
floor=FloorChange(
|
|
455
|
+
share=FLOOR_SHARE,
|
|
456
|
+
before=len(floor_before),
|
|
457
|
+
after=len(floor_after),
|
|
458
|
+
added=added,
|
|
459
|
+
removed=sorted(floor_before - floor_after),
|
|
460
|
+
path=evidence.path(shortest_path(after.edges, roots, set(added)) if added else []),
|
|
461
|
+
files=sorted(floor_after),
|
|
462
|
+
entries=_floor_entries(after, roots, floor_after, evidence),
|
|
463
|
+
cycle=_floor_cycle(after, floor_after),
|
|
464
|
+
),
|
|
465
|
+
changes=_changed_reach(after, report.seeds, report.selected, reach_after, roots, evidence),
|
|
466
|
+
widened=[
|
|
467
|
+
BlastRadiusChange(
|
|
468
|
+
path=file, before=old, after=new, cause=evidence.path(_cause(graph_before, after, changed, file))
|
|
469
|
+
)
|
|
470
|
+
for file, old, new in _widened(graph_before, after, reach_before, reach_after, set(added))
|
|
471
|
+
],
|
|
472
|
+
)
|
|
473
|
+
if floor_seeds:
|
|
474
|
+
# A changed root explains nothing about how the rest of the change entered the floor.
|
|
475
|
+
beyond_roots = [seed for seed in floor_seeds if seed not in roots] or floor_seeds
|
|
476
|
+
route_path = shortest_path(after.edges, roots, seed_nodes(beyond_roots))
|
|
477
|
+
elif (funnel := explanation.top_funnel) is not None and funnel.via is not None:
|
|
478
|
+
route_path = shortest_path(after.edges, [funnel.via], seed_nodes(report.seeds))
|
|
479
|
+
else:
|
|
480
|
+
route_path = []
|
|
481
|
+
return explanation.model_copy(update={"path": evidence.path(route_path)})
|