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.
@@ -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 ""
@@ -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."""