pytest-selective 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ )