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/__init__.py +3 -0
- codeterrain/_guard.py +243 -0
- codeterrain/_pytest_dump_plugin.py +253 -0
- codeterrain/_routes_runner.py +119 -0
- codeterrain/budget.py +128 -0
- codeterrain/cache.py +618 -0
- codeterrain/calls_join.py +416 -0
- codeterrain/calls_static.py +1099 -0
- codeterrain/cli.py +1447 -0
- codeterrain/code_static.py +2411 -0
- codeterrain/config.py +331 -0
- codeterrain/executables.py +130 -0
- codeterrain/fixtures_order.py +77 -0
- codeterrain/fixtures_static.py +501 -0
- codeterrain/hookspecs.py +25 -0
- codeterrain/impact.py +725 -0
- codeterrain/languages.py +293 -0
- codeterrain/plugins/__init__.py +0 -0
- codeterrain/plugins/fastapi_routes.py +193 -0
- codeterrain/plugins/manifest_join.py +107 -0
- codeterrain/protocol.py +138 -0
- codeterrain/routes_view.py +61 -0
- codeterrain/sandbox.py +565 -0
- codeterrain/views.py +198 -0
- codeterrain-0.3.0.dist-info/METADATA +295 -0
- codeterrain-0.3.0.dist-info/RECORD +30 -0
- codeterrain-0.3.0.dist-info/WHEEL +4 -0
- codeterrain-0.3.0.dist-info/entry_points.txt +2 -0
- codeterrain-0.3.0.dist-info/licenses/LICENSE +201 -0
- codeterrain-0.3.0.dist-info/licenses/NOTICE +4 -0
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
|
+
}
|