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
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""Static pytest fixture facts from ``ast``: no imports, no pytest run, instant when warm.
|
|
2
|
+
|
|
3
|
+
Per file the pass records every fixture (scope, autouse, params, dependencies, location) and every
|
|
4
|
+
test function (the fixtures it requests). The facts are keyed by content hash in the cache, so an
|
|
5
|
+
edit re-parses one file. Overrides and dependents are resolved by name from those facts; they are a
|
|
6
|
+
static approximation of pytest's visibility rules (plugin and ``pytest_plugins`` fixtures are not
|
|
7
|
+
seen, and a fixture defined outside ``test_paths`` is not either).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import PurePosixPath
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from codeterrain.cache import Cache, ViewRecord
|
|
18
|
+
from codeterrain.config import Config
|
|
19
|
+
|
|
20
|
+
VIEW = "fixtures"
|
|
21
|
+
OBJECT_KIND = "static"
|
|
22
|
+
#: The shape of the view's rows: bump it when they change, so a signed view of the old shape is
|
|
23
|
+
#: rebuilt rather than read unchecked.
|
|
24
|
+
VIEW_FORMAT = "fixtures-1"
|
|
25
|
+
SCOPE_LETTER = {"session": "S", "package": "P", "module": "M", "class": "C", "function": "F"}
|
|
26
|
+
_SELF_NAMES = {"self", "cls"}
|
|
27
|
+
#: field -> expected type; "strs" is a list of strings, "int" excludes bool.
|
|
28
|
+
FIXTURE_FACT_TYPES = {
|
|
29
|
+
"name": str,
|
|
30
|
+
"func": str,
|
|
31
|
+
"scope": str,
|
|
32
|
+
"autouse": bool,
|
|
33
|
+
"params": "int",
|
|
34
|
+
"argnames": "strs",
|
|
35
|
+
"line": "int",
|
|
36
|
+
"cls": str,
|
|
37
|
+
"dyn": "strs",
|
|
38
|
+
}
|
|
39
|
+
TEST_FACT_TYPES = {
|
|
40
|
+
"name": str,
|
|
41
|
+
"line": "int",
|
|
42
|
+
"argnames": "strs",
|
|
43
|
+
"usefixtures": "strs",
|
|
44
|
+
"dyn": "strs",
|
|
45
|
+
"parametrized": "strs",
|
|
46
|
+
}
|
|
47
|
+
#: The marks written on one thing, not inherited: ``name`` is "" for the module (``pytestmark``),
|
|
48
|
+
#: a class (``Outer::Inner``: its decorators and body ``pytestmark``), or a test method of any
|
|
49
|
+
#: class (``Base::test_it``: its decorators). ``use`` are usefixtures names, ``direct`` the
|
|
50
|
+
#: directly parametrized ones. A test copied into a subclass is given its marks from these.
|
|
51
|
+
MARK_FACT_TYPES = {"name": str, "use": "strs", "direct": "strs"}
|
|
52
|
+
FIXTURE_FACT_KEYS = frozenset(FIXTURE_FACT_TYPES)
|
|
53
|
+
TEST_FACT_KEYS = frozenset(TEST_FACT_TYPES)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _dotted(node: ast.AST) -> str:
|
|
57
|
+
parts: list[str] = []
|
|
58
|
+
while isinstance(node, ast.Attribute):
|
|
59
|
+
parts.append(node.attr)
|
|
60
|
+
node = node.value
|
|
61
|
+
if isinstance(node, ast.Name):
|
|
62
|
+
parts.append(node.id)
|
|
63
|
+
return ".".join(reversed(parts))
|
|
64
|
+
return ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _call_parts(node: ast.expr) -> tuple[str, list[ast.expr], dict[str, ast.expr]]:
|
|
68
|
+
if isinstance(node, ast.Call):
|
|
69
|
+
kwargs = {k.arg: k.value for k in node.keywords if k.arg}
|
|
70
|
+
return _dotted(node.func), list(node.args), kwargs
|
|
71
|
+
return _dotted(node), [], {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _const(node: ast.expr | None) -> Any:
|
|
75
|
+
return node.value if isinstance(node, ast.Constant) else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fixture_marker(decorators: list[ast.expr]) -> tuple[ast.expr, dict[str, ast.expr]] | None:
|
|
79
|
+
for dec in decorators:
|
|
80
|
+
name, _, kwargs = _call_parts(dec)
|
|
81
|
+
if name.rsplit(".", 1)[-1] in {"fixture", "yield_fixture"}:
|
|
82
|
+
return dec, kwargs
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _argnames(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
|
|
87
|
+
"""Parameters pytest treats as fixture requests: no default, not self/cls."""
|
|
88
|
+
args = fn.args
|
|
89
|
+
positional = args.posonlyargs + args.args
|
|
90
|
+
without_default = positional[: len(positional) - len(args.defaults)]
|
|
91
|
+
keyword_only = [a for a, d in zip(args.kwonlyargs, args.kw_defaults, strict=True) if d is None]
|
|
92
|
+
return [a.arg for a in without_default + keyword_only if a.arg not in _SELF_NAMES]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _str_list(node: ast.expr | None) -> list[str]:
|
|
96
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
97
|
+
return [part.strip() for part in node.value.split(",") if part.strip()]
|
|
98
|
+
if isinstance(node, ast.List | ast.Tuple):
|
|
99
|
+
return [
|
|
100
|
+
e.value for e in node.elts if isinstance(e, ast.Constant) and isinstance(e.value, str)
|
|
101
|
+
]
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _marks(decorators: list[ast.expr]) -> tuple[list[str], list[str]]:
|
|
106
|
+
"""(usefixtures names, direct-parametrize names) from a decorator list."""
|
|
107
|
+
use: list[str] = []
|
|
108
|
+
direct: list[str] = []
|
|
109
|
+
for dec in decorators:
|
|
110
|
+
name, args, kwargs = _call_parts(dec)
|
|
111
|
+
if name.endswith("mark.usefixtures"):
|
|
112
|
+
for arg in args:
|
|
113
|
+
use.extend(_str_list(arg))
|
|
114
|
+
elif name.endswith("mark.parametrize"):
|
|
115
|
+
names = args[0] if args else kwargs.get("argnames")
|
|
116
|
+
if names is not None:
|
|
117
|
+
through = _indirect_names(args, kwargs, names)
|
|
118
|
+
direct.extend(n for n in _str_list(names) if n not in through)
|
|
119
|
+
return use, direct
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _indirect_names(
|
|
123
|
+
args: list[ast.expr], kwargs: dict[str, ast.expr], names: ast.expr
|
|
124
|
+
) -> set[str]:
|
|
125
|
+
"""Parametrized names that go through a fixture (``indirect=True`` or a list of names), given
|
|
126
|
+
as the keyword or as the third positional argument, as in
|
|
127
|
+
``parametrize(argnames, argvalues, indirect)``."""
|
|
128
|
+
indirect = args[2] if len(args) >= 3 else kwargs.get("indirect")
|
|
129
|
+
if indirect is None or _const(indirect) is False:
|
|
130
|
+
return set()
|
|
131
|
+
if isinstance(indirect, ast.List | ast.Tuple):
|
|
132
|
+
return set(_str_list(indirect))
|
|
133
|
+
return set(_str_list(names))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _pytestmark(body: list[ast.stmt]) -> tuple[list[str], list[str]]:
|
|
137
|
+
"""(usefixtures names, direct-parametrize names) from ``pytestmark = <mark | [marks]>``."""
|
|
138
|
+
use: list[str] = []
|
|
139
|
+
direct: list[str] = []
|
|
140
|
+
for stmt in body:
|
|
141
|
+
if isinstance(stmt, ast.Assign) and any(
|
|
142
|
+
isinstance(t, ast.Name) and t.id == "pytestmark" for t in stmt.targets
|
|
143
|
+
):
|
|
144
|
+
value = stmt.value
|
|
145
|
+
marks = value.elts if isinstance(value, ast.List | ast.Tuple) else [value]
|
|
146
|
+
found_use, found_direct = _marks(list(marks))
|
|
147
|
+
use.extend(found_use)
|
|
148
|
+
direct.extend(found_direct)
|
|
149
|
+
return use, direct
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _dynamic_requests(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
|
|
153
|
+
found: list[str] = []
|
|
154
|
+
for node in ast.walk(fn):
|
|
155
|
+
if (
|
|
156
|
+
isinstance(node, ast.Call)
|
|
157
|
+
and isinstance(node.func, ast.Attribute)
|
|
158
|
+
and node.func.attr in {"getfixturevalue", "getfuncargvalue"}
|
|
159
|
+
and node.args
|
|
160
|
+
and isinstance(_const(node.args[0]), str)
|
|
161
|
+
):
|
|
162
|
+
found.append(node.args[0].value)
|
|
163
|
+
return sorted(set(found))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _first_line(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
|
|
167
|
+
return min([fn.lineno, *(d.lineno for d in fn.decorator_list)])
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _fixture_fact(
|
|
171
|
+
fn: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
172
|
+
marker: tuple[ast.expr, dict[str, ast.expr]],
|
|
173
|
+
cls: str,
|
|
174
|
+
) -> dict[str, Any]:
|
|
175
|
+
kwargs = marker[1]
|
|
176
|
+
scope = _const(kwargs.get("scope")) if "scope" in kwargs else "function"
|
|
177
|
+
params = kwargs.get("params")
|
|
178
|
+
n_params = 0
|
|
179
|
+
if params is not None:
|
|
180
|
+
n_params = len(params.elts) if isinstance(params, ast.List | ast.Tuple) else -1
|
|
181
|
+
return {
|
|
182
|
+
"name": _const(kwargs.get("name")) or fn.name,
|
|
183
|
+
"func": fn.name,
|
|
184
|
+
"scope": scope if isinstance(scope, str) else "dynamic",
|
|
185
|
+
"autouse": _const(kwargs.get("autouse")) is True,
|
|
186
|
+
"params": n_params,
|
|
187
|
+
"argnames": _argnames(fn),
|
|
188
|
+
"line": _first_line(fn),
|
|
189
|
+
"cls": cls,
|
|
190
|
+
"dyn": _dynamic_requests(fn),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _test_fact(
|
|
195
|
+
fn: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
196
|
+
cls: str,
|
|
197
|
+
inherited_use: list[str],
|
|
198
|
+
inherited_direct: list[str],
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
use, direct = _marks(fn.decorator_list)
|
|
201
|
+
supplied = {*direct, *inherited_direct}
|
|
202
|
+
return {
|
|
203
|
+
"name": f"{cls}::{fn.name}" if cls else fn.name,
|
|
204
|
+
"line": _first_line(fn),
|
|
205
|
+
"argnames": [a for a in _argnames(fn) if a not in supplied],
|
|
206
|
+
# A directly parametrized name is supplied by the parameter, not the fixture, even when a
|
|
207
|
+
# usefixtures mark also names it.
|
|
208
|
+
"usefixtures": sorted(set(use + inherited_use) - supplied),
|
|
209
|
+
"dyn": _dynamic_requests(fn),
|
|
210
|
+
# pytest gives these names the parameter everywhere in the test's fixture closure, so a
|
|
211
|
+
# fixture of the same name never runs for it, not even through another fixture.
|
|
212
|
+
"parametrized": sorted(supplied),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def parse_source(source: str) -> dict[str, Any]:
|
|
217
|
+
"""Facts for one file's text: ``{"fixtures": [...], "tests": [...], "error": str|None}``."""
|
|
218
|
+
try:
|
|
219
|
+
tree = ast.parse(source)
|
|
220
|
+
except (SyntaxError, ValueError, RecursionError, MemoryError) as exc:
|
|
221
|
+
# A deep expression (a generated file, or a hostile one) exhausts the parser: that file
|
|
222
|
+
# becomes a recorded parse error and the rest of the repository still answers.
|
|
223
|
+
return {
|
|
224
|
+
"fixtures": [], "tests": [], "marks": [], "error": f"{type(exc).__name__}: {exc}"
|
|
225
|
+
}
|
|
226
|
+
fixtures: list[dict[str, Any]] = []
|
|
227
|
+
tests: list[dict[str, Any]] = []
|
|
228
|
+
marks: list[dict[str, Any]] = []
|
|
229
|
+
module_use, module_direct = _pytestmark(tree.body)
|
|
230
|
+
|
|
231
|
+
def mark(name: str, use: list[str], direct: list[str]) -> None:
|
|
232
|
+
if use or direct:
|
|
233
|
+
marks.append({"name": name, "use": sorted(set(use)), "direct": sorted(set(direct))})
|
|
234
|
+
|
|
235
|
+
mark("", module_use, module_direct)
|
|
236
|
+
|
|
237
|
+
def visit(
|
|
238
|
+
fn: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
239
|
+
cls: str,
|
|
240
|
+
class_use: list[str],
|
|
241
|
+
class_direct: list[str],
|
|
242
|
+
) -> None:
|
|
243
|
+
marker = _fixture_marker(fn.decorator_list)
|
|
244
|
+
if marker:
|
|
245
|
+
fixtures.append(_fixture_fact(fn, marker, cls))
|
|
246
|
+
elif fn.name.startswith("test"):
|
|
247
|
+
tests.append(
|
|
248
|
+
_test_fact(fn, cls, module_use + class_use, module_direct + class_direct)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def visit_class(
|
|
252
|
+
node: ast.ClassDef, outer: str, outer_use: list[str], outer_direct: list[str]
|
|
253
|
+
) -> None:
|
|
254
|
+
# Nested test classes are collected too (TestOuter::TestInner) and inherit the outer
|
|
255
|
+
# class's marks.
|
|
256
|
+
name = f"{outer}::{node.name}" if outer else node.name
|
|
257
|
+
decorated_use, decorated_direct = _marks(node.decorator_list)
|
|
258
|
+
body_use, body_direct = _pytestmark(node.body)
|
|
259
|
+
mark(name, decorated_use + body_use, decorated_direct + body_direct)
|
|
260
|
+
class_use = outer_use + decorated_use + body_use
|
|
261
|
+
class_direct = outer_direct + decorated_direct + body_direct
|
|
262
|
+
for member in node.body:
|
|
263
|
+
if isinstance(member, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
264
|
+
if member.name.startswith("test"):
|
|
265
|
+
# Kept for every class, collected or not: a subclass that inherits the
|
|
266
|
+
# method runs it with these marks.
|
|
267
|
+
mark(f"{name}::{member.name}", *_marks(member.decorator_list))
|
|
268
|
+
if node.name.startswith("Test") or _fixture_marker(member.decorator_list):
|
|
269
|
+
visit(member, name, class_use, class_direct)
|
|
270
|
+
elif (
|
|
271
|
+
isinstance(member, ast.ClassDef)
|
|
272
|
+
and node.name.startswith("Test")
|
|
273
|
+
and member.name.startswith("Test")
|
|
274
|
+
):
|
|
275
|
+
visit_class(member, name, class_use, class_direct)
|
|
276
|
+
|
|
277
|
+
for node in tree.body:
|
|
278
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
279
|
+
visit(node, "", [], [])
|
|
280
|
+
elif isinstance(node, ast.ClassDef):
|
|
281
|
+
visit_class(node, "", [], [])
|
|
282
|
+
return {"fixtures": fixtures, "tests": tests, "marks": marks, "error": None}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
#: How many top-level entries ``unlisted_test_trees`` looks at, and how many entries of each
|
|
286
|
+
#: directory: a listing, not a walk.
|
|
287
|
+
MAX_TREE_ENTRIES = 500
|
|
288
|
+
_NOT_TEST_TREES = frozenset({"node_modules", "venv", "site-packages", "build", "dist"})
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def unlisted_test_trees(config: Config) -> list[str]:
|
|
292
|
+
"""Top-level directories outside ``test_paths`` that hold a ``conftest.py`` or test modules
|
|
293
|
+
directly: a second test tree (a live suite with its own conftest chain) that the fixture
|
|
294
|
+
view does not read. Symbolic links and hidden directories are skipped."""
|
|
295
|
+
listed = [PurePosixPath(p) for p in config.test_paths]
|
|
296
|
+
if any(p in (PurePosixPath("."), PurePosixPath("")) for p in listed):
|
|
297
|
+
return []
|
|
298
|
+
out = []
|
|
299
|
+
try:
|
|
300
|
+
with os.scandir(config.root) as top:
|
|
301
|
+
entries = [e for _, e in zip(range(MAX_TREE_ENTRIES), top, strict=False)]
|
|
302
|
+
except OSError:
|
|
303
|
+
return []
|
|
304
|
+
for entry in sorted(entries, key=lambda e: e.name):
|
|
305
|
+
name = entry.name
|
|
306
|
+
if name.startswith((".", "_")) or name in _NOT_TEST_TREES:
|
|
307
|
+
continue
|
|
308
|
+
try:
|
|
309
|
+
if entry.is_symlink() or not entry.is_dir():
|
|
310
|
+
continue
|
|
311
|
+
except OSError:
|
|
312
|
+
continue
|
|
313
|
+
here = PurePosixPath(name)
|
|
314
|
+
# A test path inside it (`backend/tests`), or it inside a test path, is read already.
|
|
315
|
+
if any(p == here or here in p.parents or p in here.parents for p in listed):
|
|
316
|
+
continue
|
|
317
|
+
try:
|
|
318
|
+
with os.scandir(entry.path) as inner:
|
|
319
|
+
names = [e.name for _, e in zip(range(MAX_TREE_ENTRIES), inner, strict=False)]
|
|
320
|
+
except OSError:
|
|
321
|
+
continue
|
|
322
|
+
if "conftest.py" in names or any(is_test_module(n) for n in names):
|
|
323
|
+
out.append(name)
|
|
324
|
+
return out
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def is_test_module(rel: str) -> bool:
|
|
328
|
+
name = PurePosixPath(rel).name
|
|
329
|
+
return (name.startswith("test_") or name.endswith("_test.py")) and name.endswith(".py")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _directory(rel: str) -> PurePosixPath:
|
|
333
|
+
return PurePosixPath(rel).parent
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _resolve_overrides(fixtures: list[dict[str, Any]]) -> None:
|
|
337
|
+
"""Point each fixture at the nearest same-named conftest fixture it shadows, if any."""
|
|
338
|
+
conftests: dict[str, list[dict[str, Any]]] = {}
|
|
339
|
+
for fx in fixtures:
|
|
340
|
+
if PurePosixPath(fx["file"]).name == "conftest.py":
|
|
341
|
+
conftests.setdefault(fx["name"], []).append(fx)
|
|
342
|
+
for fx in fixtures:
|
|
343
|
+
here = _directory(fx["file"])
|
|
344
|
+
best: dict[str, Any] | None = None
|
|
345
|
+
for cand in conftests.get(fx["name"], []):
|
|
346
|
+
if cand["file"] == fx["file"]:
|
|
347
|
+
continue
|
|
348
|
+
there = _directory(cand["file"])
|
|
349
|
+
if there == here or there in here.parents:
|
|
350
|
+
if best is None or len(_directory(best["file"]).parts) < len(there.parts):
|
|
351
|
+
best = cand
|
|
352
|
+
fx["overrides"] = f"{best['file']}:{best['line']}" if best else ""
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _typed(entry: Any, types: dict[str, Any]) -> bool:
|
|
356
|
+
if not isinstance(entry, dict):
|
|
357
|
+
return False
|
|
358
|
+
for field, kind in types.items():
|
|
359
|
+
value = entry.get(field)
|
|
360
|
+
if kind == "int":
|
|
361
|
+
ok = isinstance(value, int) and not isinstance(value, bool)
|
|
362
|
+
elif kind == "strs":
|
|
363
|
+
ok = isinstance(value, list) and all(isinstance(v, str) for v in value)
|
|
364
|
+
else:
|
|
365
|
+
ok = isinstance(value, kind)
|
|
366
|
+
if not ok:
|
|
367
|
+
return False
|
|
368
|
+
return True
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _valid_facts(facts: Any) -> bool:
|
|
372
|
+
"""A cached per-file entry has every field this version reads, of the right type (anything
|
|
373
|
+
else is re-parsed)."""
|
|
374
|
+
return (
|
|
375
|
+
isinstance(facts, dict)
|
|
376
|
+
and (facts.get("error") is None or isinstance(facts.get("error"), str))
|
|
377
|
+
and "error" in facts
|
|
378
|
+
and isinstance(facts.get("fixtures"), list)
|
|
379
|
+
and isinstance(facts.get("tests"), list)
|
|
380
|
+
and isinstance(facts.get("marks"), list)
|
|
381
|
+
and all(_typed(f, FIXTURE_FACT_TYPES) for f in facts["fixtures"])
|
|
382
|
+
and all(_typed(t, TEST_FACT_TYPES) for t in facts["tests"])
|
|
383
|
+
and all(_typed(m, MARK_FACT_TYPES) for m in facts["marks"])
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _valid_view(data: Any) -> bool:
|
|
388
|
+
return (
|
|
389
|
+
isinstance(data, dict)
|
|
390
|
+
and data.get("format") == VIEW_FORMAT
|
|
391
|
+
and all(isinstance(data.get(k), list) for k in ("fixtures", "tests", "marks", "errors"))
|
|
392
|
+
and all(_typed(f, {**FIXTURE_FACT_TYPES, "file": str, "overrides": str})
|
|
393
|
+
for f in data["fixtures"])
|
|
394
|
+
and all(_typed(t, {**TEST_FACT_TYPES, "file": str}) for t in data["tests"])
|
|
395
|
+
and all(_typed(m, {**MARK_FACT_TYPES, "file": str}) for m in data["marks"])
|
|
396
|
+
and all(_typed(e, {"file": str, "error": str}) for e in data["errors"])
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def build(cache: Cache, config: Config, force: bool = False) -> tuple[ViewRecord, bool]:
|
|
401
|
+
"""The ``fixtures`` view, rebuilt incrementally when any test file changed.
|
|
402
|
+
|
|
403
|
+
Returns (record, rebuilt).
|
|
404
|
+
"""
|
|
405
|
+
fingerprint = cache.scan(config.test_paths)
|
|
406
|
+
record = cache.read_view(VIEW)
|
|
407
|
+
if (
|
|
408
|
+
record is not None
|
|
409
|
+
and not force
|
|
410
|
+
and record.fingerprint == fingerprint
|
|
411
|
+
and (record := cache.served(record, VIEW_FORMAT, _valid_view)) is not None
|
|
412
|
+
):
|
|
413
|
+
cache.flush()
|
|
414
|
+
return record, False
|
|
415
|
+
objects = cache.load_objects(OBJECT_KIND)
|
|
416
|
+
added = False
|
|
417
|
+
for rel, sha in fingerprint.items():
|
|
418
|
+
if not _valid_facts(objects.get(sha)):
|
|
419
|
+
text = (cache.root / rel).read_bytes().decode("utf-8", errors="replace")
|
|
420
|
+
objects[sha] = parse_source(text)
|
|
421
|
+
added = True
|
|
422
|
+
if added:
|
|
423
|
+
cache.save_objects(OBJECT_KIND, objects)
|
|
424
|
+
fixtures: list[dict[str, Any]] = []
|
|
425
|
+
tests: list[dict[str, Any]] = []
|
|
426
|
+
marks: list[dict[str, Any]] = []
|
|
427
|
+
errors: list[dict[str, str]] = []
|
|
428
|
+
for rel in sorted(fingerprint):
|
|
429
|
+
facts = objects[fingerprint[rel]]
|
|
430
|
+
if facts["error"]:
|
|
431
|
+
errors.append({"file": rel, "error": facts["error"]})
|
|
432
|
+
fixtures.extend({**fx, "file": rel} for fx in facts["fixtures"])
|
|
433
|
+
# Marks of every file: a test class inherits from bases in other modules.
|
|
434
|
+
marks.extend({**m, "file": rel} for m in facts["marks"])
|
|
435
|
+
if is_test_module(rel):
|
|
436
|
+
tests.extend({**t, "file": rel} for t in facts["tests"])
|
|
437
|
+
_resolve_overrides(fixtures)
|
|
438
|
+
fixtures.sort(key=lambda f: (f["name"], f["file"], f["line"]))
|
|
439
|
+
record = cache.write_view(
|
|
440
|
+
VIEW,
|
|
441
|
+
{
|
|
442
|
+
"format": VIEW_FORMAT, "fixtures": fixtures, "tests": tests, "marks": marks,
|
|
443
|
+
"errors": errors,
|
|
444
|
+
},
|
|
445
|
+
fingerprint,
|
|
446
|
+
sign=True, # built from checked facts: a warm read need not check every row again
|
|
447
|
+
)
|
|
448
|
+
cache.flush()
|
|
449
|
+
return record, True
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def scope_letter(scope: str) -> str:
|
|
453
|
+
return SCOPE_LETTER.get(scope, "?")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def normalise_scope(text: str) -> str:
|
|
457
|
+
"""``--scope`` accepts a scope word or its S/P/M/C/F letter."""
|
|
458
|
+
lowered = text.lower()
|
|
459
|
+
for word, letter in SCOPE_LETTER.items():
|
|
460
|
+
if lowered in (word, letter.lower()):
|
|
461
|
+
return word
|
|
462
|
+
raise ValueError(f"unknown scope {text!r}; use session, package, module, class or function")
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def select(
|
|
466
|
+
data: dict[str, Any], substring: str = "", autouse: bool = False, scope: str | None = None
|
|
467
|
+
) -> list[dict[str, Any]]:
|
|
468
|
+
needle = substring.lower()
|
|
469
|
+
return [
|
|
470
|
+
fx
|
|
471
|
+
for fx in data["fixtures"]
|
|
472
|
+
if needle in fx["name"].lower()
|
|
473
|
+
and (not autouse or fx["autouse"])
|
|
474
|
+
and (scope is None or fx["scope"] == scope)
|
|
475
|
+
]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def dependents(data: dict[str, Any], name: str) -> list[dict[str, Any]]:
|
|
479
|
+
"""Fixtures and tests that request ``name`` (by argument, usefixtures or getfixturevalue)."""
|
|
480
|
+
out: list[dict[str, Any]] = []
|
|
481
|
+
for fx in data["fixtures"]:
|
|
482
|
+
how = _how(name, fx["argnames"], [], fx["dyn"])
|
|
483
|
+
if how:
|
|
484
|
+
out.append({"kind": "fixture", "name": fx["name"], "file": fx["file"],
|
|
485
|
+
"line": fx["line"], "via": how})
|
|
486
|
+
for test in data["tests"]:
|
|
487
|
+
how = _how(name, test["argnames"], test["usefixtures"], test["dyn"])
|
|
488
|
+
if how:
|
|
489
|
+
out.append({"kind": "test", "name": test["name"], "file": test["file"],
|
|
490
|
+
"line": test["line"], "via": how})
|
|
491
|
+
return out
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _how(name: str, args: list[str], use: list[str], dyn: list[str]) -> str:
|
|
495
|
+
if name in args:
|
|
496
|
+
return "arg"
|
|
497
|
+
if name in use:
|
|
498
|
+
return "usefixtures"
|
|
499
|
+
if name in dyn:
|
|
500
|
+
return "getfixturevalue"
|
|
501
|
+
return ""
|
codeterrain/hookspecs.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""pluggy hookspecs. Plugins run inside the target runtime, never in the core process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pluggy
|
|
8
|
+
|
|
9
|
+
PROJECT = "codeterrain"
|
|
10
|
+
hookspec = pluggy.HookspecMarker(PROJECT)
|
|
11
|
+
hookimpl = pluggy.HookimplMarker(PROJECT)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@hookspec(firstresult=True)
|
|
15
|
+
def iter_routes(app: Any) -> list[dict[str, Any]] | None:
|
|
16
|
+
"""Return the routes of ``app`` as dicts, or None when this plugin does not know the app.
|
|
17
|
+
|
|
18
|
+
Each dict carries ``method``, ``path``, ``handler`` (qualified name), ``file`` (absolute),
|
|
19
|
+
``line``, ``dependencies`` (list of names) and ``response_model`` (name or None).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@hookspec
|
|
24
|
+
def tests_for(file: str) -> list[str] | None:
|
|
25
|
+
"""Seam only in v0.1: the test node ids that exercise ``file``. Nothing calls it yet."""
|