codeterrain 0.3.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.
codeterrain/impact.py ADDED
@@ -0,0 +1,725 @@
1
+ """Impact: the tests that reach a Python name, joining the code index to the fixture index.
2
+
3
+ From the definition, ``impact`` walks outwards through its callers, a bounded number of levels,
4
+ until it meets tests: a call inside a test function is a test that runs the name; a call inside a
5
+ fixture reaches every test that pytest gives that fixture, directly or through other fixtures,
6
+ and every test an autouse fixture runs for; a call inside any other function (code or a test
7
+ helper) makes that function the next name to follow, and following a constructor (``__init__``)
8
+ follows the calls of its class too. A test that patches the name is listed apart: there the real
9
+ one does not run.
10
+
11
+ What a static walk cannot follow is counted, not guessed: calls that run at import time (module
12
+ level, a class body), the name used as a value (``Depends(get_user)``, a callback) and calls by
13
+ name on a receiver of unknown type. Nested functions are not definitions of their own, so a call
14
+ inside one counts as a call from the function that holds it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from bisect import bisect_left, bisect_right
20
+ from pathlib import PurePosixPath
21
+ from typing import Any
22
+
23
+ from codeterrain import code_static
24
+ from codeterrain.code_static import Index
25
+ from codeterrain.fixtures_static import is_test_module
26
+
27
+ #: How many levels of callers ``impact`` follows by default, and at most.
28
+ DEFAULT_DEPTH = 3
29
+ MAX_DEPTH = 10
30
+ #: How many functions ``impact`` follows in all, and how many fixtures it expands.
31
+ MAX_FOLLOWED = 2_000
32
+ MAX_FIXTURES = 2_000
33
+ #: How many steps of a chain a test row keeps: the first and the last ones, with "…" between.
34
+ MAX_VIA = 12
35
+ #: How many requests of a fixture (a test or fixture naming it) the fixture walk checks in all.
36
+ MAX_REQUESTS = 1_000_000
37
+ #: How many classes up an inheritance chain, and how many inheriting classes, are looked at.
38
+ MAX_BASES = 32
39
+ MAX_SUBCLASSES = 2_000
40
+ #: Methods that run when the class is called: following one follows the class's calls too.
41
+ _CONSTRUCTORS = ("__init__", "__new__", "__post_init__")
42
+ #: Functions pytest calls around tests without being asked (xunit style and unittest): a class's
43
+ #: hooks run for its tests and its subclasses' tests, a module's for the tests in that module.
44
+ _CLASS_HOOKS = frozenset({"setup_method", "teardown_method", "setup_class", "teardown_class"})
45
+ _MODULE_HOOKS = frozenset({"setup_module", "teardown_module"})
46
+ #: unittest's own, which pytest calls only for ``unittest.TestCase`` classes.
47
+ _UNITTEST_CLASS_HOOKS = frozenset({"setUp", "tearDown", "setUpClass", "tearDownClass"})
48
+ _UNITTEST_MODULE_HOOKS = frozenset({"setUpModule", "tearDownModule"})
49
+ _UNITTEST_BASES = frozenset({"unittest.TestCase", "unittest.IsolatedAsyncioTestCase"})
50
+ _FUNCTION_HOOKS = frozenset({"setup_function", "teardown_function"})
51
+
52
+
53
+ def _caller(index: Index, rel: str, scope: str) -> str:
54
+ """The definition a call in ``scope`` of ``rel`` belongs to: the innermost enclosing
55
+ function or method the index knows (nested functions and comprehensions are not
56
+ definitions), or "" for code that runs at import time: module level, or a class body."""
57
+ module = code_static.module_name(rel, index.src_package)
58
+ while scope:
59
+ full = f"{module}.{scope}"
60
+ found = index.definition(full)
61
+ if found is not None:
62
+ return "" if found[1]["kind"] == "class" else full
63
+ scope = scope.rpartition(".")[0]
64
+ return ""
65
+
66
+
67
+ def _qualname(index: Index, rel: str, scope: str) -> str:
68
+ """The pytest-style name (``Class::method``) of the definition a call in ``scope`` of
69
+ ``rel`` belongs to, or "" when it runs at import time."""
70
+ caller = _caller(index, rel, scope)
71
+ module = code_static.module_name(rel, index.src_package)
72
+ return caller.removeprefix(f"{module}.").replace(".", "::") if caller else ""
73
+
74
+
75
+ def _class_of(test: dict[str, Any]) -> str:
76
+ """The class a test is defined in, as fixtures record it (``TestOuter::TestInner``; "" for
77
+ a function)."""
78
+ return test["name"].rpartition("::")[0]
79
+
80
+
81
+ def _step(name: str, path: list[str]) -> list[str]:
82
+ """``path`` with ``name`` in front, kept to ``MAX_VIA`` steps: the newest, then the end."""
83
+ if len(path) < MAX_VIA:
84
+ return [name, *path]
85
+ return [name, "…", *path[-(MAX_VIA - 2):]]
86
+
87
+
88
+ class _Decorators:
89
+ """Which tests a decorator line of a test file applies to (``@patch(...)`` is read before
90
+ the function's own scope): the test whose decorators span the line, else every test method
91
+ of the class defined next, when no other definition (a decorated helper) comes between.
92
+ Each file's spans are laid out once, then found by bisection."""
93
+
94
+ def __init__(self, index: Index, by_file: dict[str, list[dict[str, Any]]]) -> None:
95
+ self.index, self.by_file = index, by_file
96
+ self._files: dict[str, tuple[list[int], list[tuple[int, int, list[str]]]]] = {}
97
+ self._lines: dict[str, list[int]] = {}
98
+
99
+ def _spans(self, rel: str) -> tuple[list[int], list[tuple[int, int, list[str]]]]:
100
+ if rel not in self._files:
101
+ module = code_static.module_name(rel, self.index.src_package)
102
+ tests = self.by_file.get(rel, [])
103
+ # every test and test class of the file looked up at once, not one query each
104
+ quals = {t["name"].replace("::", ".") for t in tests} | {
105
+ _class_of(t).replace("::", ".") for t in tests if "::" in t["name"]
106
+ }
107
+ known = self.index.definitions(f"{module}.{q}" for q in quals)
108
+
109
+ def def_line(qual: str) -> int:
110
+ found = known.get(f"{module}.{qual}")
111
+ return found[1]["line"] if found is not None else 0
112
+
113
+ # (start, end, tests): a test spans its first decorator to its `def`; a class is a
114
+ # point at its `class` line, standing for all its test methods.
115
+ spans = [
116
+ (t["line"], def_line(t["name"].replace("::", ".")), [t["name"]]) for t in tests
117
+ ]
118
+ methods: dict[str, list[str]] = {}
119
+ for t in tests:
120
+ if "::" in t["name"]:
121
+ methods.setdefault(_class_of(t), []).append(t["name"])
122
+ spans += [
123
+ (line, line, names) for cls, names in methods.items()
124
+ if (line := def_line(cls.replace("::", ".")))
125
+ ]
126
+ spans.sort()
127
+ self._files[rel] = ([s[0] for s in spans], spans)
128
+ return self._files[rel]
129
+
130
+ def tests(self, rel: str, line: int) -> list[str]:
131
+ starts, spans = self._spans(rel)
132
+ at = bisect_right(starts, line)
133
+ if at and spans[at - 1][0] <= line < spans[at - 1][1]:
134
+ return spans[at - 1][2]
135
+ # The next definition after the line is a test class: a decorator of that class.
136
+ if at < len(spans) and spans[at][0] == spans[at][1]:
137
+ if rel not in self._lines:
138
+ self._lines[rel] = self.index.lines(rel)
139
+ lines = self._lines[rel]
140
+ following = bisect_left(lines, line)
141
+ if following < len(lines) and lines[following] == spans[at][0]:
142
+ return spans[at][2]
143
+ return []
144
+
145
+
146
+ class _Classes:
147
+ """Class inheritance from the code index, in the fixture view's naming (``Outer::Inner``):
148
+ the classes a class takes methods and fixtures from, and the classes that inherit from it.
149
+ Bounded: ``MAX_BASES`` classes up a chain, ``MAX_SUBCLASSES`` found below one."""
150
+
151
+ def __init__(self, index: Index) -> None:
152
+ self.index = index
153
+ self._mro: dict[tuple[str, str], list[tuple[str, str]]] = {}
154
+ self._below: dict[tuple[str, str], list[tuple[str, str]]] = {}
155
+ self._members: dict[tuple[str, str], dict[str, dict[str, Any]]] = {}
156
+ self.cut = False
157
+
158
+ def _bases(self, rel: str, cls: str) -> list[tuple[str, str]]:
159
+ out = []
160
+ for full in self.index.bases(rel, cls.replace("::", ".")):
161
+ found = self.index.definition(full)
162
+ if found is not None and found[1]["kind"] == "class":
163
+ out.append((found[0], found[1]["name"].replace(".", "::")))
164
+ return out
165
+
166
+ def mro(self, rel: str, cls: str, depth: int = 0) -> list[tuple[str, str]]:
167
+ """``(rel, cls)`` and then its bases in Python's method resolution order (C3), as far
168
+ as the index can place them; an order C3 rejects falls back to depth first."""
169
+ key = (rel, cls)
170
+ if key not in self._mro:
171
+ self._mro[key] = [key] # a cycle through bad bases stops here
172
+ bases = self._bases(rel, cls) if depth < MAX_BASES else []
173
+ seqs = [self.mro(*b, depth + 1) for b in bases] + [bases]
174
+ seqs = [list(s) for s in seqs if s]
175
+ out = [key]
176
+ while seqs and len(out) < MAX_BASES:
177
+ head = next(
178
+ (s[0] for s in seqs if not any(s[0] in t[1:] for t in seqs)), None
179
+ )
180
+ if head is None: # inconsistent: keep the rest in depth-first order
181
+ out += [c for s in seqs for c in s if c not in out]
182
+ break
183
+ if head not in out:
184
+ out.append(head)
185
+ seqs = [t for t in ([c for c in s if c != head] for s in seqs) if t]
186
+ self._mro[key] = out[:MAX_BASES]
187
+ return self._mro[key]
188
+
189
+ def members(self, rel: str, cls: str) -> dict[str, dict[str, Any]]:
190
+ key = (rel, cls)
191
+ if key not in self._members:
192
+ self._members[key] = self.index.members(rel, cls.replace("::", "."))
193
+ return self._members[key]
194
+
195
+ def is_unittest(self, rel: str, cls: str) -> bool:
196
+ """Whether ``cls`` derives from ``unittest.TestCase``."""
197
+ return any(
198
+ base in _UNITTEST_BASES
199
+ for c in self.mro(rel, cls) for base in self.index.bases(c[0], c[1].replace("::", "."))
200
+ )
201
+
202
+ def definer(self, rel: str, cls: str, name: str) -> tuple[str, str] | None:
203
+ """The class in the method resolution order of ``cls`` whose ``name`` it uses."""
204
+ return next((c for c in self.mro(rel, cls) if name in self.members(*c)), None)
205
+
206
+ def initializer(self, rel: str, cls: str) -> tuple[tuple[str, str], bool] | None:
207
+ """The class in the MRO of ``cls`` whose ``__init__`` a call of it runs, and whether
208
+ ``@dataclass`` generated it (a written one in the same class wins); None for object's."""
209
+ module = code_static.module_name
210
+ for c in self.mro(rel, cls):
211
+ if "__init__" in self.members(*c):
212
+ return c, False
213
+ dotted = c[1].replace("::", ".")
214
+ found = self.index.definition(
215
+ ".".join(filter(None, (module(c[0], self.index.src_package), dotted)))
216
+ )
217
+ if found is not None and found[0] == c[0] and found[1]["gen"]:
218
+ return c, True
219
+ return None
220
+
221
+ def scopes(self, rel: str, cls: str) -> list[tuple[str, str]]:
222
+ """Where pytest looks for a class-level fixture a request in ``cls`` sees: the class and
223
+ its bases, then each enclosing class and its bases."""
224
+ out: list[tuple[str, str]] = []
225
+ while cls:
226
+ out += [s for s in self.mro(rel, cls) if s not in out]
227
+ cls = cls.rpartition("::")[0]
228
+ return out
229
+
230
+ def subclasses(self, rel: str, cls: str) -> list[tuple[str, str]]:
231
+ """Every class whose bases reach ``(rel, cls)``, nearest first."""
232
+ key = (rel, cls)
233
+ if key not in self._below:
234
+ out: list[tuple[str, str]] = []
235
+ todo = [key]
236
+ while todo:
237
+ here = todo.pop(0)
238
+ # A subclass may name the base under an import alias (``Base as Parent``).
239
+ tails = self.index.aliases({here[1].rpartition("::")[2]})
240
+ for sub_rel, sub in dict.fromkeys(
241
+ c for t in sorted(tails) for c in self.index.subclasses_named(t)
242
+ ):
243
+ found = (sub_rel, sub.replace(".", "::"))
244
+ if found in out or found == key or here not in self.mro(*found)[1:]:
245
+ continue
246
+ if len(out) >= MAX_SUBCLASSES:
247
+ self.cut = True
248
+ break
249
+ out.append(found)
250
+ todo.append(found)
251
+ self._below[key] = out
252
+ return self._below[key]
253
+
254
+
255
+ class _Fixtures:
256
+ """The fixture view, arranged for the walk: who requests each fixture name, and which
257
+ definition of a name pytest gives a request (visibility and overrides)."""
258
+
259
+ def __init__(
260
+ self, data: dict[str, Any], by_file: dict[str, list[dict[str, Any]]], classes: _Classes
261
+ ) -> None:
262
+ self.by_file, self.classes = by_file, classes
263
+ self.at = {(f["file"], f["cls"], f["func"]): f for f in data.get("fixtures", [])}
264
+ self.named: dict[str, list[dict[str, Any]]] = {}
265
+ # Name -> (file, class) -> its definitions there, in view order; and name -> plugin
266
+ # module -> its definitions: a request looks up its scopes, not every definition.
267
+ self._placed: dict[str, dict[tuple[str, str], list[dict[str, Any]]]] = {}
268
+ self._plugins: dict[str, dict[str, list[dict[str, Any]]]] = {}
269
+ for f in data.get("fixtures", []):
270
+ self.named.setdefault(f["name"], []).append(f)
271
+ self._placed.setdefault(f["name"], {}).setdefault((f["file"], f["cls"]), []).append(f)
272
+ if not is_test_module(f["file"]) and PurePosixPath(f["file"]).name != "conftest.py":
273
+ self._plugins.setdefault(f["name"], {}).setdefault(f["file"], []).append(f)
274
+ #: Scope lookups made by ``visible``: each costs one dictionary probe.
275
+ self.inspected = 0
276
+ # Fixture name -> (kind, record, class) of each test and fixture that requests it, by
277
+ # argument, usefixtures or getfixturevalue.
278
+ self.requests: dict[str, list[tuple[str, dict[str, Any], str]]] = {}
279
+ for t in data.get("tests", []):
280
+ for name in {*t["argnames"], *t["usefixtures"], *t["dyn"]}:
281
+ self.requests.setdefault(name, []).append(("test", t, _class_of(t)))
282
+ for f in data.get("fixtures", []):
283
+ for name in {*f["argnames"], *f["dyn"]}:
284
+ self.requests.setdefault(name, []).append(("fixture", f, f["cls"]))
285
+ self._seen: dict[tuple[str, str, str, int], dict[str, Any] | None] = {}
286
+
287
+ def visible(
288
+ self, name: str, rel: str, cls: str, skip: dict[str, Any] | None = None
289
+ ) -> dict[str, Any] | None:
290
+ """The definition of fixture ``name`` that pytest gives a request in ``rel`` (in class
291
+ ``cls``): the class's own or a base class's, then each enclosing class's, then the
292
+ module's, then the nearest conftest's up the folders, then one from a plugin module.
293
+ ``skip`` is a fixture requesting its own name, which gets the one it overrides."""
294
+ key = (name, rel, cls, id(skip))
295
+ if key not in self._seen:
296
+ placed = self._placed.get(name, {})
297
+
298
+ def at(file: str, owner: str) -> dict[str, Any] | None:
299
+ self.inspected += 1
300
+ return next((f for f in placed.get((file, owner), ()) if f is not skip), None)
301
+
302
+ best = None
303
+ if cls:
304
+ for scope in self.classes.scopes(rel, cls):
305
+ best = at(*scope)
306
+ if best is not None:
307
+ break
308
+ if best is None:
309
+ best = at(rel, "")
310
+ if best is None:
311
+ for folder in PurePosixPath(rel).parents:
312
+ best = at("conftest.py" if str(folder) == "." else f"{folder}/conftest.py", "")
313
+ if best is not None:
314
+ break
315
+ if best is None:
316
+ # A plugin module's fixture, when only one module offers the name: which of
317
+ # several pytest loads depends on registration (``pytest_plugins``, entry
318
+ # points) that a static read does not resolve, so then it stays unresolved.
319
+ # Leaving out ``skip`` removes one module at most, so past two none is chosen.
320
+ modules = self._plugins.get(name, {})
321
+ offered = [] if len(modules) > 2 else [
322
+ f for fs in modules.values()
323
+ # The module's last definition, the one its import binds.
324
+ if (f := next((g for g in reversed(fs) if g is not skip), None)) is not None
325
+ ]
326
+ best = offered[0] if len(offered) == 1 else None
327
+ if len(self._seen) >= code_static.MAX_CACHED:
328
+ self._seen.clear()
329
+ self._seen[key] = best
330
+ return self._seen[key]
331
+
332
+ def autouse_tests(self, fx: dict[str, Any]) -> list[tuple[str, dict[str, Any], str]]:
333
+ """The tests an autouse fixture can run for: below its conftest's folder, else in its
334
+ module, and only its class's methods (its nested and inheriting classes' too) when it
335
+ is defined in a class."""
336
+ if not fx.get("autouse"):
337
+ return []
338
+ folder, _, base = fx["file"].rpartition("/")
339
+ if base == "conftest.py":
340
+ tests = [
341
+ t for rel, ts in self.by_file.items()
342
+ if not folder or rel.startswith(f"{folder}/") for t in ts
343
+ ]
344
+ elif not fx["cls"]:
345
+ tests = self.by_file.get(fx["file"], [])
346
+ else:
347
+ tests = self.in_classes([(fx["file"], fx["cls"])], nested=True)
348
+ return [("autouse", t, _class_of(t)) for t in tests]
349
+
350
+ def in_classes(self, owners: list[tuple[str, str]], nested: bool) -> list[dict[str, Any]]:
351
+ """The tests in each ``(rel, cls)`` of ``owners`` and in every class inheriting from
352
+ one, and, with ``nested``, in the classes nested in those."""
353
+ every = [*owners, *(s for o in owners for s in self.classes.subclasses(*o))]
354
+ out = []
355
+ for rel, cls in dict.fromkeys(every):
356
+ out += [
357
+ t for t in self.by_file.get(rel, [])
358
+ if _class_of(t) == cls or nested and _class_of(t).startswith(f"{cls}::")
359
+ ]
360
+ return out
361
+
362
+
363
+ def _inherited_tests(
364
+ index: Index, classes: _Classes, tests: list[dict[str, Any]],
365
+ marks: list[dict[str, Any]] | None = None,
366
+ ) -> tuple[
367
+ list[dict[str, Any]], dict[tuple[str, str], list[dict[str, Any]]],
368
+ dict[tuple[str, str], dict[str, Any]],
369
+ ]:
370
+ """The test methods pytest collects in a test class that come from its bases, as test
371
+ records of that class, so their fixtures resolve there; and, for each base method, its
372
+ copies. A copy runs with the marks pytest gives it there: the method's own, the consuming
373
+ class's and each of its bases' (pytest merges class marks along the MRO), its enclosing
374
+ classes' and the consuming module's, not the base module's. A ``unittest.TestCase``
375
+ subclass without the ``Test`` prefix gets records for its own test methods the same way,
376
+ since pytest collects it whatever its name. The third result replaces the records of a test
377
+ class's own methods when a base class carries marks the fixture view (one file at a time)
378
+ could not apply."""
379
+ marked = {(m["file"], m["name"]): m for m in marks or []}
380
+ records: dict[tuple[str, str], dict[str, dict[str, Any]]] = {}
381
+ for t in tests:
382
+ cls, _, func = t["name"].rpartition("::")
383
+ if cls:
384
+ records.setdefault((t["file"], cls), {})[func] = t
385
+ out: list[dict[str, Any]] = []
386
+ copies: dict[tuple[str, str], list[dict[str, Any]]] = {}
387
+ adjusted: dict[tuple[str, str], dict[str, Any]] = {}
388
+
389
+ def class_marks(rel: str, cls: str, mro: list[tuple[str, str]]) -> tuple[set[str], set[str]]:
390
+ """The module's, the enclosing classes' and every class in the MRO's own marks."""
391
+ parts = cls.split("::")
392
+ scopes = [(rel, ""), *((rel, "::".join(parts[:i])) for i in range(1, len(parts)))]
393
+ use: set[str] = set()
394
+ direct: set[str] = set()
395
+ for key in [*scopes, *mro]:
396
+ found = marked.get(key)
397
+ if found is not None:
398
+ use.update(found["use"])
399
+ direct.update(found["direct"])
400
+ return use, direct
401
+
402
+ def record_for(
403
+ row: dict[str, Any], source: dict[str, Any] | None, own: tuple[str, str],
404
+ context: tuple[set[str], set[str]],
405
+ ) -> dict[str, Any]:
406
+ mine = marked.get(own)
407
+ use = context[0] | set(mine["use"] if mine else ())
408
+ supplied = context[1] | set(mine["direct"] if mine else ())
409
+ # A static method has no `self` to skip.
410
+ bound = 0 if row["kind"] == "staticmethod" else 1
411
+ pos = row["pos"][bound:len(row["pos"]) - row["defaults"]]
412
+ return {
413
+ "line": source["line"] if source else row["line"],
414
+ "argnames": [a for a in [*pos, *row["kwreq"]] if a not in supplied],
415
+ "usefixtures": sorted(use - supplied),
416
+ "dyn": source["dyn"] if source else [],
417
+ "parametrized": sorted(supplied),
418
+ }
419
+ found = index.classes_with_bases(MAX_SUBCLASSES + 1)
420
+ if len(found) > MAX_SUBCLASSES:
421
+ classes.cut = True
422
+ for rel, dotted in found[:MAX_SUBCLASSES]:
423
+ cls = dotted.replace(".", "::")
424
+ if not is_test_module(rel):
425
+ continue
426
+ named = cls.rpartition("::")[2].startswith("Test")
427
+ # pytest collects a unittest.TestCase subclass whatever its name, and the fixture view
428
+ # (which keeps Test* classes) has no record of its own methods.
429
+ if not named and not classes.is_unittest(rel, cls):
430
+ continue
431
+ mro = classes.mro(rel, cls)
432
+ context = class_marks(rel, cls, mro)
433
+ taken = set(classes.members(rel, cls)) if named else set()
434
+ if named and any(marked.get(b) for b in mro[1:]):
435
+ # The class's own methods: the fixture view applied this file's marks, not a base
436
+ # class's from the MRO.
437
+ for name, row in classes.members(rel, cls).items():
438
+ own_record = records.get((rel, cls), {}).get(name)
439
+ if own_record is not None and row["kind"] not in ("class", "variable"):
440
+ adjusted[(rel, own_record["name"])] = {
441
+ **own_record,
442
+ **record_for(row, own_record, (rel, f"{cls}::{name}"), context),
443
+ }
444
+ for base in mro:
445
+ if base == (rel, cls) and named:
446
+ continue
447
+ members = classes.members(*base)
448
+ for name, row in members.items():
449
+ if name in taken or not name.startswith("test") or row["kind"] in (
450
+ "class", "variable",
451
+ ):
452
+ continue
453
+ source = records.get(base, {}).get(name)
454
+ record = {
455
+ **record_for(row, source, (base[0], f"{base[1]}::{name}"), context),
456
+ "file": rel, "name": f"{cls}::{name}",
457
+ }
458
+ out.append(record)
459
+ copies.setdefault((base[0], f"{base[1]}::{name}"), []).append(record)
460
+ taken |= set(members)
461
+ return out, copies, adjusted
462
+
463
+
464
+ def impact(
465
+ index: Index, fixtures: dict[str, Any], targets: list[str], depth: int = DEFAULT_DEPTH,
466
+ ) -> dict[str, Any]:
467
+ """The tests that reach ``targets`` within ``depth`` levels of callers. ``fixtures`` is the
468
+ fixture view's data (its ``fixtures`` and ``tests``). Each test row names the function it
469
+ calls and the chain from there to a target (``via``); ``patched`` are the tests that patch a
470
+ target; ``counts`` holds what was followed and what could not be."""
471
+ depth = max(1, min(depth, MAX_DEPTH))
472
+ classes = _Classes(index)
473
+ own = list(fixtures.get("tests", []))
474
+ copied, copies, adjusted = _inherited_tests(index, classes, own, fixtures.get("marks"))
475
+ own = [adjusted.get((t["file"], t["name"]), t) for t in own]
476
+ fixtures = {**fixtures, "tests": own + copied}
477
+ tests_in = {(t["file"], t["name"]): t for t in own}
478
+ own_by_file: dict[str, list[dict[str, Any]]] = {}
479
+ for t in own:
480
+ own_by_file.setdefault(t["file"], []).append(t)
481
+ by_file = {rel: list(ts) for rel, ts in own_by_file.items()}
482
+ for t in copied:
483
+ by_file.setdefault(t["file"], []).append(t)
484
+ decorators = _Decorators(index, own_by_file)
485
+ fx = _Fixtures(fixtures, by_file, classes)
486
+ # Each followed name -> (the name it calls, "file:line" of that call); targets map to None.
487
+ parent: dict[str, tuple[str, str] | None] = dict.fromkeys(targets)
488
+ reached: dict[str, dict[str, Any]] = {}
489
+ patched: dict[str, dict[str, Any]] = {}
490
+ counts = dict.fromkeys(
491
+ ("functions", "beyond_depth", "module_level", "values", "by_name"), 0
492
+ )
493
+ budget = [code_static.MAX_EXAMINED]
494
+ truncated = False
495
+ expanded: set[tuple[Any, ...]] = set()
496
+ autoused_by_name: dict[str, list[tuple[str, dict[str, Any], str]]] = {}
497
+ work = [MAX_REQUESTS]
498
+
499
+ def constructed(name: str) -> list[str]:
500
+ """The classes whose call runs ``name`` when it is a constructor: its class, and each
501
+ subclass that inherits it unchanged; [] for anything else."""
502
+ owner, _, method = name.rpartition(".")
503
+ if method not in _CONSTRUCTORS:
504
+ return []
505
+ found_owner = index.definition(owner)
506
+ if found_owner is None or found_owner[1]["kind"] != "class":
507
+ return []
508
+ here = (found_owner[0], found_owner[1]["name"].replace(".", "::"))
509
+
510
+ def runs(rel: str, cls: str) -> bool:
511
+ # The `__init__` a call of `cls` runs, written or generated by `@dataclass`: it must
512
+ # be the target itself, and `__post_init__` runs only from a generated one.
513
+ if method == "__init__":
514
+ return classes.initializer(rel, cls) == (here, False)
515
+ if method == "__post_init__":
516
+ # The generated `__init__` calls the hook only if its class had one when
517
+ # `@dataclass` ran: a hook added by a subclass of it is never called.
518
+ found = classes.initializer(rel, cls)
519
+ return (
520
+ found is not None and found[1]
521
+ and classes.definer(*found[0], "__post_init__") is not None
522
+ )
523
+ return True
524
+
525
+ out = [owner] if runs(*here) else []
526
+ for sub_rel, sub in classes.subclasses(*here):
527
+ if classes.definer(sub_rel, sub, method) == here and runs(sub_rel, sub):
528
+ sub_module = code_static.module_name(sub_rel, index.src_package)
529
+ out.append(".".join(filter(None, (sub_module, sub.replace("::", ".")))))
530
+ return out
531
+
532
+ def chain(full: str) -> list[str]:
533
+ out = [full]
534
+ while (step := parent.get(out[-1])) is not None and len(out) <= MAX_DEPTH + 1:
535
+ out.append(step[0])
536
+ return out
537
+
538
+ def reach(test: dict[str, Any], via: list[str], how: str, level: int) -> None:
539
+ node = f"{test['file']}::{test['name']}"
540
+ if node not in reached or reached[node]["depth"] > level:
541
+ reached[node] = {
542
+ "test": node, "file": test["file"], "line": test["line"],
543
+ "depth": level, "how": how, "via": via,
544
+ }
545
+
546
+ def hooked(rel: str, cls: str, func: str) -> list[dict[str, Any]]:
547
+ """The tests a setup or teardown hook ``func`` (of class ``cls``, or of the module when
548
+ it is "") runs for; [] when it is not one. A class's hook runs for the tests of each
549
+ class that inherits it without overriding it, inherited test methods included."""
550
+ if cls and func in _CLASS_HOOKS | _UNITTEST_CLASS_HOOKS:
551
+ return [
552
+ t for t in fx.in_classes([(rel, cls)], nested=False)
553
+ if classes.definer(t["file"], _class_of(t), func) == (rel, cls)
554
+ and (func in _CLASS_HOOKS or classes.is_unittest(t["file"], _class_of(t)))
555
+ ]
556
+ if not cls and func in _MODULE_HOOKS:
557
+ return by_file.get(rel, [])
558
+ if not cls and func in _UNITTEST_MODULE_HOOKS:
559
+ return [
560
+ t for t in by_file.get(rel, [])
561
+ if "::" in t["name"] and classes.is_unittest(rel, _class_of(t))
562
+ ]
563
+ if not cls and func in _FUNCTION_HOOKS:
564
+ return [t for t in by_file.get(rel, []) if "::" not in t["name"]]
565
+ return []
566
+
567
+ def through_fixture(start: dict[str, Any], via: list[str], level: int) -> None:
568
+ """Every test that gets fixture ``start``: the requests of its name that pytest
569
+ resolves to this very definition, the requests of a fixture that does, and, for an
570
+ autouse fixture, every test it runs for. pytest resolves a fixture's own arguments for
571
+ the test that uses it, so each link of a chain is checked from that test (``needs``),
572
+ except a fixture requesting its own name, which gets the one it overrides."""
573
+ nonlocal truncated
574
+ todo: list[tuple[dict[str, Any], list[str], tuple[dict[str, Any], ...]]] = [
575
+ (start, via, ())
576
+ ]
577
+ while todo:
578
+ fixture, path, needs = todo.pop()
579
+ identity = (fixture["file"], fixture["cls"], fixture["func"], *map(id, needs))
580
+ if identity in expanded:
581
+ continue
582
+ if len(expanded) >= MAX_FIXTURES:
583
+ truncated = True
584
+ return
585
+ expanded.add(identity)
586
+ name = fixture["name"]
587
+ # pytest requests an autouse name for every test in the autouse fixture's reach,
588
+ # and the request resolves like any other: to this fixture where it overrides it.
589
+ # The reach depends on the name alone: built once, not for each fixture of a chain.
590
+ autoused = autoused_by_name.get(name)
591
+ if autoused is None:
592
+ autoused = autoused_by_name[name] = list({
593
+ (r["file"], r["name"]): (kind, r, cls)
594
+ for each in fx.named.get(name, []) if each.get("autouse")
595
+ for kind, r, cls in fx.autouse_tests(each)
596
+ }.values())
597
+ chain = (fixture, *needs)
598
+ # Whether the chain is what a test gets depends on its file and class alone: the
599
+ # tests of one class are checked once.
600
+ chain_visible: dict[tuple[str, str], bool] = {}
601
+ for kind, record, cls in [*autoused, *fx.requests.get(name, [])]:
602
+ work[0] -= 1
603
+ if work[0] < 0:
604
+ truncated = True
605
+ return
606
+ if kind == "fixture":
607
+ step = _step(f"fixture {record['name']}", path)
608
+ if record["name"] != name:
609
+ todo.append((record, step, (*needs, fixture)))
610
+ elif fx.visible(name, record["file"], cls, record) is fixture:
611
+ todo.append((record, step, needs))
612
+ continue
613
+ parametrized = record["parametrized"]
614
+ if parametrized and any(f["name"] in parametrized for f in chain):
615
+ continue
616
+ where = (record["file"], cls)
617
+ visible = chain_visible.get(where)
618
+ if visible is None:
619
+ visible = chain_visible[where] = all(
620
+ fx.visible(f["name"], record["file"], cls) is f for f in chain
621
+ )
622
+ if not visible:
623
+ continue
624
+ how = "autouse fixture" if kind == "autouse" else "fixture"
625
+ reach(record, path, f"{how} {name}", level)
626
+
627
+ # pytest runs a fixture or a setup hook without a call site, so a target that is one
628
+ # reaches its tests directly.
629
+ for target in targets:
630
+ found_def = index.definition(target)
631
+ if found_def is None:
632
+ continue
633
+ rel, row = found_def
634
+ cls, _, func = row["name"].replace(".", "::").rpartition("::")
635
+ fixture = fx.at.get((rel, cls, func))
636
+ if fixture is not None:
637
+ through_fixture(fixture, [target], 1)
638
+ continue
639
+ for t in hooked(rel, cls, func):
640
+ reach(t, [target], f"setup hook {func}", 1)
641
+
642
+ frontier = list(targets)
643
+ # A constructor named as the target runs wherever its class is called.
644
+ for target in targets:
645
+ for name in constructed(target):
646
+ if name not in parent:
647
+ parent[name] = (target, "")
648
+ frontier.append(name)
649
+ for level in range(1, depth + 1):
650
+ following: list[str] = []
651
+ for target in frontier:
652
+ found = code_static.references(index, [target], budget=budget)
653
+ truncated = truncated or found["truncated"]
654
+ counts["values"] += found["counts"]["uses"]
655
+ counts["by_name"] += found["counts"]["candidates"]
656
+ for p in found["patches"]:
657
+ if not p["test"]:
658
+ continue
659
+ qual = _qualname(index, p["file"], p["scope"])
660
+ names = (
661
+ [qual] if (p["file"], qual) in tests_in
662
+ else decorators.tests(p["file"], p["line"])
663
+ )
664
+ # The copies of a patched test method in inheriting classes are patched too.
665
+ for rel, name in [
666
+ (p["file"], n) for n in names
667
+ ] + [
668
+ (c["file"], c["name"]) for n in names for c in copies.get((p["file"], n), [])
669
+ ]:
670
+ node = f"{rel}::{name}"
671
+ patched.setdefault(
672
+ node, {"test": node, "file": rel, "line": p["line"], "target": target},
673
+ )
674
+ for call in found["calls"]:
675
+ caller = _caller(index, call["file"], call["scope"])
676
+ if not caller:
677
+ counts["module_level"] += 1
678
+ continue
679
+ where = f"{call['file']}:{call['line']}"
680
+ qual = _qualname(index, call["file"], call["scope"])
681
+ cls, _, func = qual.rpartition("::")
682
+ test = tests_in.get((call["file"], qual))
683
+ # A test method also runs as each copy pytest collects in an inheriting class,
684
+ # from a base pytest does not collect itself (``class Contract``) too.
685
+ inherited = copies.get((call["file"], qual), [])
686
+ for t in [test] if test is not None else []:
687
+ reach(t, chain(target), f"call at {where}", level)
688
+ for t in inherited:
689
+ reach(t, chain(target), f"call at {where}", level)
690
+ if test is not None or inherited:
691
+ continue
692
+ fixture = fx.at.get((call["file"], cls, func))
693
+ if fixture is not None:
694
+ through_fixture(
695
+ fixture, _step(f"fixture {fixture['name']}", chain(target)), level
696
+ )
697
+ continue
698
+ hook_tests = hooked(call["file"], cls, func)
699
+ for t in hook_tests:
700
+ reach(t, _step(caller, chain(target)), f"setup hook {func}", level)
701
+ if hook_tests:
702
+ continue
703
+ follow = [caller, *constructed(caller)]
704
+ for name in follow:
705
+ if name in parent:
706
+ continue
707
+ if len(parent) - len(targets) >= MAX_FOLLOWED:
708
+ truncated = True
709
+ continue
710
+ parent[name] = (target, where) if name == caller else (caller, where)
711
+ following.append(name)
712
+ counts["functions"] += len(following)
713
+ frontier = following
714
+ # Callers found at the last level whose own callers were not looked at.
715
+ counts["beyond_depth"] = len(frontier)
716
+ # A test that patches a target does not run the real one, whatever else it calls.
717
+ tests = sorted(
718
+ (r for node, r in reached.items() if node not in patched),
719
+ key=lambda r: (r["depth"], r["test"]),
720
+ )
721
+ return {
722
+ "tests": tests, "patched": sorted(patched.values(), key=lambda r: r["test"]),
723
+ "counts": counts, "depth": depth,
724
+ "truncated": truncated or budget[0] <= 0 or classes.cut,
725
+ }