diffcone 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,1487 @@
1
+ """Evidence-mode planning: select the tests whose recorded run meets a change.
2
+
3
+ Evidence recorded at commit C says which symbols each test executed and
4
+ which repository paths it touched (evidence.py). A test that runs
5
+ identically at C and at a snapshot cannot differ there, so a plan base ->
6
+ head is the union of two plans from C: C -> base and C -> head (one when C
7
+ is the base). For each, every change is turned into E, the symbols whose
8
+ execution would *notice* it (internal/evidence_design.md, "What a change is
9
+ observed by"), and a test is selected when its record meets E:
10
+
11
+ * a function body: the function itself; if it ran during an import, the
12
+ importing module's import-time state may differ, so that module is
13
+ escalated (below). Code that ran outside every test and every import
14
+ (hooks, collection) is escalated itself;
15
+ * a function's signature, defaults, decorators or annotations: also its
16
+ readers (resolved references and name matches, one hop), the lookup and
17
+ reflection sites that can see its namespace, and escalation when the
18
+ ``def`` runs code at import;
19
+ * a variable: its readers and the lookup and reflection sites that can see
20
+ its namespace (a lookup by a name nothing bounds reads the value too); a
21
+ reader that is itself a variable captured the value and is followed in
22
+ turn; a module's or class's top-level reader escalates its module;
23
+ * a class body: the attributes whose statements changed, through every
24
+ reader of those names and the lookup and reflection sites; an opaque
25
+ body statement, a dunder attribute or a changed class statement (bases,
26
+ decorators, keywords) escalates, and so does any attribute change of a
27
+ class whose creation may read its body (``SourceIndex.open_classes``:
28
+ decorators, keywords, a base the index cannot see, such as a dataclass
29
+ field default or an ``Enum`` member; or an ancestor with
30
+ ``__init_subclass__``). A special method (``__eq__``) is used without
31
+ being named, so a change to one reaches every member of the class's
32
+ hierarchy; a module's ``__getattr__``/``__dir__`` escalates its module;
33
+ * an added or deleted name: its readers, the unbounded lookup and
34
+ reflection sites that can see the namespace, and for a deletion the
35
+ modules importing it (their import now fails); an added or deleted
36
+ definition that runs code at import (a decorated function, a class whose
37
+ creation runs code) escalates too;
38
+ * module-level code: escalates the module, and so does a change to
39
+ ``__all__`` (what star imports bind);
40
+ * a non-Python file, or any file outside the source roots (read from a
41
+ git diff, since the index holds only the roots): the tests that touched
42
+ it or a directory above it; everything when it is compiled source, build
43
+ or pytest configuration, a ``conftest.py`` outside the roots, or was
44
+ touched outside every test;
45
+ * test code other than a function body: the tests that list the symbol as
46
+ a lifecycle dependency (a fixture) or are collected from its class or a
47
+ subclass, and for a test module's variable (``pytestmark``) every test
48
+ of the module, since pytest reads marks, fixtures and parameters without
49
+ a static reader.
50
+
51
+ "Escalated" means planned by the static planner from exactly those seeds,
52
+ without dynamic-reference pseudo-seeds: a test that executed a dynamic
53
+ site is in the record through the code it reached, so the lookup sites
54
+ that can see an escalated module join E instead. Every symbol of an
55
+ escalated module joins E too.
56
+
57
+ Targets of other runners, which have no evidence, keep their static
58
+ decision. A pytest target with no record (new, or never run) is selected.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ from collections import defaultdict
64
+ from collections.abc import Iterable
65
+ from pathlib import Path
66
+
67
+ from diffcone.classify import (
68
+ ADDED,
69
+ ANNOTATIONS_CHANGED,
70
+ BODY_CHANGED,
71
+ DEFINITION_CHANGED,
72
+ DELETED,
73
+ DEPENDENCIES_CHANGED,
74
+ DOCSTRING_CHANGED,
75
+ SymbolChange,
76
+ classify,
77
+ )
78
+ from diffcone.cython import CLASS as CYTHON_CLASS
79
+ from diffcone.cython import IMPORT as CYTHON_IMPORT
80
+ from diffcone.cython import (
81
+ CythonFunction,
82
+ CythonName,
83
+ cython_changes,
84
+ is_cython,
85
+ names_module,
86
+ pxd_stem,
87
+ symbol_id,
88
+ )
89
+ from diffcone.declarations import Declaration
90
+ from diffcone.discovery import DiscoveryResult
91
+ from diffcone.evidence import FLAG_SUBPROCESS, FLAG_UNSTABLE, UNINDEXED_MODULE, Evidence
92
+ from diffcone.manifest import Manifest, Target
93
+ from diffcone.model import (
94
+ CLASS,
95
+ CLASS_STATEMENT,
96
+ DECLARED,
97
+ FUNCTION,
98
+ IMPORTS,
99
+ IMPORTS_NAME,
100
+ METHOD,
101
+ MODULE,
102
+ OPAQUE_ATTRIBUTE,
103
+ REFERENCES,
104
+ UNRESOLVED_ATTRIBUTE,
105
+ UNRESOLVED_DYNAMIC,
106
+ VARIABLE,
107
+ SourceIndex,
108
+ Symbol,
109
+ )
110
+ from diffcone.planner import (
111
+ BUILD_SCRIPTS,
112
+ OWN_DIRS,
113
+ OWN_FILES,
114
+ RULE_ANALYSIS_ERROR,
115
+ RULE_CHANGED_TARGET,
116
+ RULE_CYTHON_CALLER,
117
+ RULE_ENTRY_DOCSTRING,
118
+ RULE_ESCALATED,
119
+ RULE_EXECUTED_CHANGED,
120
+ RULE_EXECUTED_READER,
121
+ RULE_LIFECYCLE_UNRESOLVED,
122
+ RULE_LOOKUP_SITE,
123
+ RULE_NEW_TARGET,
124
+ RULE_NO_EVIDENCE,
125
+ RULE_PYTEST_HOOK,
126
+ RULE_SUBPROCESS,
127
+ RULE_TEST_SCOPE,
128
+ RULE_TOUCHED_FILE,
129
+ RULE_UNINDEXED_IMPORT,
130
+ RULE_UNOBSERVED_FILE,
131
+ RULE_UNSTABLE,
132
+ Decision,
133
+ Fallback,
134
+ Plan,
135
+ Reason,
136
+ Seeds,
137
+ _changed_unanalysed_files,
138
+ _decision,
139
+ _ImportReach,
140
+ _inside,
141
+ _is_dunder,
142
+ _members_by_container,
143
+ _runner_dependency_fallbacks,
144
+ merge_targets,
145
+ plan_from_indexes,
146
+ )
147
+ from diffcone.snapshot import changed_paths, split_root
148
+
149
+ # Sources no Python-level event reports: compiled into extensions, or read
150
+ # by pytest or the build before the recorder starts.
151
+ COMPILED_SUFFIXES = (
152
+ ".pyx",
153
+ ".pxd",
154
+ ".pxi",
155
+ ".c",
156
+ ".h",
157
+ ".cc",
158
+ ".cpp",
159
+ ".cxx",
160
+ ".hh",
161
+ ".hpp",
162
+ ".f",
163
+ ".f77",
164
+ ".for",
165
+ ".f90",
166
+ ".f95",
167
+ ".pyf",
168
+ ".rs",
169
+ ".cu",
170
+ ".i",
171
+ ".swg",
172
+ ".m",
173
+ ".mm",
174
+ ".src",
175
+ ".in",
176
+ ".tpl",
177
+ ".so",
178
+ ".pyd",
179
+ ".dylib",
180
+ ".dll",
181
+ )
182
+ CONFIG_FILES = frozenset(
183
+ {
184
+ "pyproject.toml",
185
+ "setup.cfg",
186
+ "tox.ini",
187
+ "pytest.ini",
188
+ ".pytest.ini",
189
+ "MANIFEST.in",
190
+ "meson.build",
191
+ "meson.options",
192
+ "meson_options.txt",
193
+ "CMakeLists.txt",
194
+ "Makefile",
195
+ "Pipfile",
196
+ "pixi.toml",
197
+ ".python-version",
198
+ ".coveragerc",
199
+ "Cargo.toml",
200
+ "build.rs",
201
+ # Build scripts are Python, but nobody imports them.
202
+ "setup.py",
203
+ "hatch_build.py",
204
+ "build.py",
205
+ "pdm_build.py",
206
+ }
207
+ )
208
+ CONFIG_PREFIXES = ("requirements", "environment", "constraints")
209
+ # Module-level names in a conftest that pytest reads to decide what to load
210
+ # or collect at all.
211
+ PYTEST_COLLECTION_NAMES = frozenset({"pytest_plugins", "collect_ignore", "collect_ignore_glob"})
212
+
213
+ # UNRESOLVED_DYNAMIC details, by what the site can see.
214
+ SITE_CLOSURE = "closure" # a name looked up on a module global: its import closure
215
+ SITE_ELSEWHERE = "elsewhere" # on an object from anywhere
216
+ SITE_IMPORT = "import" # a module named at run time
217
+ SITE_ANY = "any" # eval/exec/run_path
218
+
219
+
220
+ def _site_kind(detail: str) -> str:
221
+ if "import" in detail or detail.startswith("runpy.run_module"):
222
+ return SITE_IMPORT
223
+ if detail.startswith(("getattr(<non-literal>) on a receiver from elsewhere", "vars(")):
224
+ return SITE_ELSEWHERE
225
+ if detail.startswith(("getattr(<non-literal>)", "globals(")):
226
+ return SITE_CLOSURE
227
+ return SITE_ANY # eval, exec, runpy.run_path, anything not recognised
228
+
229
+
230
+ def _unobserved_file(path: str) -> bool:
231
+ name = path.rsplit("/", 1)[-1]
232
+ return (
233
+ path.lower().endswith(COMPILED_SUFFIXES)
234
+ or name in CONFIG_FILES
235
+ or name.endswith(".lock")
236
+ or (name.startswith(CONFIG_PREFIXES) and name.endswith((".txt", ".yml", ".yaml")))
237
+ )
238
+
239
+
240
+ def _ancestors(path: str) -> list[str]:
241
+ """The path and every directory above it, the checkout root as ""."""
242
+ out = [path]
243
+ while "/" in path:
244
+ path = path.rsplit("/", 1)[0]
245
+ out.append(path)
246
+ out.append("")
247
+ return out
248
+
249
+
250
+ class _TestCode:
251
+ """Which modules are test code: those holding a pytest target (by file or
252
+ entry symbol) and every conftest, and which tests each one scopes."""
253
+
254
+ def __init__(self, targets: list[Target], indexes: Iterable[SourceIndex]) -> None:
255
+ self.module_of_path: dict[str, str] = {}
256
+ self.path_of_module: dict[str, str] = {}
257
+ for index in indexes:
258
+ for symbol in index.symbols.values():
259
+ if symbol.kind == MODULE:
260
+ self.module_of_path[symbol.path] = symbol.id
261
+ self.path_of_module[symbol.id] = symbol.path
262
+ self.tests_by_file: dict[str, list[str]] = defaultdict(list)
263
+ self.entries: set[str] = set()
264
+ self.modules: set[str] = set()
265
+ # Symbol -> the tests listing it as a lifecycle dependency (fixtures,
266
+ # setup functions, conftest hooks: discovery's fixture chain).
267
+ self.users: dict[str, list[str]] = defaultdict(list)
268
+ for target in targets:
269
+ file = target.runner_id.split("::", 1)[0]
270
+ self.tests_by_file[file].append(target.runner_id)
271
+ self.entries.add(target.entry_symbol)
272
+ for dep in target.lifecycle_dependencies:
273
+ self.users[dep].append(target.runner_id)
274
+ if file in self.module_of_path:
275
+ self.modules.add(self.module_of_path[file])
276
+ self.conftests = {m for p, m in self.module_of_path.items() if _is_conftest(p)}
277
+
278
+ def entry_modules(self, symbols: dict[str, Symbol]) -> None:
279
+ for entry in self.entries:
280
+ symbol = symbols.get(entry)
281
+ if symbol is not None:
282
+ self.modules.add(symbol.module)
283
+
284
+ def is_test_code(self, module: str) -> bool:
285
+ return module in self.modules or module in self.conftests
286
+
287
+ def scope(self, symbol: Symbol, classes: set[str], *, whole_module: bool) -> list[str]:
288
+ """The tests a change to ``symbol`` in test code reaches without a
289
+ static reader:
290
+ - every test listing the symbol among its lifecycle dependencies
291
+ (a fixture, as discovery resolves it at head, autouse included);
292
+ - every test collected from ``classes``, the class holding the
293
+ symbol and its subclasses (a fixture or mark on a base test class
294
+ in another file reaches every subclass's tests);
295
+ - with ``whole_module``, every test of the module (``pytestmark``).
296
+ Tests that ran a fixture at C have it in their record, which covers
297
+ one that was deleted or stopped applying to them."""
298
+ tests = set(self.users.get(symbol.id, ()))
299
+ for cls in classes:
300
+ tests.update(self.users.get(cls, ()))
301
+ if whole_module:
302
+ tests.update(self.tests_by_file.get(self.path_of_module.get(symbol.module, ""), ()))
303
+ return sorted(tests)
304
+
305
+
306
+ def _class_graph(
307
+ indexes: Iterable[SourceIndex],
308
+ ) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
309
+ """Bases and subclasses of every class, over both revisions."""
310
+ bases: dict[str, set[str]] = defaultdict(set)
311
+ subclasses: dict[str, set[str]] = defaultdict(set)
312
+ for index in indexes:
313
+ for cls, names in index.class_bases.items():
314
+ for base in names:
315
+ bases[cls].add(base)
316
+ subclasses[base].add(cls)
317
+ return bases, subclasses
318
+
319
+
320
+ def _runner_only(c_index: SourceIndex, other: SourceIndex, targets: list[Target]) -> set[str]:
321
+ """Test classes (with the bases they inherit from) whose instances only
322
+ pytest ever holds: planner._runner_only_classes, with two differences.
323
+ A class that is only ever a base of test classes (pandas' ExtensionTests
324
+ combines a dozen Base*Tests) counts too, since its instances are the
325
+ test classes' instances. And a read of ``.instance`` does not turn the
326
+ rule off: the tests that executed one are selected instead (see
327
+ ``_Observers._sites``)."""
328
+ symbols = {**c_index.symbols, **other.symbols}
329
+ bases, _ = _class_graph((c_index, other))
330
+ runner: set[str] = set()
331
+ for target in targets:
332
+ entry = symbols.get(target.entry_symbol)
333
+ if entry is not None and entry.kind == METHOD and entry.container:
334
+ runner.add(entry.container)
335
+ for dep in target.lifecycle_dependencies:
336
+ owner = symbols.get(dep)
337
+ if owner is not None and owner.kind == CLASS:
338
+ runner.add(dep)
339
+ stack = list(runner)
340
+ while stack:
341
+ for base in bases.get(stack.pop(), ()):
342
+ if base not in runner:
343
+ runner.add(base)
344
+ stack.append(base)
345
+ if not runner:
346
+ return set()
347
+ held = (c_index.escaped_classes | other.escaped_classes) & runner
348
+ for index in (c_index, other):
349
+ for edge in index.edges:
350
+ if (
351
+ edge.kind == REFERENCES
352
+ and edge.target in runner
353
+ and not _inside(edge.source, runner, c_index, other)
354
+ ):
355
+ held.add(edge.target)
356
+ stack = list(held)
357
+ while stack: # a held subclass holds its bases: its instances carry their methods
358
+ for base in bases.get(stack.pop(), ()):
359
+ if base in runner and base not in held:
360
+ held.add(base)
361
+ stack.append(base)
362
+ return runner - held
363
+
364
+
365
+ def _is_conftest(path: str) -> bool:
366
+ return path == "conftest.py" or path.endswith("/conftest.py")
367
+
368
+
369
+ class _Observers:
370
+ """E for one pair of snapshots (C -> other), and what else it selects."""
371
+
372
+ def __init__(
373
+ self,
374
+ c_index: SourceIndex,
375
+ other: SourceIndex,
376
+ evidence: Evidence,
377
+ test_code: _TestCode,
378
+ declarations: list[Declaration],
379
+ runner_only: set[str],
380
+ ) -> None:
381
+ self.c, self.other = c_index, other
382
+ self.runner_only = runner_only
383
+ self.evidence = evidence
384
+ self.test_code = test_code
385
+ self.symbols: dict[str, Symbol] = {**c_index.symbols, **other.symbols}
386
+ self.changes = classify(c_index, other)
387
+ self.readers_of: dict[str, set[str]] = defaultdict(set)
388
+ self.attribute_readers: dict[str, set[str]] = defaultdict(set) # "attribute:n" edges
389
+ self.importers_of: dict[str, set[str]] = defaultdict(set)
390
+ self.by_name: dict[str, set[str]] = defaultdict(set)
391
+ self.sites: list[tuple[str, str]] = [] # (symbol, kind)
392
+ for index in (c_index, other):
393
+ for edge in index.edges:
394
+ if edge.kind in (REFERENCES, DECLARED):
395
+ self.readers_of[edge.target].add(edge.source)
396
+ if edge.detail.startswith("attribute:"):
397
+ self.attribute_readers[edge.detail[len("attribute:") :]].add(edge.source)
398
+ elif edge.kind in (IMPORTS, IMPORTS_NAME):
399
+ self.importers_of[edge.target].add(edge.source)
400
+ for ref in index.unresolved:
401
+ if ref.kind == UNRESOLVED_DYNAMIC:
402
+ self.sites.append((ref.symbol, _site_kind(ref.detail)))
403
+ elif ref.name:
404
+ self.by_name[ref.name].add(ref.symbol)
405
+ for symbol, _detail in index.reflection:
406
+ self.sites.append((symbol, SITE_ANY))
407
+ for decl in declarations:
408
+ self.readers_of[decl.target].add(decl.source)
409
+ self.hands_on = {
410
+ ref.symbol
411
+ for index in (c_index, other)
412
+ for ref in index.unresolved
413
+ if ref.kind == UNRESOLVED_ATTRIBUTE and ref.name in ("instance", "cls")
414
+ }
415
+ self.module_hands_on = {
416
+ ref.symbol
417
+ for index in (c_index, other)
418
+ for ref in index.unresolved
419
+ if ref.kind == UNRESOLVED_ATTRIBUTE and ref.name == "module"
420
+ } | {
421
+ symbol
422
+ for index in (c_index, other)
423
+ for symbol, detail in index.reflection
424
+ if detail == ".modules"
425
+ }
426
+ self.module_hands_on = {
427
+ s
428
+ for s in self.module_hands_on
429
+ if s in self.symbols and test_code.is_test_code(self.symbols[s].module)
430
+ }
431
+ self.sites = sorted(set(self.sites))
432
+ self.members = _members_by_container(set(self.symbols))
433
+ self.reach = _ImportReach(c_index, other)
434
+ self.bases, self.subclasses = _class_graph((c_index, other))
435
+
436
+ # Whether a module that uses pytest added, deleted or redefined a
437
+ # function or class: a fixture (or a class holding them) could have
438
+ # changed where discovery does not see it.
439
+ pytest_modules = {
440
+ self.symbols[x.symbol].module
441
+ for index in (c_index, other)
442
+ for x in index.external
443
+ if x.symbol in self.symbols and x.module.split(".")[0] in ("pytest", "_pytest")
444
+ }
445
+ self.fixture_sources_changed = any(
446
+ c.carries_impact
447
+ and c.symbol.kind in (FUNCTION, METHOD, CLASS)
448
+ and c.symbol.module in pytest_modules
449
+ and {ADDED, DELETED, DEFINITION_CHANGED} & set(c.changes)
450
+ for c in self.changes
451
+ )
452
+ self.E: dict[str, Reason] = {}
453
+ self.direct: dict[str, Reason] = {}
454
+ self.fallbacks: list[Fallback] = []
455
+ self.files: dict[str, tuple[str, bool]] = {} # changed path -> (what, names too)
456
+ # Changed paths outside the source roots (set by plan_with_evidence):
457
+ # the index reads none of them, but the recorder saw what tests ran
458
+ # and opened there.
459
+ self.outside: dict[str, str] = {}
460
+ self.seed_changes: set[str] = set()
461
+ self.seed_nodes: dict[str, str] = {}
462
+ self.escalated_modules: set[str] = set()
463
+ self._followed: set[str] = set()
464
+ # Cython name -> the Cython functions at C that mention it (lazily).
465
+ self._cython_mentions: dict[str, list[tuple[str, CythonFunction]]] | None = None
466
+
467
+ # -- recording --------------------------------------------------------------
468
+
469
+ def _observe(self, symbol: str, rule: str, detail: str, change: SymbolChange | None) -> None:
470
+ if symbol not in self.E:
471
+ self.E[symbol] = Reason(
472
+ rule,
473
+ detail,
474
+ (),
475
+ change.id if change is not None else None,
476
+ change.changes if change is not None else (),
477
+ )
478
+
479
+ def _select_all(self, rule: str, detail: str) -> None:
480
+ self.fallbacks.append(Fallback(rule, "all_targets", detail))
481
+
482
+ def _scope(self, symbol: Symbol, change: SymbolChange) -> None:
483
+ classes: set[str] = set()
484
+ current: str | None = symbol.id if symbol.kind == CLASS else symbol.container
485
+ while current:
486
+ holder = self.symbols.get(current)
487
+ if holder is None:
488
+ break
489
+ if holder.kind == CLASS:
490
+ stack = [current]
491
+ while stack:
492
+ cls = stack.pop()
493
+ if cls not in classes:
494
+ classes.add(cls)
495
+ stack.extend(self.subclasses.get(cls, ()))
496
+ current = holder.container
497
+ whole_module = (
498
+ symbol.kind == VARIABLE
499
+ and symbol.container == symbol.module
500
+ and symbol.module not in self.test_code.conftests
501
+ )
502
+ for test in self.test_code.scope(symbol, classes, whole_module=whole_module):
503
+ self.direct.setdefault(
504
+ test,
505
+ Reason(
506
+ RULE_TEST_SCOPE,
507
+ f"{change.id} {'/'.join(change.changes)} in test code; pytest reads marks, "
508
+ "fixtures and parameters without a static reader, so every test in its "
509
+ "scope is selected",
510
+ (),
511
+ change.id,
512
+ change.changes,
513
+ ),
514
+ )
515
+
516
+ # -- rules -------------------------------------------------------------------
517
+
518
+ def run(self) -> None:
519
+ decorated = self.c.doc_decorated | self.other.doc_decorated
520
+ for change in self.changes:
521
+ if change.symbol.path in BUILD_SCRIPTS:
522
+ self._file(change.symbol.path, "edited", names=False)
523
+ continue
524
+ if change.carries_impact:
525
+ self._change(change)
526
+ elif DOCSTRING_CHANGED in change.changes and change.id in decorated:
527
+ # Its decorator reads the docstring when the module is
528
+ # imported: no record shows that, the import-time state does.
529
+ self._escalate_change(
530
+ change, f"{change.id} docstring_changed: its decorator reads it at import"
531
+ )
532
+ cython = self._cython()
533
+ for path in _changed_unanalysed_files(self.c, self.other):
534
+ if path in cython:
535
+ continue
536
+ before, after = path in self.c.other_files, path in self.other.other_files
537
+ what = "edited" if before and after else ("added" if after else "deleted")
538
+ self._file(path, what, names=not (before and after))
539
+ for change in self.changes:
540
+ symbol = change.symbol
541
+ if symbol.kind == MODULE and {ADDED, DELETED} & set(change.changes):
542
+ what = f"{change.id} {'/'.join(change.changes)}"
543
+ self._file(symbol.path, what, names=True)
544
+ # A source file is also data to whoever reads it as a file
545
+ # (``exec(open("version.py").read())``, ``inspect.getsource``): its
546
+ # edit reaches the tests that opened it.
547
+ for path in sorted({c.symbol.path for c in self.changes} - set(self.files)):
548
+ if path.endswith(".py") and path not in BUILD_SCRIPTS:
549
+ self._file(path, "edited", names=False)
550
+ for path, what in sorted(self.outside.items()):
551
+ if path.rsplit("/", 1)[-1] == "conftest.py":
552
+ # pytest loads it for tests that never ran a line of it (a new
553
+ # autouse fixture, a hook): no record can vouch for them.
554
+ self._select_all(
555
+ RULE_UNOBSERVED_FILE,
556
+ f"{path} {what}: a conftest.py outside the source roots, which the "
557
+ "index does not read",
558
+ )
559
+ else:
560
+ self._file(path, what, names=what != "edited")
561
+
562
+ def _cython(self) -> set[str]:
563
+ """Cython sources (roadmap item 7): the paths this rule handled.
564
+
565
+ A body edit selects the tests that executed the function in a
566
+ profiled build at C, and for a ``nogil`` or ``cpdef`` function (which
567
+ such a build does not always report) the tests that executed any
568
+ Cython function naming it. Anything else, or a module the store holds
569
+ no Cython record of (built without ``profile=True``), selects all."""
570
+ paths = {
571
+ p
572
+ for p in _changed_unanalysed_files(self.c, self.other)
573
+ if is_cython(p) and (p in self.c.cython or p in self.other.cython)
574
+ }
575
+ if not paths:
576
+ return set()
577
+ changes = cython_changes(
578
+ {p: m for p, m in self.c.cython.items() if p in paths},
579
+ {p: m for p, m in self.other.cython.items() if p in paths},
580
+ )
581
+ for path, why in changes.files:
582
+ self._select_all(
583
+ RULE_UNOBSERVED_FILE,
584
+ f"{path} {why}: a Cython change outside function bodies is not attributed "
585
+ "to any function",
586
+ )
587
+ recorded = {
588
+ s.split("::", 1)[0]
589
+ for s in self.evidence.symbols
590
+ if "::" in s and is_cython(s.split("::", 1)[0])
591
+ }
592
+ for path, name in changes.functions:
593
+ label = f"{symbol_id(path, name)} body_changed (Cython)"
594
+ function = self.c.cython[path].by_name()[name]
595
+ self._cython_ran(path, function, RULE_EXECUTED_CHANGED, label, label, recorded)
596
+ for changed in changes.names:
597
+ self._cython_name(changed, recorded)
598
+ return paths
599
+
600
+ def _cython_ran(
601
+ self,
602
+ path: str,
603
+ function: CythonFunction,
604
+ rule: str,
605
+ detail: str,
606
+ label: str,
607
+ recorded: set[str],
608
+ ) -> None:
609
+ """The tests that executed ``function`` at C observe the change;
610
+ when it is ``nogil`` or ``cpdef``, which a profiled build does not
611
+ always report, so do those that executed a Cython function naming
612
+ it."""
613
+ sid = symbol_id(path, function.name)
614
+ if path not in recorded:
615
+ self._select_all(
616
+ RULE_UNOBSERVED_FILE,
617
+ f"{label}: the evidence holds no Cython record from {path} (collected "
618
+ "without a profile=True build of it?)",
619
+ )
620
+ return
621
+ self._observe(sid, rule, detail, None)
622
+ self._cython_import_effect(sid, label)
623
+ if function.nogil or function.cpdef or function.unprofiled:
624
+ kind = "nogil" if function.nogil else ("cpdef" if function.cpdef else "profile(False)")
625
+ for caller in self._cython_callers(path, function.name):
626
+ self._observe(
627
+ caller,
628
+ RULE_CYTHON_CALLER,
629
+ f"{caller} names {sid}, a {kind} function a profiled build does not "
630
+ f"always report; {label}",
631
+ None,
632
+ )
633
+ self._cython_import_effect(caller, label)
634
+
635
+ def _cython_name(self, changed: CythonName, recorded: set[str]) -> None:
636
+ """A name bound outside every function body changed (roadmap item
637
+ 8): the Cython functions that can see it and mention it, and for a
638
+ name Python can see, the Python code reading it by that name and the
639
+ lookup and reflection sites. A class attribute also reaches
640
+ everything holding an instance."""
641
+ qualified = f"{changed.scope}.{changed.name}" if changed.scope else changed.name
642
+ label = f"{changed.path}::{qualified} {changed.change} (Cython, outside functions)"
643
+ seers = self._cython_seers(changed.path, changed.name, changed.visible)
644
+ for path, function in self._cython_mentioning(changed.name):
645
+ if seers is None or path in seers:
646
+ self._cython_ran(
647
+ path,
648
+ function,
649
+ RULE_EXECUTED_READER,
650
+ f"{symbol_id(path, function.name)} names {changed.name}; {label}",
651
+ label,
652
+ recorded,
653
+ )
654
+ if changed.attribute:
655
+ self._cython_instances(changed.scope.split(".")[-1], label, recorded)
656
+ if changed.visible:
657
+ readers = self.by_name.get(changed.name, set())
658
+ if changed.scope:
659
+ readers = readers | self.attribute_readers.get(changed.name, set())
660
+ for reader in sorted(readers):
661
+ self._reader(reader, None, label)
662
+ # A lookup by a name nothing bounds may find it, and read its
663
+ # value as well as notice it come or go.
664
+ for site, kind in self.sites:
665
+ if kind != SITE_IMPORT and site in self.symbols:
666
+ self._observe(
667
+ site,
668
+ RULE_LOOKUP_SITE,
669
+ f"{site} looks names up by a name nothing bounds and may see "
670
+ f"{changed.path}; {label}",
671
+ None,
672
+ )
673
+
674
+ def _cython_instances(self, cls: str, label: str, recorded: set[str]) -> None:
675
+ """A class attribute declaration changes the layout and the generated
676
+ pickling of every instance of the class and its subclasses: the
677
+ tests that ran their methods or a function naming one of them (where
678
+ instances come from), and the Python code naming one."""
679
+ family, stack = {cls}, [cls]
680
+ while stack:
681
+ base = stack.pop()
682
+ for module in (*self.c.cython.values(), *self.other.cython.values()):
683
+ for statement in module.statements or ():
684
+ name = statement.names[0] if statement.names else ""
685
+ if statement.kind == CYTHON_CLASS and base in statement.bases:
686
+ if name not in family:
687
+ family.add(name)
688
+ stack.append(name)
689
+ for path, module in self.c.cython.items():
690
+ for function in module.functions:
691
+ if function.scope.split(".")[-1] in family:
692
+ self._cython_ran(
693
+ path,
694
+ function,
695
+ RULE_EXECUTED_READER,
696
+ f"{symbol_id(path, function.name)} is a method of {cls} or a "
697
+ f"subclass; {label}",
698
+ label,
699
+ recorded,
700
+ )
701
+ for name in sorted(family):
702
+ for path, function in self._cython_mentioning(name):
703
+ self._cython_ran(
704
+ path,
705
+ function,
706
+ RULE_EXECUTED_READER,
707
+ f"{symbol_id(path, function.name)} names {name}, whose instances "
708
+ f"changed; {label}",
709
+ label,
710
+ recorded,
711
+ )
712
+ for reader in sorted(self.by_name.get(name, ())):
713
+ self._reader(reader, None, label)
714
+
715
+ def _cython_mentioning(self, name: str) -> list[tuple[str, CythonFunction]]:
716
+ """The Cython functions at C whose header or body mentions ``name``."""
717
+ if self._cython_mentions is None:
718
+ self._cython_mentions = defaultdict(list)
719
+ for p, module in self.c.cython.items():
720
+ for function in module.functions:
721
+ for mentioned in function.names:
722
+ self._cython_mentions[mentioned].append((p, function))
723
+ return self._cython_mentions.get(name, [])
724
+
725
+ def _cython_seers(self, path: str, name: str, visible: bool) -> set[str] | None:
726
+ """The Cython files whose functions can see ``name`` bound in
727
+ ``path``, or None for every file. A name Python can see may be
728
+ imported anywhere, and a ``.pxi`` file is included anywhere; a
729
+ ``.pyx`` file's C names stay in it (and in the ``.pxi`` files it may
730
+ include) unless its ``.pxd`` declares them too (a C global the
731
+ ``.pyx`` initialises, read by cimporters); a ``.pxd`` file's reach its
732
+ ``.pyx`` and every file that cimports from it, transitively through
733
+ other ``.pxd`` files."""
734
+ if visible or path.endswith(".pxi"):
735
+ return None
736
+ modules = {**self.c.cython, **self.other.cython}
737
+ seen = {path} | {p for p in modules if p.endswith(".pxi")}
738
+ if path.endswith(".pyx"):
739
+ twin = modules.get(path[: -len(".pyx")] + ".pxd")
740
+ declared = twin is not None and (
741
+ twin.statements is None
742
+ or any(name in s.names for s in twin.statements)
743
+ or any(f.simple_name == name for f in twin.functions)
744
+ )
745
+ if not declared:
746
+ return seen
747
+ path = path[: -len(".pyx")] + ".pxd"
748
+ seen.add(path)
749
+ seen.add(path[: -len(".pxd")] + ".pyx")
750
+ frontier = [path]
751
+ while frontier:
752
+ stem = pxd_stem(frontier.pop())
753
+ for p in sorted(modules):
754
+ if p in seen:
755
+ continue
756
+ statements = modules[p].statements
757
+ if statements is None or any(
758
+ s.kind == CYTHON_IMPORT and not s.visible and names_module(s, stem)
759
+ for s in statements
760
+ ):
761
+ seen.add(p)
762
+ if p.endswith(".pxd"):
763
+ frontier.append(p)
764
+ return seen
765
+
766
+ def _cython_callers(self, path: str, name: str) -> list[str]:
767
+ """The Cython functions at C that name ``path::name``, and through
768
+ any of them that is itself ``nogil`` or ``cpdef``, theirs."""
769
+ found: set[str] = set()
770
+ stack = [(path, name)]
771
+ while stack:
772
+ p, n = stack.pop()
773
+ simple = n.split(".")[-1].split("#")[0]
774
+ for caller_path, caller in self._cython_mentioning(simple):
775
+ caller_id = symbol_id(caller_path, caller.name)
776
+ if caller_id in found or (caller_path, caller.name) == (path, name):
777
+ continue
778
+ found.add(caller_id)
779
+ if caller.nogil or caller.cpdef or caller.unprofiled:
780
+ stack.append((caller_path, caller.name))
781
+ return sorted(found)
782
+
783
+ def _cython_import_effect(self, symbol_id_: str, label: str) -> None:
784
+ """Cython code that ran while a module was imported built its state;
785
+ code that ran in a hook or during collection cannot be planned
786
+ statically, so it selects all."""
787
+ for module in sorted(self.evidence.import_by.get(symbol_id_, ())):
788
+ if module.startswith(UNINDEXED_MODULE):
789
+ self._select_all(
790
+ RULE_UNINDEXED_IMPORT,
791
+ f"{label}: {symbol_id_} ran while a file outside the source roots was imported",
792
+ )
793
+ else:
794
+ self._escalate_module(module, f"{symbol_id_} ran while {module} was imported")
795
+ if symbol_id_ in self.evidence.hook_phase:
796
+ self._select_all(
797
+ RULE_UNOBSERVED_FILE,
798
+ f"{label}: {symbol_id_} ran outside every test (a hook or collection)",
799
+ )
800
+
801
+ def _change(self, change: SymbolChange) -> None:
802
+ symbol = change.symbol
803
+ kinds = set(change.changes)
804
+ test = self.test_code.is_test_code(symbol.module)
805
+ label = f"{change.id} {'/'.join(change.changes)}"
806
+ if symbol.kind in (FUNCTION, METHOD) and symbol.name.startswith("pytest_"):
807
+ self._select_all(RULE_PYTEST_HOOK, f"{label}: a pytest hook can change any test")
808
+ return
809
+ if (
810
+ symbol.kind == VARIABLE
811
+ and symbol.module in self.test_code.conftests
812
+ and symbol.container == symbol.module
813
+ and symbol.name in PYTEST_COLLECTION_NAMES
814
+ ):
815
+ self._select_all(RULE_PYTEST_HOOK, f"{label}: it decides what pytest loads")
816
+ return
817
+ body_only = symbol.kind in (FUNCTION, METHOD) and kinds <= {
818
+ BODY_CHANGED,
819
+ DEPENDENCIES_CHANGED,
820
+ DOCSTRING_CHANGED,
821
+ }
822
+ if test and not body_only and change.id not in self.test_code.entries:
823
+ # A test's own change reaches the targets it is the entry of
824
+ # (changed_target); anything else in test code reaches its scope.
825
+ self._scope(symbol, change)
826
+ if symbol.kind == MODULE:
827
+ if kinds & {ADDED, DELETED}:
828
+ self._observe(change.id, RULE_EXECUTED_CHANGED, label, change)
829
+ self._readers(change.id, change, label)
830
+ if DELETED in kinds:
831
+ self._importers(change.id, change, label)
832
+ self._sites(symbol, change, label, imports=True)
833
+ else:
834
+ self._escalate_change(change, f"{label}: module-level code runs at import")
835
+ return
836
+ if symbol.kind in (FUNCTION, METHOD):
837
+ self._observe(change.id, RULE_EXECUTED_CHANGED, label, change)
838
+ self._import_effect(change.id, change, label)
839
+ if kinds & {ADDED, DELETED}:
840
+ self._readers(change.id, change, label)
841
+ if DELETED in kinds:
842
+ self._importers(change.id, change, label)
843
+ self._sites(symbol, change, label)
844
+ # Decorators and defaults are what can register a function;
845
+ # its annotations cannot.
846
+ inert = all(s.inert_header for s in (change.base, change.head) if s is not None)
847
+ if not inert and change.id not in self.test_code.entries:
848
+ # ``@register def two()``: the decorator runs at import and
849
+ # may change shared state no test's record names.
850
+ self._escalate_change(change, f"{label}: its definition runs code at import")
851
+ elif kinds & {DEFINITION_CHANGED, ANNOTATIONS_CHANGED}:
852
+ self._readers(change.id, change, label)
853
+ self._sites(symbol, change, label)
854
+ runs_code = not all(
855
+ s.inert_definition for s in (change.base, change.head) if s is not None
856
+ )
857
+ if runs_code and change.id not in self.test_code.entries:
858
+ self._escalate_change(change, f"{label}: its definition runs code at import")
859
+ if _is_dunder(symbol.name) and not body_only and symbol.container:
860
+ container = self.symbols.get(symbol.container)
861
+ if container is not None and container.kind == CLASS:
862
+ self._hierarchy(container.id, change, label)
863
+ elif container is not None and container.kind == MODULE:
864
+ # A module ``__getattr__``/``__dir__`` (PEP 562) serves every
865
+ # lookup the module does not answer, wherever it is made.
866
+ self._escalate_change(
867
+ change, f"{label}: a module's {symbol.name} serves its missing names"
868
+ )
869
+ return
870
+ if symbol.kind == VARIABLE and symbol.name == "__all__":
871
+ # What ``from m import *`` binds in every star importer: no record
872
+ # names the variable, the import statement reads it.
873
+ self._escalate_change(change, f"{label}: star imports of the module bind its names")
874
+ if symbol.kind == VARIABLE:
875
+ self._observe(change.id, RULE_EXECUTED_CHANGED, label, change)
876
+ self._readers(change.id, change, label)
877
+ # A lookup by a name nothing bounds reads the value as well as
878
+ # noticing the name come or go: a variable runs no code of its
879
+ # own for the record to show.
880
+ self._sites(symbol, change, label)
881
+ if DELETED in kinds:
882
+ self._importers(change.id, change, label)
883
+ return
884
+ if symbol.kind == CLASS:
885
+ self._observe(change.id, RULE_EXECUTED_CHANGED, label, change)
886
+ if kinds & {ADDED, DELETED}:
887
+ self._readers(change.id, change, label)
888
+ self._sites(symbol, change, label)
889
+ if DELETED in kinds:
890
+ self._importers(change.id, change, label)
891
+ if self._runs_on_creation(symbol.id):
892
+ # ``class B(Base)`` whose base registers subclasses, a
893
+ # decorated or metaclassed class: creating it runs code.
894
+ self._escalate_change(change, f"{label}: creating the class runs code")
895
+ return
896
+ before = self.c.class_attributes.get(symbol.id, {})
897
+ after = self.other.class_attributes.get(symbol.id, {})
898
+ if DEPENDENCIES_CHANGED in kinds or before.get(CLASS_STATEMENT) != after.get(
899
+ CLASS_STATEMENT
900
+ ):
901
+ # Bases, decorators, metaclass keywords (or what the body's
902
+ # names resolve to): they run at import and may register the
903
+ # class or rebuild it. A definition change that is only an
904
+ # added or deleted member is that member's own change.
905
+ self._escalate_change(change, f"{label}: the class statement runs at import")
906
+ if BODY_CHANGED in kinds:
907
+ self._class_body(symbol, change, label)
908
+ # A definition change that is only an added or deleted member is
909
+ # that member's own change: code notices a new or missing
910
+ # attribute only by looking it up, by name (a reader), by a name
911
+ # nothing bounds (a lookup site) or reflectively.
912
+ return
913
+ self._escalate_change(change, f"{label}: not a kind evidence can bound")
914
+
915
+ def _runs_on_creation(self, class_id: str) -> bool:
916
+ """Whether creating the class may run code that reads its attributes:
917
+ it or an ancestor is open (decorators, keywords, a base outside the
918
+ index) or an ancestor defines ``__init_subclass__``."""
919
+ for index in (self.c, self.other):
920
+ seen = {class_id}
921
+ stack = [class_id]
922
+ while stack:
923
+ cls = stack.pop()
924
+ if cls in index.open_classes:
925
+ return True
926
+ if cls != class_id and f"{cls}.__init_subclass__" in index.symbols:
927
+ return True
928
+ for base in index.class_bases.get(cls, ()):
929
+ if base not in seen:
930
+ seen.add(base)
931
+ stack.append(base)
932
+ return False
933
+
934
+ def _class_body(self, symbol: Symbol, change: SymbolChange, label: str) -> None:
935
+ before = self.c.class_attributes.get(symbol.id, {})
936
+ after = self.other.class_attributes.get(symbol.id, {})
937
+ names = {
938
+ n
939
+ for n in before.keys() | after.keys()
940
+ if before.get(n) != after.get(n) and n != CLASS_STATEMENT
941
+ }
942
+ if OPAQUE_ATTRIBUTE in names or any(_is_dunder(n) for n in names):
943
+ self._escalate_change(
944
+ change,
945
+ f"{label}: its body runs code that binds no plain attribute, or a special one",
946
+ )
947
+ return
948
+ if self._runs_on_creation(symbol.id):
949
+ # A dataclass field default, an Enum member: consumed when the class
950
+ # is created, and used through generated code that never names it.
951
+ self._escalate_change(
952
+ change, f"{label}: its creation (a decorator, metaclass or base) reads its body"
953
+ )
954
+ return
955
+ for name in sorted(names):
956
+ readers = self.attribute_readers.get(name, set()) | self.by_name.get(name, set())
957
+ for reader in sorted(readers):
958
+ self._reader(reader, change, f"{label} (attribute {name})")
959
+ self._sites(symbol, change, label)
960
+
961
+ def _hierarchy(self, cls: str, change: SymbolChange, label: str) -> None:
962
+ """Members of the class, its bases and its subclasses: code running on
963
+ an instance of any of them may see the change."""
964
+ related = {cls}
965
+ for graph in (self.bases, self.subclasses):
966
+ stack = [cls]
967
+ while stack:
968
+ for nxt in graph.get(stack.pop(), ()):
969
+ if nxt not in related:
970
+ related.add(nxt)
971
+ stack.append(nxt)
972
+ for c in sorted(related):
973
+ for member in self.members.get(c, ()):
974
+ kind = self.symbols[member].kind
975
+ if kind in (FUNCTION, METHOD, CLASS):
976
+ self._observe(
977
+ member,
978
+ RULE_EXECUTED_READER,
979
+ f"{member} belongs to the hierarchy of {cls}; {label}",
980
+ change,
981
+ )
982
+ if c != cls and c in self.subclasses.get(cls, set()):
983
+ self._readers(c, change, label)
984
+
985
+ def _readers(self, target: str, change: SymbolChange | None, label: str) -> None:
986
+ readers = set(self.readers_of.get(target, ()))
987
+ name = target.rsplit(".", 1)[-1]
988
+ if not _is_dunder(name):
989
+ readers |= self.by_name.get(name, set())
990
+ readers.discard(target)
991
+ for reader in sorted(readers):
992
+ own = change is not None and target == change.id
993
+ self._reader(reader, change, label if own else f"{label} via {target}")
994
+
995
+ def _reader(self, reader: str, change: SymbolChange | None, label: str) -> None:
996
+ symbol = self.symbols.get(reader)
997
+ if symbol is None:
998
+ return
999
+ if symbol.kind in (FUNCTION, METHOD):
1000
+ self._observe(reader, RULE_EXECUTED_READER, f"{reader} reads {label}", change)
1001
+ self._import_effect(reader, change, f"{label}, read by {reader}")
1002
+ elif symbol.kind == VARIABLE:
1003
+ # Its initialiser captured the value: it changed too.
1004
+ if reader not in self._followed:
1005
+ self._followed.add(reader)
1006
+ self._observe(reader, RULE_EXECUTED_READER, f"{reader} reads {label}", change)
1007
+ self._readers(reader, change, label)
1008
+ self._sites(symbol, change, f"{label}, captured by {reader}")
1009
+ else:
1010
+ # Module or class top-level code: import-time state.
1011
+ self._escalate_module(symbol.module, f"top-level code of {reader} reads {label}")
1012
+
1013
+ def _importers(self, target: str, change: SymbolChange, label: str) -> None:
1014
+ """A deleted name or module: whoever imports it fails there."""
1015
+ for importer in sorted(self.importers_of.get(target, ())):
1016
+ symbol = self.symbols.get(importer)
1017
+ if symbol is None:
1018
+ continue
1019
+ if symbol.kind == MODULE:
1020
+ self._escalate_module(importer, f"{importer} imports {label}")
1021
+ else:
1022
+ self._observe(importer, RULE_EXECUTED_READER, f"{importer} imports {label}", change)
1023
+
1024
+ def _import_effect(self, symbol_id: str, change: SymbolChange | None, label: str) -> None:
1025
+ """Code that ran at C while a module was imported built that module's
1026
+ import-time state; code that ran outside every test and import ran in
1027
+ a hook or during collection."""
1028
+ for module in sorted(self.evidence.import_by.get(symbol_id, ())):
1029
+ if module.startswith(UNINDEXED_MODULE):
1030
+ self._select_all(
1031
+ RULE_UNINDEXED_IMPORT,
1032
+ f"{label}: {symbol_id} ran while {module[len(UNINDEXED_MODULE) :]}, a file "
1033
+ "outside the source roots, was imported",
1034
+ )
1035
+ else:
1036
+ self._escalate_module(
1037
+ module, f"{symbol_id} ran while {module} was imported; {label}"
1038
+ )
1039
+ if symbol_id in self.evidence.hook_phase:
1040
+ reason = f"{symbol_id} ran outside every test (a hook or collection); {label}"
1041
+ if change is not None and symbol_id == change.id:
1042
+ self._escalate_change(change, reason)
1043
+ elif symbol_id not in self.seed_nodes:
1044
+ self.seed_nodes[symbol_id] = reason
1045
+
1046
+ def _escalate_change(self, change: SymbolChange, why: str) -> None:
1047
+ """Plan the change statically (it runs at import). What it built is
1048
+ seen through the namespace holding it: a module-level change through
1049
+ the module, a decorated member through its class, so the lookup
1050
+ sites are those that can see that namespace."""
1051
+ self.seed_changes.add(change.id)
1052
+ symbol = change.symbol
1053
+ self._module_symbols(symbol.module, why)
1054
+ self._sites(symbol, change, why, imports=symbol.kind == MODULE)
1055
+
1056
+ def _escalate_module(self, module: str, why: str) -> None:
1057
+ """Plan the module's import statically, as if its top-level code
1058
+ changed: what ran during it (or what it read) did."""
1059
+ if module not in self.seed_nodes:
1060
+ self.seed_nodes[module] = why
1061
+ self._module_symbols(module, why)
1062
+ module_symbol = self.symbols.get(module)
1063
+ if module_symbol is not None:
1064
+ self._sites(module_symbol, None, why, imports=True)
1065
+
1066
+ def _module_symbols(self, module: str, why: str) -> None:
1067
+ """Every symbol of a module whose import-time state may differ: code
1068
+ running there can read that state without naming it."""
1069
+ if module in self.escalated_modules:
1070
+ return
1071
+ self.escalated_modules.add(module)
1072
+ for symbol in self.symbols.values():
1073
+ if symbol.module == module and symbol.kind in (FUNCTION, METHOD, CLASS, MODULE):
1074
+ self._observe(
1075
+ symbol.id,
1076
+ RULE_ESCALATED,
1077
+ f"{symbol.id} is in {module}, whose import-time state may differ: {why}",
1078
+ None,
1079
+ )
1080
+
1081
+ def _sites(
1082
+ self, symbol: Symbol, change: SymbolChange | None, label: str, *, imports: bool = False
1083
+ ) -> None:
1084
+ """Code that finds names by a name nothing bounds and can see the
1085
+ namespace of ``symbol``: a read off an object from anywhere, eval and
1086
+ reflection always; a read off a module global when the namespace's
1087
+ module is in the site's import closure; a module named at run time
1088
+ for a module-level namespace, when ``imports``. For test code only
1089
+ sites in test code count: nothing else holds a test module or a test
1090
+ class the runner instantiates. A member of a class only the runner
1091
+ ever instantiates (planner._runner_only_classes) is seen only by sites
1092
+ inside that class, its bases and its subclasses: no other code can
1093
+ hold one of its instances."""
1094
+ namespace = symbol.module
1095
+ test = self.test_code.is_test_code(namespace)
1096
+ family = self._runner_family(symbol.id)
1097
+ # A test module or conftest is held by pytest and by its importers.
1098
+ # Only code that imports it, or is handed it (below), can look a
1099
+ # module-level name up on it.
1100
+ module_name = test and (symbol.kind == MODULE or symbol.container == namespace)
1101
+ for site, kind in self.sites:
1102
+ site_symbol = self.symbols.get(site)
1103
+ if site_symbol is None:
1104
+ continue
1105
+ if test and not self.test_code.is_test_code(site_symbol.module):
1106
+ continue
1107
+ if family is not None and not _inside(site, family, self.c, self.other):
1108
+ continue # but see the handing-on sites below
1109
+ if kind == SITE_CLOSURE or module_name:
1110
+ if namespace not in self.reach.closure_of(site):
1111
+ continue
1112
+ elif kind == SITE_IMPORT:
1113
+ if not imports or test:
1114
+ continue
1115
+ self._observe(
1116
+ site,
1117
+ RULE_LOOKUP_SITE,
1118
+ f"{site} looks names up by a name nothing bounds and can see {namespace}; {label}",
1119
+ change,
1120
+ )
1121
+ if module_name:
1122
+ # ``request.module`` hands a test module on, and ``sys.modules``
1123
+ # finds one by name: the reader stands in for what follows.
1124
+ for site in sorted(self.module_hands_on):
1125
+ self._observe(
1126
+ site,
1127
+ RULE_LOOKUP_SITE,
1128
+ f"{site} reads .module or sys.modules and may hand a test module on to "
1129
+ f"a lookup that can see {namespace}; {label}",
1130
+ change,
1131
+ )
1132
+ if family is not None:
1133
+ # ``request.instance``, ``item.instance`` and ``request.cls`` hand
1134
+ # a test object to other code, which may then look anything up on
1135
+ # it. That happens within the test that read it, so its record
1136
+ # holds the read: the reader stands in for every lookup after it.
1137
+ for site in sorted(self.hands_on):
1138
+ self._observe(
1139
+ site,
1140
+ RULE_LOOKUP_SITE,
1141
+ f"{site} reads .instance or .cls and may hand a test object on to a lookup "
1142
+ f"that can see {namespace}; {label}",
1143
+ change,
1144
+ )
1145
+
1146
+ def _runner_family(self, symbol_id: str) -> set[str] | None:
1147
+ """The runner-only class holding ``symbol_id`` with its bases and
1148
+ subclasses, or None when no runner-only class holds it."""
1149
+ current: str | None = symbol_id
1150
+ while current:
1151
+ if current in self.runner_only:
1152
+ family = {current}
1153
+ for graph in (self.bases, self.subclasses):
1154
+ stack = [current]
1155
+ while stack:
1156
+ for nxt in graph.get(stack.pop(), ()):
1157
+ if nxt not in family:
1158
+ family.add(nxt)
1159
+ stack.append(nxt)
1160
+ return family
1161
+ symbol = self.symbols.get(current)
1162
+ current = symbol.container if symbol is not None else None
1163
+ return None
1164
+
1165
+ def _file(self, path: str, what: str, *, names: bool) -> None:
1166
+ """A changed file the index does not read. Its content is observed by
1167
+ whoever opened or stat'ed it; that it exists (``names``: it was added
1168
+ or deleted) also by whoever listed a directory above it."""
1169
+ if _unobserved_file(path):
1170
+ self._select_all(
1171
+ RULE_UNOBSERVED_FILE,
1172
+ f"{path} {what}: compiled source, build or pytest configuration, read where "
1173
+ "no test's record sees it",
1174
+ )
1175
+ return
1176
+ observed = [(path, self.evidence.import_paths)]
1177
+ if names:
1178
+ observed += [(d, self.evidence.import_dirs) for d in _ancestors(path)[1:]]
1179
+ for seen, where_seen in observed:
1180
+ for module in sorted(where_seen.get(seen, ())):
1181
+ where = seen or "the checkout root"
1182
+ if module == "" or module.startswith(UNINDEXED_MODULE):
1183
+ self._select_all(
1184
+ RULE_UNOBSERVED_FILE,
1185
+ f"{path} {what}, and project code touched {where} outside every test "
1186
+ "and outside any import it could be credited to (a hook or collection)",
1187
+ )
1188
+ else:
1189
+ self._escalate_module(
1190
+ module,
1191
+ f"{path} {what}, and {where} was touched while {module} was imported",
1192
+ )
1193
+ self.files[path] = (what, names)
1194
+
1195
+
1196
+ def _changed_outside_roots(
1197
+ repo: Path, commit: str, other: SourceIndex, source_roots: list[str] | None
1198
+ ) -> dict[str, str]:
1199
+ """Paths changed between the recorded commit and ``other`` that lie
1200
+ outside every source root (none when a root is the repository itself)."""
1201
+ dirs = [split_root(r)[0] for r in (source_roots or ["."])]
1202
+ if "" in dirs:
1203
+ return {}
1204
+ revision = other.snapshot.commit
1205
+ changed = changed_paths(repo, commit, revision, other.snapshot.kind)
1206
+ return {
1207
+ path: what
1208
+ for path, what in changed.items()
1209
+ if not any(path.startswith(d + "/") for d in dirs)
1210
+ and path not in OWN_FILES
1211
+ and not path.startswith(OWN_DIRS)
1212
+ }
1213
+
1214
+
1215
+ def plan_with_evidence(
1216
+ base: SourceIndex,
1217
+ head: SourceIndex,
1218
+ evidence: Evidence,
1219
+ evidence_index: SourceIndex,
1220
+ manifest: Manifest | None,
1221
+ *,
1222
+ repo: str = "",
1223
+ source_roots: list[str] | None = None,
1224
+ discovered: list[DiscoveryResult] | None = None,
1225
+ declarations: list[Declaration] | None = None,
1226
+ base_target_ids: set[str] | None = None,
1227
+ ) -> Plan:
1228
+ discovered = list(discovered or [])
1229
+ declared = list(declarations or [])
1230
+ targets = merge_targets(manifest, discovered)
1231
+ pytest_targets = [t for t in targets if t.runner == "pytest"]
1232
+ changes = classify(base, head)
1233
+ errors = sorted(base.errors + head.errors + evidence_index.errors)
1234
+
1235
+ pairs = [("head", head)]
1236
+ if not (base.snapshot.committed and base.snapshot.commit == evidence.commit):
1237
+ pairs.append(("base", base))
1238
+ test_code = _TestCode(pytest_targets, (evidence_index, base, head))
1239
+ test_code.entry_modules({**evidence_index.symbols, **base.symbols, **head.symbols})
1240
+
1241
+ fallbacks: list[Fallback] = []
1242
+ if errors:
1243
+ fallbacks.append(
1244
+ Fallback(
1245
+ RULE_ANALYSIS_ERROR,
1246
+ "all_targets",
1247
+ f"{len(errors)} analysis error(s); the dependency graph is incomplete, "
1248
+ "so every supplied target is selected",
1249
+ )
1250
+ )
1251
+ observed: list[_Observers] = []
1252
+ escalated: dict[str, list[Reason]] = defaultdict(list)
1253
+ target_fallbacks: dict[str, list[Reason]] = defaultdict(list)
1254
+ changed_ids: set[str] = {c.id for c in changes if c.carries_impact}
1255
+ docstring_ids: set[str] = {c.id for c in changes if DOCSTRING_CHANGED in c.changes}
1256
+ for side, other in pairs:
1257
+ runner_only = _runner_only(evidence_index, other, pytest_targets)
1258
+ obs = _Observers(evidence_index, other, evidence, test_code, declared, runner_only)
1259
+ if repo:
1260
+ obs.outside = _changed_outside_roots(Path(repo), evidence.commit, other, source_roots)
1261
+ obs.run()
1262
+ observed.append(obs)
1263
+ fallbacks += obs.fallbacks
1264
+ changed_ids |= {c.id for c in obs.changes if c.carries_impact}
1265
+ docstring_ids |= {c.id for c in obs.changes if DOCSTRING_CHANGED in c.changes}
1266
+ runner = _runner_dependency_fallbacks(targets, obs.changes, evidence_index, other)
1267
+ seeds = Seeds(frozenset(obs.seed_changes), dict(obs.seed_nodes))
1268
+ static = plan_from_indexes(
1269
+ evidence_index,
1270
+ other,
1271
+ manifest,
1272
+ repo=repo,
1273
+ source_roots=source_roots,
1274
+ discovered=discovered,
1275
+ declarations=declared,
1276
+ seeds=seeds,
1277
+ )
1278
+ for decision in static.decisions:
1279
+ for r in decision.reasons:
1280
+ if r.rule == RULE_ANALYSIS_ERROR:
1281
+ continue # reported once, above
1282
+ if r.rule in (RULE_ESCALATED,) or r.path or r.changed_symbol:
1283
+ escalated[decision.target.node_id].append(
1284
+ Reason(
1285
+ RULE_ESCALATED,
1286
+ f"static planning of C -> {side}: {r.detail}",
1287
+ r.path,
1288
+ r.changed_symbol,
1289
+ r.changes,
1290
+ )
1291
+ )
1292
+ else:
1293
+ target_fallbacks[decision.target.node_id].append(r)
1294
+ for fb in runner:
1295
+ target_fallbacks[fb.target or ""].append(Reason(fb.rule, fb.detail))
1296
+
1297
+ static_other: dict[str, Decision] = {}
1298
+ if any(t.runner != "pytest" for t in targets):
1299
+ full = plan_from_indexes(
1300
+ base,
1301
+ head,
1302
+ manifest,
1303
+ repo=repo,
1304
+ source_roots=source_roots,
1305
+ discovered=discovered,
1306
+ declarations=declared,
1307
+ base_target_ids=base_target_ids,
1308
+ )
1309
+ static_other = {d.target.node_id: d for d in full.decisions if d.target.runner != "pytest"}
1310
+
1311
+ decisions = [
1312
+ _evidence_decision(
1313
+ target,
1314
+ evidence,
1315
+ observed,
1316
+ fallbacks,
1317
+ escalated,
1318
+ target_fallbacks,
1319
+ changed_ids,
1320
+ docstring_ids,
1321
+ base_target_ids,
1322
+ static_other,
1323
+ )
1324
+ for target in targets
1325
+ ]
1326
+ plan = Plan(
1327
+ repo=repo,
1328
+ source_roots=list(source_roots or []),
1329
+ changes=changes,
1330
+ decisions=decisions,
1331
+ fallbacks=_dedupe(fallbacks),
1332
+ unresolved=[],
1333
+ errors=errors,
1334
+ declarations=sorted(declared),
1335
+ base_index=base,
1336
+ head_index=head,
1337
+ discovery=discovered,
1338
+ targets=targets,
1339
+ )
1340
+ plan.evidence = _summary(evidence, pairs, observed, changes)
1341
+ return plan
1342
+
1343
+
1344
+ def _dedupe(fallbacks: list[Fallback]) -> list[Fallback]:
1345
+ return list(dict.fromkeys(fallbacks))
1346
+
1347
+
1348
+ def _evidence_decision(
1349
+ target: Target,
1350
+ evidence: Evidence,
1351
+ observed: list[_Observers],
1352
+ fallbacks: list[Fallback],
1353
+ escalated: dict[str, list[Reason]],
1354
+ target_fallbacks: dict[str, list[Reason]],
1355
+ changed_ids: set[str],
1356
+ docstring_ids: set[str],
1357
+ base_target_ids: set[str] | None,
1358
+ static_other: dict[str, Decision],
1359
+ ) -> Decision:
1360
+ if target.runner != "pytest":
1361
+ return static_other[target.node_id]
1362
+ reasons: list[Reason] = [Reason(fb.rule, fb.detail) for fb in _dedupe(fallbacks)]
1363
+ record = evidence.tests.get(target.runner_id)
1364
+ fixture_sources = any(obs.fixture_sources_changed for obs in observed)
1365
+ for r in dict.fromkeys(target_fallbacks.get(target.node_id, ())):
1366
+ if r.rule == RULE_LIFECYCLE_UNRESOLVED and record is not None and not fixture_sources:
1367
+ # A fixture discovery could not resolve still ran, so the record
1368
+ # holds it. Only one added or redefined where discovery cannot
1369
+ # see it could reach the test unrecorded, and a fixture needs
1370
+ # pytest: nothing that uses pytest defined a function differently.
1371
+ continue
1372
+ reasons.append(r)
1373
+ short = evidence.commit[:12]
1374
+ if record is None:
1375
+ reasons.append(
1376
+ Reason(
1377
+ RULE_NO_EVIDENCE,
1378
+ f"{target.runner_id} has no record in the evidence from {short} (new, "
1379
+ "deselected or not collected then)",
1380
+ )
1381
+ )
1382
+ else:
1383
+ if record.flags & FLAG_UNSTABLE:
1384
+ reasons.append(
1385
+ Reason(
1386
+ RULE_UNSTABLE,
1387
+ "its record differed between two collections in different orders",
1388
+ )
1389
+ )
1390
+ if record.flags & FLAG_SUBPROCESS:
1391
+ reasons.append(
1392
+ Reason(RULE_SUBPROCESS, "it started a subprocess, whose execution is not recorded")
1393
+ )
1394
+ executed = evidence.executed(record)
1395
+ touched = evidence.touched(record)
1396
+ listed = evidence.listed(record)
1397
+ for obs in observed:
1398
+ hits = sorted(executed & obs.E.keys())
1399
+ if hits:
1400
+ why = obs.E[hits[0]]
1401
+ more = f" (and {len(hits) - 1} more)" if len(hits) > 1 else ""
1402
+ reasons.append(
1403
+ Reason(
1404
+ why.rule,
1405
+ f"executed {hits[0]} in the evidence run at {short}{more}: {why.detail}",
1406
+ (),
1407
+ why.changed_symbol,
1408
+ why.changes,
1409
+ )
1410
+ )
1411
+ for path, (what, names) in sorted(obs.files.items()):
1412
+ how = None
1413
+ if path in touched:
1414
+ how = f"opened or stat'ed {path}"
1415
+ elif names:
1416
+ dirs = [d for d in _ancestors(path)[1:] if d in listed]
1417
+ if dirs:
1418
+ how = f"listed {dirs[0] or 'the checkout root'}"
1419
+ if how is not None:
1420
+ reasons.append(
1421
+ Reason(
1422
+ RULE_TOUCHED_FILE,
1423
+ f"{how} in the evidence run at {short}; {path} {what}",
1424
+ )
1425
+ )
1426
+ break
1427
+ for obs in observed:
1428
+ if target.runner_id in obs.direct:
1429
+ reasons.append(obs.direct[target.runner_id])
1430
+ containers = _entry_chain(target.entry_symbol)
1431
+ hit = sorted(changed_ids & set(containers))
1432
+ if hit:
1433
+ reasons.append(
1434
+ Reason(
1435
+ RULE_CHANGED_TARGET,
1436
+ f"{hit[0]} changed: the target's entry or what contains it (a skipped test's "
1437
+ "record lacks its own entry)",
1438
+ (),
1439
+ hit[0],
1440
+ )
1441
+ )
1442
+ if base_target_ids is not None and target.runner_id not in base_target_ids:
1443
+ reasons.append(
1444
+ Reason(
1445
+ RULE_NEW_TARGET,
1446
+ f"{target.runner_id} is not in the base snapshot: a new target is selected "
1447
+ "whatever its entry symbol did",
1448
+ )
1449
+ )
1450
+ if target.entry_symbol in docstring_ids:
1451
+ reasons.append(
1452
+ Reason(
1453
+ RULE_ENTRY_DOCSTRING,
1454
+ f"the docstring of the entry symbol {target.entry_symbol} changed",
1455
+ )
1456
+ )
1457
+ reasons += escalated.get(target.node_id, [])
1458
+ return _decision(target, list(dict.fromkeys(reasons)), {})
1459
+
1460
+
1461
+ def _entry_chain(entry: str) -> list[str]:
1462
+ parts = entry.split(".")
1463
+ return [".".join(parts[:i]) for i in range(len(parts), 0, -1)]
1464
+
1465
+
1466
+ def _summary(
1467
+ evidence: Evidence,
1468
+ pairs: list[tuple[str, SourceIndex]],
1469
+ observed: list[_Observers],
1470
+ changes: list[SymbolChange],
1471
+ ) -> dict:
1472
+ in_range = {c.id for c in changes}
1473
+ outside = sorted({c.id for obs in observed for c in obs.changes} - in_range)
1474
+ return {
1475
+ "store": str(evidence.location) if evidence.location else None,
1476
+ "commit": evidence.commit,
1477
+ "environment_hash": evidence.environment_hash,
1478
+ "python": evidence.environment.get("python", "").split()[0],
1479
+ "hash_seed": evidence.environment.get("variables", {}).get("PYTHONHASHSEED"),
1480
+ "variables": sorted(evidence.environment.get("variables", {})),
1481
+ "created": evidence.created,
1482
+ "tests": len(evidence.tests),
1483
+ "reverse_checked": evidence.reverse_checked,
1484
+ "planned": [f"{evidence.commit[:12]} -> {side}" for side, _ in pairs],
1485
+ "changes_outside_range": outside,
1486
+ "escalated_modules": sorted({m for obs in observed for m in obs.escalated_modules}),
1487
+ }