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.
- diffcone/__init__.py +13 -0
- diffcone/cache.py +528 -0
- diffcone/check.py +399 -0
- diffcone/classify.py +255 -0
- diffcone/cli.py +886 -0
- diffcone/collect.py +846 -0
- diffcone/cython.py +757 -0
- diffcone/declarations.py +111 -0
- diffcone/discovery/__init__.py +121 -0
- diffcone/discovery/asv_static.py +494 -0
- diffcone/discovery/common.py +130 -0
- diffcone/discovery/pytest_static.py +2680 -0
- diffcone/evidence.py +738 -0
- diffcone/evidence_plan.py +1487 -0
- diffcone/execution.py +1681 -0
- diffcone/indexer/__init__.py +48 -0
- diffcone/indexer/core.py +286 -0
- diffcone/indexer/definitions.py +292 -0
- diffcone/indexer/dynamics.py +406 -0
- diffcone/indexer/facts.py +373 -0
- diffcone/indexer/literals.py +388 -0
- diffcone/indexer/references.py +895 -0
- diffcone/indexer/resolver.py +1001 -0
- diffcone/indexer/scopes.py +276 -0
- diffcone/indexer/state.py +83 -0
- diffcone/indexer/symbols.py +441 -0
- diffcone/indexer/syntax.py +173 -0
- diffcone/manifest.py +166 -0
- diffcone/model.py +191 -0
- diffcone/planner.py +1453 -0
- diffcone/report.py +256 -0
- diffcone/selection.py +133 -0
- diffcone/snapshot.py +739 -0
- diffcone/testing.py +200 -0
- diffcone-0.1.0.dist-info/METADATA +133 -0
- diffcone-0.1.0.dist-info/RECORD +39 -0
- diffcone-0.1.0.dist-info/WHEEL +4 -0
- diffcone-0.1.0.dist-info/entry_points.txt +3 -0
- diffcone-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
}
|