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,498 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Narrow a change to a name-scoped module to the files that read the names it changes.
|
|
3
|
+
|
|
4
|
+
Some modules are read name by name: a settings module, a constants module, a feature-flag table. Every
|
|
5
|
+
importer depends on the whole file, so an edit to one value selects every test that loads it, which is
|
|
6
|
+
every test when the module is global. A `NameScopedModule` declares that its readers depend only on the
|
|
7
|
+
names they read. A change that only rebinds names then selects the files that read those names; anything
|
|
8
|
+
the selector cannot prove keeps the module's ordinary edges.
|
|
9
|
+
|
|
10
|
+
Design and limits: docs/technical-design/test-impact.md#name-scoped-modules.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import difflib
|
|
15
|
+
import fnmatch
|
|
16
|
+
import io
|
|
17
|
+
import re
|
|
18
|
+
import tokenize
|
|
19
|
+
import unicodedata
|
|
20
|
+
from collections.abc import Iterable, Mapping
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
from pytest_selective.ast_imports import import_candidates, iter_imports, parse_ast
|
|
26
|
+
from pytest_selective.impact_declarations import SIDECAR_SUFFIX, match_inputs
|
|
27
|
+
from pytest_selective.impact_graph import (
|
|
28
|
+
ModuleIndex,
|
|
29
|
+
blobs,
|
|
30
|
+
contents_node,
|
|
31
|
+
inventory,
|
|
32
|
+
matches,
|
|
33
|
+
reachable,
|
|
34
|
+
read_sources,
|
|
35
|
+
reverse_edges,
|
|
36
|
+
seed_nodes,
|
|
37
|
+
union_edges,
|
|
38
|
+
)
|
|
39
|
+
from pytest_selective.impact_models import ImpactConfig, NameScopedModule, Snapshot
|
|
40
|
+
|
|
41
|
+
#: Methods that change the name they are called on, as a statement (``SCHEDULE.update({...})``).
|
|
42
|
+
_MUTATORS = frozenset({"append", "extend", "insert", "update", "setdefault", "add"})
|
|
43
|
+
#: A maximal run of ASCII word characters: exactly the spans ``\b...\b`` delimits in bytes.
|
|
44
|
+
_WORD_RUN = re.compile(rb"[A-Za-z0-9_]+")
|
|
45
|
+
|
|
46
|
+
StatementKind = Literal["binding", "function", "code", "inert"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NotNarrowed(Exception):
|
|
50
|
+
"""The change cannot be proven to reach only the readers of the names it changes."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class _Statement:
|
|
55
|
+
kind: StatementKind
|
|
56
|
+
binds: frozenset[str]
|
|
57
|
+
reads: frozenset[str]
|
|
58
|
+
calls: tuple[str, ...]
|
|
59
|
+
line: int
|
|
60
|
+
dump: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _dotted(node: ast.expr) -> str | None:
|
|
64
|
+
"""``a.b.c`` for a name or attribute chain, else None."""
|
|
65
|
+
parts: list[str] = []
|
|
66
|
+
while isinstance(node, ast.Attribute):
|
|
67
|
+
parts.append(node.attr)
|
|
68
|
+
node = node.value
|
|
69
|
+
if not isinstance(node, ast.Name):
|
|
70
|
+
return None
|
|
71
|
+
parts.append(node.id)
|
|
72
|
+
return ".".join(reversed(parts))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _root(node: ast.expr) -> str | None:
|
|
76
|
+
"""The name a subscript or attribute target ultimately writes into."""
|
|
77
|
+
while isinstance(node, (ast.Attribute, ast.Subscript, ast.Starred)):
|
|
78
|
+
node = node.value
|
|
79
|
+
return node.id if isinstance(node, ast.Name) else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _target_names(target: ast.expr) -> set[str]:
|
|
83
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
84
|
+
return {name for element in target.elts for name in _target_names(element)}
|
|
85
|
+
root = _root(target)
|
|
86
|
+
return {root} if root else set()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _loads(node: ast.AST) -> frozenset[str]:
|
|
90
|
+
return frozenset(n.id for n in ast.walk(node) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _callee(node: ast.expr) -> str:
|
|
94
|
+
"""
|
|
95
|
+
A callee as written: ``config.str``, or ``config.str().lower`` for a method of a call's result.
|
|
96
|
+
|
|
97
|
+
``?`` for anything else (a subscript, a lambda), which no ``pure_calls`` pattern but ``*`` matches.
|
|
98
|
+
"""
|
|
99
|
+
if isinstance(node, ast.Attribute):
|
|
100
|
+
if isinstance(node.value, ast.Call):
|
|
101
|
+
return f"{_callee(node.value.func)}().{node.attr}"
|
|
102
|
+
base = _dotted(node.value)
|
|
103
|
+
return f"{base}.{node.attr}" if base else f"?.{node.attr}"
|
|
104
|
+
return _dotted(node) or "?"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _calls(node: ast.AST) -> tuple[str, ...]:
|
|
108
|
+
"""Every call's callee, as `_callee` writes it."""
|
|
109
|
+
return tuple(_callee(n.func) for n in ast.walk(node) if isinstance(n, ast.Call))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _statement(node: ast.stmt) -> _Statement:
|
|
113
|
+
dump = ast.dump(node)
|
|
114
|
+
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
|
|
115
|
+
return _Statement("inert", frozenset(), frozenset(), (), node.lineno, dump)
|
|
116
|
+
if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
|
|
117
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
118
|
+
binds = frozenset(name for target in targets for name in _target_names(target))
|
|
119
|
+
value = node.value
|
|
120
|
+
reads = _loads(value) if value is not None else frozenset()
|
|
121
|
+
# A subscript target's key is read too: SCHEDULE[KEY] = ...
|
|
122
|
+
reads |= frozenset(name for target in targets for name in _loads(target) if name not in binds)
|
|
123
|
+
return _Statement("binding", binds, reads, _calls(value) if value else (), node.lineno, dump)
|
|
124
|
+
if (
|
|
125
|
+
isinstance(node, ast.Expr)
|
|
126
|
+
and isinstance(node.value, ast.Call)
|
|
127
|
+
and isinstance(node.value.func, ast.Attribute)
|
|
128
|
+
and node.value.func.attr in _MUTATORS
|
|
129
|
+
and (root := _root(node.value.func.value))
|
|
130
|
+
):
|
|
131
|
+
arguments = [*node.value.args, *(keyword.value for keyword in node.value.keywords)]
|
|
132
|
+
reads = frozenset(name for argument in arguments for name in _loads(argument))
|
|
133
|
+
calls = tuple(callee for argument in arguments for callee in _calls(argument))
|
|
134
|
+
return _Statement("binding", frozenset({root}), reads, calls, node.lineno, dump)
|
|
135
|
+
# A decorator runs when the module loads and can hand the function to a framework that calls it
|
|
136
|
+
# without naming it (a signal receiver, a task, a route), so a decorated definition is code.
|
|
137
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not node.decorator_list:
|
|
138
|
+
return _Statement("function", frozenset({node.name}), _loads(node), _calls(node), node.lineno, dump)
|
|
139
|
+
return _Statement("code", frozenset(), _loads(node), _calls(node), node.lineno, dump)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _statements(source: bytes, label: str) -> list[_Statement]:
|
|
143
|
+
try:
|
|
144
|
+
tree = parse_ast(source)
|
|
145
|
+
except (SyntaxError, ValueError) as error:
|
|
146
|
+
raise NotNarrowed(f"{label} does not parse: {error}") from error
|
|
147
|
+
statements = [_statement(node) for node in tree.body]
|
|
148
|
+
# The module docstring is the module's __doc__, which a CLI's help text or a schema may read.
|
|
149
|
+
if statements and statements[0].kind == "inert":
|
|
150
|
+
statements[0] = _Statement(
|
|
151
|
+
"binding", frozenset({"__doc__"}), frozenset(), (), statements[0].line, statements[0].dump
|
|
152
|
+
)
|
|
153
|
+
return statements
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_pure(
|
|
157
|
+
callee: str,
|
|
158
|
+
pure_calls: Iterable[str],
|
|
159
|
+
functions: Mapping[str, _Statement],
|
|
160
|
+
seen: frozenset[str] = frozenset(),
|
|
161
|
+
) -> bool:
|
|
162
|
+
if any(fnmatch.fnmatchcase(callee, pattern) for pattern in pure_calls):
|
|
163
|
+
return True
|
|
164
|
+
function = functions.get(callee)
|
|
165
|
+
if function is None or callee in seen:
|
|
166
|
+
return callee in seen
|
|
167
|
+
return all(_is_pure(inner, pure_calls, functions, seen | {callee}) for inner in function.calls)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def changed_names(before: bytes, after: bytes, module: NameScopedModule) -> set[str]:
|
|
171
|
+
"""
|
|
172
|
+
The top-level names a change rebinds, directly or through what they are computed from.
|
|
173
|
+
|
|
174
|
+
Raises ``NotNarrowed`` when the change does anything else: changes an import or any other statement
|
|
175
|
+
that runs code, changes code that reads a changed name, or calls something ``module.pure_calls`` does
|
|
176
|
+
not name while computing a changed name.
|
|
177
|
+
"""
|
|
178
|
+
old, new = _statements(before, f"{module.file} (base)"), _statements(after, f"{module.file} (head)")
|
|
179
|
+
# Order matters: moving `X = 2` above `X = 1` changes X. A statement outside the longest common
|
|
180
|
+
# subsequence of the two versions is edited, including one that only moved.
|
|
181
|
+
matcher = difflib.SequenceMatcher(None, [s.dump for s in old], [s.dump for s in new], autojunk=False)
|
|
182
|
+
kept_old = {i for block in matcher.get_matching_blocks() for i in range(block.a, block.a + block.size)}
|
|
183
|
+
kept_new = {j for block in matcher.get_matching_blocks() for j in range(block.b, block.b + block.size)}
|
|
184
|
+
edited = [s for i, s in enumerate(old) if i not in kept_old] + [s for j, s in enumerate(new) if j not in kept_new]
|
|
185
|
+
for statement in edited:
|
|
186
|
+
if statement.kind == "code":
|
|
187
|
+
raise NotNarrowed(f"{module.file}:{statement.line} changes a statement that runs code")
|
|
188
|
+
names = {name for statement in edited for name in statement.binds}
|
|
189
|
+
# Whatever is computed from a changed name changes with it, in either version of the file.
|
|
190
|
+
everything = old + new
|
|
191
|
+
while True:
|
|
192
|
+
# Code that reads a changed name always counts; a binding counts until its names are in.
|
|
193
|
+
dependents = [s for s in everything if s.reads & names and (s.kind == "code" or not s.binds <= names)]
|
|
194
|
+
for statement in dependents:
|
|
195
|
+
if statement.kind == "code":
|
|
196
|
+
raise NotNarrowed(
|
|
197
|
+
f"{module.file}:{statement.line} runs code that reads " + ", ".join(sorted(statement.reads & names))
|
|
198
|
+
)
|
|
199
|
+
grown = names | {name for statement in dependents for name in statement.binds}
|
|
200
|
+
if grown == names:
|
|
201
|
+
break
|
|
202
|
+
names = grown
|
|
203
|
+
functions = {name: s for s in everything if s.kind == "function" for name in s.binds}
|
|
204
|
+
for statement in everything:
|
|
205
|
+
if statement.kind != "binding" or not statement.binds & names:
|
|
206
|
+
continue
|
|
207
|
+
for callee in statement.calls:
|
|
208
|
+
if not _is_pure(callee, module.pure_calls, functions):
|
|
209
|
+
raise NotNarrowed(
|
|
210
|
+
f"{module.file}:{statement.line} calls {callee} while computing "
|
|
211
|
+
+ ", ".join(sorted(statement.binds & names))
|
|
212
|
+
+ "; name it in pure_calls if it has no side effects"
|
|
213
|
+
)
|
|
214
|
+
return names
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def externally_read(names: Iterable[str], module: NameScopedModule, routed: set[str]) -> set[str]:
|
|
218
|
+
"""Changed names code outside the repository reads, except those a deferred-strings rule routes."""
|
|
219
|
+
return {
|
|
220
|
+
name
|
|
221
|
+
for name in names
|
|
222
|
+
if name not in routed and (name in module.external_names or name.startswith(tuple(module.external_prefixes)))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _casefold(name: str) -> str:
|
|
227
|
+
return unicodedata.normalize("NFKC", name).casefold()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _bindings(
|
|
231
|
+
tree: ast.Module, packages: Iterable[str], accessors: Iterable[str], modules: Iterable[str]
|
|
232
|
+
) -> tuple[set[str], bool]:
|
|
233
|
+
"""
|
|
234
|
+
Local names bound to an accessor or to the module, and whether the file reads names it can't name.
|
|
235
|
+
|
|
236
|
+
``modules`` are the module's dotted names, one per module root. Relative imports resolve against
|
|
237
|
+
``packages``, the importer's own. A star import of the module, or an unaliased ``import pkg.module``
|
|
238
|
+
(whose attribute chains this does not follow), reads names the selector can't see.
|
|
239
|
+
"""
|
|
240
|
+
wanted = {_casefold(name) for name in modules}
|
|
241
|
+
accessor_names = {_casefold(name) for name in accessors}
|
|
242
|
+
bound: set[str] = set()
|
|
243
|
+
unnamed = False
|
|
244
|
+
for node in iter_imports(tree):
|
|
245
|
+
if isinstance(node, ast.Import):
|
|
246
|
+
for alias in node.names:
|
|
247
|
+
if _casefold(alias.name) in wanted:
|
|
248
|
+
if alias.asname:
|
|
249
|
+
bound.add(alias.asname)
|
|
250
|
+
else:
|
|
251
|
+
unnamed = True
|
|
252
|
+
continue
|
|
253
|
+
for package in packages:
|
|
254
|
+
bases = import_candidates(ast.ImportFrom(module=node.module, names=[], level=node.level), package)
|
|
255
|
+
for base in bases:
|
|
256
|
+
for alias in node.names:
|
|
257
|
+
if alias.name == "*":
|
|
258
|
+
unnamed = unnamed or _casefold(base) in wanted
|
|
259
|
+
elif _casefold(f"{base}.{alias.name}") in wanted | accessor_names:
|
|
260
|
+
bound.add(alias.asname or alias.name)
|
|
261
|
+
return bound, unnamed
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _is_named_read(node: ast.Name, parent: ast.AST | None) -> bool:
|
|
265
|
+
"""``binding.NAME``, or ``getattr(binding, "NAME")`` / ``hasattr`` with a literal name."""
|
|
266
|
+
if isinstance(parent, ast.Attribute) and parent.value is node:
|
|
267
|
+
return parent.attr != "__dict__"
|
|
268
|
+
return (
|
|
269
|
+
isinstance(parent, ast.Call)
|
|
270
|
+
and isinstance(parent.func, ast.Name)
|
|
271
|
+
and parent.func.id in {"getattr", "hasattr"}
|
|
272
|
+
and len(parent.args) > 1
|
|
273
|
+
and parent.args[0] is node
|
|
274
|
+
and isinstance(parent.args[1], ast.Constant)
|
|
275
|
+
and isinstance(parent.args[1].value, str)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _may_bind(source: bytes, dotted_names: Iterable[str]) -> bool:
|
|
280
|
+
"""Whether ``source`` spells the last part of any of ``dotted_names``; only such a file can bind one."""
|
|
281
|
+
lowered = source.lower()
|
|
282
|
+
return any(name.rpartition(".")[2].lower().encode() in lowered for name in dotted_names)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def reads_unnamed(
|
|
286
|
+
source: bytes, accessors: Iterable[str], modules: Iterable[str], packages: Iterable[str] = ("",)
|
|
287
|
+
) -> bool:
|
|
288
|
+
"""
|
|
289
|
+
Whether a file reads names of the module without naming them, through an accessor or the module.
|
|
290
|
+
|
|
291
|
+
``binding.NAME`` and ``getattr(binding, "NAME")`` name what they read, which `named_readers` finds.
|
|
292
|
+
Any other use of the binding reads names the selector can't see: ``getattr(binding, name)``,
|
|
293
|
+
``vars(binding)``, ``binding.__dict__``, passing the object on, ``importlib.reload(binding)``, which
|
|
294
|
+
runs the whole module again, or ``from module import *``.
|
|
295
|
+
"""
|
|
296
|
+
modules, accessors = list(modules), list(accessors)
|
|
297
|
+
if not _may_bind(source, [*modules, *accessors]):
|
|
298
|
+
return False
|
|
299
|
+
try:
|
|
300
|
+
tree = parse_ast(source)
|
|
301
|
+
except (SyntaxError, ValueError):
|
|
302
|
+
return True
|
|
303
|
+
bound, unnamed = _bindings(tree, packages, accessors, modules)
|
|
304
|
+
if unnamed:
|
|
305
|
+
return True
|
|
306
|
+
if not bound:
|
|
307
|
+
return False
|
|
308
|
+
parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
|
|
309
|
+
return any(
|
|
310
|
+
isinstance(node, ast.Name)
|
|
311
|
+
and node.id in bound
|
|
312
|
+
and isinstance(node.ctx, ast.Load)
|
|
313
|
+
and not _is_named_read(node, parents.get(node))
|
|
314
|
+
for node in ast.walk(tree)
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _code(source: bytes) -> bytes:
|
|
319
|
+
"""
|
|
320
|
+
``source`` without its comments and docstrings, which never run, as space-separated tokens.
|
|
321
|
+
|
|
322
|
+
A file that does not tokenize or parse comes back whole, so its textual matches still count.
|
|
323
|
+
"""
|
|
324
|
+
try:
|
|
325
|
+
tree = parse_ast(source)
|
|
326
|
+
tokens = list(tokenize.tokenize(io.BytesIO(source).readline))
|
|
327
|
+
except (SyntaxError, ValueError, tokenize.TokenError):
|
|
328
|
+
return source
|
|
329
|
+
docstrings = {
|
|
330
|
+
(first.lineno, first.col_offset)
|
|
331
|
+
for node in ast.walk(tree)
|
|
332
|
+
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef))
|
|
333
|
+
and node.body
|
|
334
|
+
and isinstance(first := node.body[0], ast.Expr)
|
|
335
|
+
and isinstance(first.value, ast.Constant)
|
|
336
|
+
and isinstance(first.value.value, str)
|
|
337
|
+
}
|
|
338
|
+
return b" ".join(
|
|
339
|
+
token.string.encode()
|
|
340
|
+
for token in tokens
|
|
341
|
+
if token.type != tokenize.COMMENT and not (token.type == tokenize.STRING and token.start in docstrings)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def named_readers(sources: Mapping[str, bytes], names: Iterable[str], exclude: str) -> set[str]:
|
|
346
|
+
"""
|
|
347
|
+
Files whose code mentions any of ``names`` as a whole word: attribute reads, imports, overrides, strings.
|
|
348
|
+
|
|
349
|
+
A mention in a comment or a docstring reads nothing, so it does not count.
|
|
350
|
+
"""
|
|
351
|
+
names = sorted(names)
|
|
352
|
+
if not names:
|
|
353
|
+
return set()
|
|
354
|
+
pattern = re.compile(rb"\b(?:" + b"|".join(re.escape(name.encode()) for name in names) + rb")\b")
|
|
355
|
+
return {
|
|
356
|
+
path
|
|
357
|
+
for path, source in sources.items()
|
|
358
|
+
if path != exclude and pattern.search(source) and pattern.search(_code(source))
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def externally_read_names(module_source: bytes, roots: Iterable[Path], name_pattern: str = r".+") -> set[str]:
|
|
363
|
+
"""
|
|
364
|
+
The top-level names the module binds that any Python file under ``roots`` mentions.
|
|
365
|
+
|
|
366
|
+
Run it in the consumer's installed environment with its site-packages as ``roots``: the result is
|
|
367
|
+
the names installed code may read, which ``NameScopedModule.external_names`` must list. Only names
|
|
368
|
+
that fully match ``name_pattern`` count; Django reads only uppercase settings, for one, so its
|
|
369
|
+
consumers pass ``[A-Z][A-Z0-9_]*``. It over-approximates on purpose: a name that only appears in a
|
|
370
|
+
comment still counts.
|
|
371
|
+
"""
|
|
372
|
+
accepted = re.compile(name_pattern)
|
|
373
|
+
tree = parse_ast(module_source)
|
|
374
|
+
names = {name for node in tree.body for name in _statement(node).binds if accepted.fullmatch(name)}
|
|
375
|
+
if not names:
|
|
376
|
+
return set()
|
|
377
|
+
# A name "mentioned" is a whole ASCII word run (what ``\bNAME\b`` matches in bytes). Splitting each
|
|
378
|
+
# file into word runs once and intersecting is ~25x faster over a site-packages than one alternation
|
|
379
|
+
# of every name; only non-ASCII names, which a word run cannot contain, keep the alternation.
|
|
380
|
+
ascii_names = {name.encode() for name in names if name.isascii()}
|
|
381
|
+
others = sorted(name.encode() for name in names if not name.isascii())
|
|
382
|
+
pattern = re.compile(rb"\b(" + b"|".join(map(re.escape, others)) + rb")\b") if others else None
|
|
383
|
+
found: set[bytes] = set()
|
|
384
|
+
for root in roots:
|
|
385
|
+
for path in Path(root).rglob("*.py"):
|
|
386
|
+
try:
|
|
387
|
+
source = path.read_bytes()
|
|
388
|
+
except OSError:
|
|
389
|
+
continue
|
|
390
|
+
found.update(ascii_names.intersection(_WORD_RUN.findall(source)))
|
|
391
|
+
if pattern is not None:
|
|
392
|
+
found.update(pattern.findall(source))
|
|
393
|
+
if len(found) == len(names):
|
|
394
|
+
return names
|
|
395
|
+
return {name.decode() for name in found}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _routed(config: ImpactConfig, module: NameScopedModule) -> dict[str, list[str]]:
|
|
399
|
+
"""Names of the module a deferred-strings rule routes, to that rule's consumer patterns."""
|
|
400
|
+
routed: dict[str, list[str]] = {}
|
|
401
|
+
for rule in config.deferred_strings:
|
|
402
|
+
if rule.file == module.file:
|
|
403
|
+
for assignment in rule.assignments:
|
|
404
|
+
routed.setdefault(assignment, []).extend(rule.consumers)
|
|
405
|
+
return routed
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _readers_of_every_name(
|
|
409
|
+
repo: Path, snapshot: Snapshot, sources: Mapping[str, bytes], config: ImpactConfig, module: NameScopedModule
|
|
410
|
+
) -> set[str]:
|
|
411
|
+
"""
|
|
412
|
+
Files that read the module other than name by name, so every change to it reaches them.
|
|
413
|
+
|
|
414
|
+
That is a file that reads names without naming them (`reads_unnamed`), reads the file's contents, or
|
|
415
|
+
declares the file by path, inline, in a sidecar or in a dependency rule. A string that names the
|
|
416
|
+
module for a framework to load is not such a read; what the framework then reads is
|
|
417
|
+
``external_names``.
|
|
418
|
+
"""
|
|
419
|
+
index = ModuleIndex(set(sources), config.module_roots, inventory(repo, snapshot.tree).links)
|
|
420
|
+
modules = index.names.get(module.file, set())
|
|
421
|
+
contents = contents_node(module.file)
|
|
422
|
+
path = module.file.encode()
|
|
423
|
+
readers = {reader for reader, dependencies in snapshot.edges.items() if contents in dependencies}
|
|
424
|
+
for reader, source in sources.items():
|
|
425
|
+
if reader == module.file:
|
|
426
|
+
continue
|
|
427
|
+
packages = index.packages.get(reader, {""})
|
|
428
|
+
if path in source or reads_unnamed(source, module.accessors, modules, packages):
|
|
429
|
+
readers.add(reader)
|
|
430
|
+
sidecars = {test + SIDECAR_SUFFIX: test for test in snapshot.tests}
|
|
431
|
+
sidecar_oids = {name: snapshot.files[name] for name in sidecars if name in snapshot.files}
|
|
432
|
+
if sidecar_oids:
|
|
433
|
+
texts = blobs(repo, set(sidecar_oids.values()))
|
|
434
|
+
readers.update(sidecars[name] for name, oid in sidecar_oids.items() if path in texts[oid])
|
|
435
|
+
files = list(snapshot.files)
|
|
436
|
+
readers.update(
|
|
437
|
+
reader
|
|
438
|
+
for rule in config.dependencies
|
|
439
|
+
if matches(module.file, rule.inputs)
|
|
440
|
+
for reader in files
|
|
441
|
+
if matches(reader, rule.readers)
|
|
442
|
+
)
|
|
443
|
+
return readers
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@dataclass(frozen=True)
|
|
447
|
+
class Narrowed:
|
|
448
|
+
"""The files a proven change to a name-scoped module reaches, and why."""
|
|
449
|
+
|
|
450
|
+
readers: set[str]
|
|
451
|
+
reason: str
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def narrow_name_scoped_change(
|
|
455
|
+
repo: Path, snapshots: tuple[Snapshot, Snapshot], config: ImpactConfig, module: NameScopedModule
|
|
456
|
+
) -> Narrowed:
|
|
457
|
+
"""
|
|
458
|
+
Replace a change to a name-scoped module with the files that read what it changed.
|
|
459
|
+
|
|
460
|
+
Raises ``NotNarrowed`` with the reason whenever that cannot be proven; the caller then keeps the
|
|
461
|
+
module's ordinary edges.
|
|
462
|
+
"""
|
|
463
|
+
before, after = snapshots
|
|
464
|
+
old_oid, new_oid = before.files.get(module.file), after.files.get(module.file)
|
|
465
|
+
if not old_oid or not new_oid:
|
|
466
|
+
raise NotNarrowed(f"{module.file} was added or removed")
|
|
467
|
+
texts = blobs(repo, {old_oid, new_oid})
|
|
468
|
+
names = changed_names(texts[old_oid], texts[new_oid], module)
|
|
469
|
+
routed = _routed(config, module)
|
|
470
|
+
external = externally_read(names, module, set(routed))
|
|
471
|
+
if external:
|
|
472
|
+
raise NotNarrowed("code outside the repository reads " + ", ".join(sorted(external)))
|
|
473
|
+
# A file can read the module's names only by loading it, through imports or an accessor. Mentioning
|
|
474
|
+
# a name without either is a coincidence, and selecting it would widen selection past the module's
|
|
475
|
+
# own dependents, so named readers come only from those two.
|
|
476
|
+
dependents = reachable(reverse_edges(union_edges(*snapshots)), seed_nodes([module.file]))
|
|
477
|
+
readers: set[str] = set()
|
|
478
|
+
for snapshot in snapshots:
|
|
479
|
+
sources = read_sources(repo, snapshot.files, {path for path in snapshot.files if path.endswith(".py")})
|
|
480
|
+
loaders = {
|
|
481
|
+
path: source
|
|
482
|
+
for path, source in sources.items()
|
|
483
|
+
if path in dependents or _may_bind(source, module.accessors)
|
|
484
|
+
}
|
|
485
|
+
readers |= named_readers(loaders, names, exclude=module.file)
|
|
486
|
+
readers |= _readers_of_every_name(repo, snapshot, sources, config, module)
|
|
487
|
+
python_files = set(sources)
|
|
488
|
+
for name in names & set(routed):
|
|
489
|
+
for pattern in routed[name]:
|
|
490
|
+
readers |= match_inputs(pattern, python_files, f"{snapshot.tree}:name_scoped_modules")
|
|
491
|
+
readers.discard(module.file)
|
|
492
|
+
for pattern in module.load_checks:
|
|
493
|
+
readers |= match_inputs(pattern, set(after.tests), f"{after.tree}:name_scoped_modules.load_checks")
|
|
494
|
+
changed = ", ".join(sorted(names)) if names else "nothing a test can observe"
|
|
495
|
+
return Narrowed(
|
|
496
|
+
readers=readers,
|
|
497
|
+
reason=f"{module.file} narrowed: it changes {changed}; {len(readers)} files read those names",
|
|
498
|
+
)
|