diffcone 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,494 @@
1
+ """Static ASV (airspeed velocity) benchmark discovery.
2
+
3
+ Reproduces ASV's collection rules without importing benchmark code:
4
+
5
+ * ``benchmark_dir`` from ``asv.conf.json`` (the shallowest one, at the
6
+ repository root or up to three levels below; default ``benchmarks``),
7
+ relative to the directory holding it; every ``.py`` file below it,
8
+ underscore modules included (ASV walks the package with ``pkgutil``),
9
+ named relative to that directory with dots, the package's own
10
+ ``__init__`` with no module prefix at all;
11
+ * every public attribute of such a module, as ASV reads ``module.__dict__``:
12
+ functions and classes defined there, imported there (by name or with a
13
+ star) from a module in the source roots, named after the importing module
14
+ and the object's own name, and attribute aliases in a class body
15
+ (``time_alias = time_a``);
16
+ * functions and methods whose names match ASV's patterns: ``time_``,
17
+ ``timeraw_``, ``mem_``, ``peakmem_`` or ``track_`` prefixes, or the
18
+ CamelCase ``Time``, ``Timeraw``, ``Mem``, ``PeakMem`` and ``Track``
19
+ followed by a capital or an underscore;
20
+ * benchmark name ``<module>.<Class>.<method>`` or ``<module>.<function>``.
21
+
22
+ Lifecycle dependencies: the class's ``setup``, ``setup_cache`` and
23
+ ``teardown`` methods, the module's ``setup``, ``setup_cache`` and
24
+ ``teardown`` functions, and the module itself (module-level attributes such
25
+ as ``timeout`` or ``params``). Class attributes reach the benchmarks through
26
+ the class body, which the planner treats as structural.
27
+
28
+ Benchmarks inherited from base classes count: ASV reads a class's
29
+ attributes including inherited ones. A base defined in the same module, or
30
+ imported from any module in the source roots (a shared base often lives in
31
+ the package under test), is followed, with the subclass's own definitions
32
+ winning; one that resolves nowhere is an ``unknown_base_class`` note.
33
+
34
+ Also: an attribute bound to a function (``time_alias = _impl``) is a
35
+ benchmark when the attribute's name matches, named after the function; a
36
+ literal ``benchmark_name`` set on a function replaces its name (and its last
37
+ part is what is matched); the module's ``setup``/``teardown`` count when
38
+ imported (``from .common import setup``) too; a ``timeraw_`` benchmark
39
+ depends (``dynamic:``) on the in-scope modules its returned code imports,
40
+ and on an unknown (always selected) when that code is not a literal string.
41
+ Benchmark files under ``benchmark_dir`` but outside the source roots are
42
+ reported (``test_file_outside_roots``).
43
+
44
+ Not modelled: ``params`` expansion (a benchmark is one target).
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import ast
50
+ import json
51
+ import posixpath
52
+ import re
53
+ import textwrap
54
+ from pathlib import PurePosixPath
55
+ from typing import Any
56
+
57
+ from diffcone.discovery import DiscoveryNote, DiscoveryOptions, DiscoveryResult
58
+ from diffcone.discovery.common import (
59
+ ParsedModule,
60
+ decorator_chain,
61
+ parse_modules,
62
+ scope_classes,
63
+ scope_functions,
64
+ )
65
+ from diffcone.indexer import resolve_relative_module
66
+ from diffcone.manifest import Target
67
+ from diffcone.model import SourceIndex
68
+ from diffcone.snapshot import Snapshot, module_name_for
69
+
70
+ RUNNER = "asv"
71
+ PREFIXES = ("time_", "timeraw_", "mem_", "peakmem_", "track_")
72
+ # asv_runner's ``name_regex`` of each benchmark type, combined.
73
+ BENCHMARK_NAME = re.compile(
74
+ r"^(?:(?:Time|Timeraw|Mem|PeakMem|Track)[A-Z_].+|(?:time|timeraw|mem|peakmem|track)_.+)$"
75
+ )
76
+ LIFECYCLE_NAMES = ("setup", "setup_cache", "teardown")
77
+
78
+
79
+ def read_asv_config(snapshot: Snapshot) -> dict[str, Any]:
80
+ """``benchmark_dir`` as a repository-relative path. ASV resolves it
81
+ against the directory holding ``asv.conf.json``, which is usually not the
82
+ repository root (numpy and networkx keep both under ``benchmarks/``,
83
+ pandas under ``asv_bench/``); the snapshot reads those nested copies, and
84
+ the shallowest one wins as ASV's own search does."""
85
+ config: dict[str, Any] = {"source": None, "benchmark_dir": "benchmarks"}
86
+ name = next(
87
+ (n for n in snapshot.config_files if PurePosixPath(n).name == "asv.conf.json"), None
88
+ )
89
+ if name is None:
90
+ return config
91
+ here = PurePosixPath(name).parent
92
+ text = snapshot.config_files[name].decode("utf-8", "replace")
93
+ try:
94
+ data = json.loads(strip_json_comments(text))
95
+ except json.JSONDecodeError as exc:
96
+ config["source"] = f"{name} (unparsable, defaults used)"
97
+ config["error"] = str(exc)
98
+ return config
99
+ if not isinstance(data, dict):
100
+ config["source"] = f"{name} (not an object, defaults used)"
101
+ config["error"] = "the configuration is not a JSON object"
102
+ return config
103
+ config["source"] = name
104
+ bench_dir = data.get("benchmark_dir")
105
+ if isinstance(bench_dir, str) and bench_dir.strip():
106
+ # ``"../benchmarks"`` beside a nested config: normalised, so it is
107
+ # compared with repository paths as they are.
108
+ resolved = posixpath.normpath((here / bench_dir.strip().rstrip("/")).as_posix())
109
+ config["benchmark_dir"] = resolved.removeprefix("./").strip("/")
110
+ elif str(here) != ".":
111
+ # No benchmark_dir: ASV's default is ``benchmarks`` beside the config.
112
+ config["benchmark_dir"] = (here / "benchmarks").as_posix()
113
+ return config
114
+
115
+
116
+ def strip_json_comments(text: str) -> str:
117
+ """Remove ``//`` and ``/* */`` comments outside string literals, as asv's
118
+ configuration loader allows."""
119
+ out: list[str] = []
120
+ i, n = 0, len(text)
121
+ in_string = False
122
+ while i < n:
123
+ ch = text[i]
124
+ if in_string:
125
+ out.append(ch)
126
+ if ch == "\\" and i + 1 < n:
127
+ out.append(text[i + 1])
128
+ i += 1
129
+ elif ch == '"':
130
+ in_string = False
131
+ elif ch == '"':
132
+ in_string = True
133
+ out.append(ch)
134
+ elif text.startswith("//", i):
135
+ while i < n and text[i] != "\n":
136
+ i += 1
137
+ continue
138
+ elif text.startswith("/*", i):
139
+ end = text.find("*/", i + 2)
140
+ i = n if end < 0 else end + 2
141
+ continue
142
+ else:
143
+ out.append(ch)
144
+ i += 1
145
+ return "".join(out)
146
+
147
+
148
+ def _is_benchmark(name: str) -> bool:
149
+ return bool(BENCHMARK_NAME.match(name))
150
+
151
+
152
+ def _module_paths(snapshot: Snapshot) -> dict[str, str]:
153
+ """Module name -> path for every ``.py`` file in the source roots: how a
154
+ base class imported from the package under test is found."""
155
+ out: dict[str, str] = {}
156
+ for path in snapshot.files:
157
+ if not path.endswith(".py"):
158
+ continue
159
+ name = module_name_for(path, snapshot.source_roots)
160
+ if name is not None:
161
+ out.setdefault(name, path)
162
+ return out
163
+
164
+
165
+ def _import_source(pm: ParsedModule, node: ast.ImportFrom) -> str:
166
+ """The absolute module ``node`` imports from, a relative import resolved
167
+ against ``pm``'s package (``pm`` itself when it is a package's
168
+ ``__init__``)."""
169
+ if not node.level:
170
+ return node.module or ""
171
+ is_package = pm.path.endswith("/__init__.py")
172
+ return resolve_relative_module(pm.module, is_package, node.module, node.level)
173
+
174
+
175
+ def _imported_names(pm: ParsedModule) -> dict[str, tuple[str, str]]:
176
+ """Bound name -> (module, original name) for ``from x import y`` at the
177
+ top level of ``pm``."""
178
+ out: dict[str, tuple[str, str]] = {}
179
+ for node in pm.tree.body:
180
+ if not isinstance(node, ast.ImportFrom):
181
+ continue
182
+ source = _import_source(pm, node)
183
+ for alias in node.names:
184
+ if alias.name != "*":
185
+ out[alias.asname or alias.name] = (source, alias.name)
186
+ return out
187
+
188
+
189
+ def _class_methods(cls: ast.ClassDef, class_id: str, methods: dict[str, str]) -> None:
190
+ """A class's functions by attribute name, aliases included
191
+ (``time_alias = time_a`` is a member ``inspect.getmembers`` returns)."""
192
+ defs = {f.name: f for f in scope_functions(cls.body)}
193
+ for name in defs:
194
+ methods[name] = f"{class_id}.{name}"
195
+ for stmt in cls.body:
196
+ if (
197
+ isinstance(stmt, ast.Assign)
198
+ and len(stmt.targets) == 1
199
+ and isinstance(stmt.targets[0], ast.Name)
200
+ and isinstance(stmt.value, ast.Name)
201
+ and stmt.value.id in defs
202
+ ):
203
+ methods[stmt.targets[0].id] = f"{class_id}.{stmt.value.id}"
204
+
205
+
206
+ def _bind_def(
207
+ origin: ParsedModule,
208
+ original: str,
209
+ bound: str,
210
+ functions: dict[str, tuple[Any, ParsedModule]],
211
+ classes: dict[str, tuple[ast.ClassDef, ParsedModule]],
212
+ ) -> None:
213
+ for node in origin.tree.body:
214
+ if isinstance(node, ast.ClassDef) and node.name == original:
215
+ classes[bound] = (node, origin)
216
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == original:
217
+ functions[bound] = (node, origin)
218
+
219
+
220
+ def _public_defs(pm: ParsedModule) -> list[str]:
221
+ """What ``from m import *`` binds of ``m``'s definitions."""
222
+ for stmt in pm.tree.body:
223
+ if isinstance(stmt, ast.Assign) and any(
224
+ isinstance(t, ast.Name) and t.id == "__all__" for t in stmt.targets
225
+ ):
226
+ if isinstance(stmt.value, (ast.List, ast.Tuple)):
227
+ return [
228
+ e.value
229
+ for e in stmt.value.elts
230
+ if isinstance(e, ast.Constant) and isinstance(e.value, str)
231
+ ]
232
+ return [
233
+ n.name
234
+ for n in pm.tree.body
235
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
236
+ and not n.name.startswith("_")
237
+ ]
238
+
239
+
240
+ def _benchmark_names(body: list[ast.stmt]) -> dict[str, str]:
241
+ """``time_x.benchmark_name = "custom.name"`` in a module or class body:
242
+ function name -> the name ASV gives the benchmark instead."""
243
+ out: dict[str, str] = {}
244
+ for stmt in body:
245
+ if (
246
+ isinstance(stmt, ast.Assign)
247
+ and len(stmt.targets) == 1
248
+ and isinstance(stmt.targets[0], ast.Attribute)
249
+ and stmt.targets[0].attr == "benchmark_name"
250
+ and isinstance(stmt.targets[0].value, ast.Name)
251
+ and isinstance(stmt.value, ast.Constant)
252
+ and isinstance(stmt.value.value, str)
253
+ ):
254
+ out[stmt.targets[0].value.id] = stmt.value.value
255
+ return out
256
+
257
+
258
+ # A ``timeraw_`` benchmark whose code could not be read: an unknown
259
+ # dependency, so the benchmark is always selected.
260
+ TIMERAW_UNANALYSED = "timeraw:unanalysed"
261
+
262
+
263
+ def _timeraw_deps(func: ast.FunctionDef | ast.AsyncFunctionDef, index: SourceIndex) -> list[str]:
264
+ """What a ``timeraw_`` benchmark runs: it returns code (a string, or a
265
+ pair of code and setup strings) that ASV runs in a fresh interpreter, so
266
+ its dependencies are what that code imports (``dynamic:<module>`` for
267
+ each in-scope module it names). Code that is not a literal cannot be
268
+ read: TIMERAW_UNANALYSED."""
269
+ if not func.name.lower().startswith("timeraw"):
270
+ return []
271
+ deps: list[str] = []
272
+ for node in ast.walk(func):
273
+ if not isinstance(node, ast.Return) or node.value is None:
274
+ continue
275
+ values = list(node.value.elts) if isinstance(node.value, ast.Tuple) else [node.value]
276
+ for value in values:
277
+ if isinstance(value, ast.Call) and value.args: # ``textwrap.dedent("...")``
278
+ value = value.args[0]
279
+ if not (isinstance(value, ast.Constant) and isinstance(value.value, str)):
280
+ return [TIMERAW_UNANALYSED]
281
+ try:
282
+ code = ast.parse(textwrap.dedent(value.value))
283
+ except SyntaxError:
284
+ return [TIMERAW_UNANALYSED]
285
+ for inner in ast.walk(code):
286
+ names = (
287
+ [a.name for a in inner.names]
288
+ if isinstance(inner, ast.Import)
289
+ else [inner.module or ""]
290
+ if isinstance(inner, ast.ImportFrom) and not inner.level
291
+ else []
292
+ )
293
+ for name in names:
294
+ parts = name.split(".")
295
+ for i in range(len(parts), 0, -1):
296
+ if ".".join(parts[:i]) in index.modules:
297
+ deps.append(f"dynamic:{'.'.join(parts[:i])}")
298
+ break
299
+ return deps
300
+
301
+
302
+ def discover_asv(
303
+ snapshot: Snapshot, index: SourceIndex, options: DiscoveryOptions
304
+ ) -> DiscoveryResult:
305
+ result = DiscoveryResult(runner=RUNNER)
306
+ config = read_asv_config(snapshot)
307
+ result.config = dict(config)
308
+ if "error" in config:
309
+ result.notes.append(
310
+ DiscoveryNote(
311
+ RUNNER,
312
+ "unparsable_config",
313
+ f"asv.conf.json could not be parsed ({config['error']}); "
314
+ f"using benchmark_dir {config['benchmark_dir']!r}",
315
+ )
316
+ )
317
+ bench_dir = config["benchmark_dir"]
318
+ prefix = bench_dir + "/"
319
+ paths = [p for p in snapshot.files if p.startswith(prefix) and p.endswith(".py")]
320
+ outside = [p for p in snapshot.python_paths if p.startswith(prefix) and p not in snapshot.files]
321
+ if outside:
322
+ result.notes.append(
323
+ DiscoveryNote(
324
+ RUNNER,
325
+ "test_file_outside_roots",
326
+ f"{len(outside)} benchmark file(s) under {bench_dir!r} are outside the source "
327
+ f"roots (first: {outside[0]}), so their benchmarks are not targets; add a source "
328
+ "root that contains them",
329
+ outside[0],
330
+ )
331
+ )
332
+ if not paths:
333
+ result.notes.append(
334
+ DiscoveryNote(RUNNER, "no_benchmarks", f"no .py files under {bench_dir!r}")
335
+ )
336
+ return result
337
+ parsed, failed = parse_modules(snapshot, paths)
338
+ module_paths = _module_paths(snapshot)
339
+ # Modules reached through a base class, parsed on demand and cached.
340
+ extra: dict[str, ParsedModule | None] = {pm.module: pm for pm in parsed}
341
+
342
+ def module_for(name: str) -> ParsedModule | None:
343
+ if name not in extra:
344
+ path = module_paths.get(name)
345
+ found, _ = parse_modules(snapshot, [path]) if path else ([], [])
346
+ extra[name] = found[0] if found else None
347
+ return extra[name]
348
+
349
+ def bases_of(
350
+ cls: ast.ClassDef, pm: ParsedModule, runner_id: str
351
+ ) -> list[tuple[ast.ClassDef, ParsedModule]]:
352
+ """Base classes of ``cls``, nearest first, each with the module that
353
+ defines it; a base that resolves nowhere is reported."""
354
+ chain: list[tuple[ast.ClassDef, ParsedModule]] = []
355
+ seen: set[tuple[str, str]] = set()
356
+ queue = [(base, pm) for base in cls.bases]
357
+ while queue:
358
+ node, owner = queue.pop(0)
359
+ parts, _ = decorator_chain(node)
360
+ name = parts[-1] if parts else ""
361
+ if name in ("object", "") or (owner.module, name) in seen:
362
+ continue
363
+ seen.add((owner.module, name))
364
+ here = {c.name: c for c in scope_classes(owner.tree.body)}
365
+ found_cls, found_mod = here.get(name), owner
366
+ if found_cls is None:
367
+ target = _imported_names(owner).get(name)
368
+ source = module_for(target[0]) if target is not None else None
369
+ if target is not None and source is not None:
370
+ found_cls = {c.name: c for c in scope_classes(source.tree.body)}.get(target[1])
371
+ found_mod = source
372
+ if found_cls is None:
373
+ result.notes.append(
374
+ DiscoveryNote(
375
+ RUNNER,
376
+ "unknown_base_class",
377
+ f"{runner_id}: base class {name!r} is not defined in this module or "
378
+ "imported from one in the source roots; benchmarks it may contribute "
379
+ "are not discovered",
380
+ )
381
+ )
382
+ continue
383
+ chain.append((found_cls, found_mod))
384
+ queue.extend((b, found_mod) for b in found_cls.bases)
385
+ return chain
386
+
387
+ for path in failed:
388
+ result.notes.append(
389
+ DiscoveryNote(RUNNER, "unparsed_file", f"{path}: not parsed or outside source roots")
390
+ )
391
+
392
+ def qualified(*names: str) -> str:
393
+ return ".".join(n for n in names if n)
394
+
395
+ added: set[str] = set()
396
+ for pm in parsed:
397
+ rel = PurePosixPath(pm.path[len(prefix) :]).with_suffix("")
398
+ parts = [p for p in rel.parts if p != "__init__"]
399
+ # The package's own __init__ has no module prefix in ASV's names.
400
+ bench_module = ".".join(parts)
401
+ body = pm.tree.body
402
+
403
+ def add(runner_id: str, entry: str, deps: list[str]) -> None:
404
+ if runner_id in added:
405
+ return # two attributes bound to one function: one benchmark
406
+ added.add(runner_id)
407
+ if entry not in index.symbols:
408
+ result.notes.append(
409
+ DiscoveryNote(
410
+ RUNNER, "missing_symbol", f"{runner_id}: {entry} is not in the index"
411
+ )
412
+ )
413
+ result.targets.append(Target(RUNNER, runner_id, entry, tuple(sorted(set(deps)))))
414
+
415
+ # Public module attributes: (function or class node, its module).
416
+ functions: dict[str, tuple[ast.FunctionDef | ast.AsyncFunctionDef, ParsedModule]] = {}
417
+ classes: dict[str, tuple[ast.ClassDef, ParsedModule]] = {}
418
+ for node in body:
419
+ if not isinstance(node, ast.ImportFrom):
420
+ continue
421
+ origin = module_for(_import_source(pm, node))
422
+ if origin is None:
423
+ continue
424
+ for alias in node.names:
425
+ if alias.name == "*":
426
+ for name in _public_defs(origin):
427
+ _bind_def(origin, name, name, functions, classes)
428
+ elif not (alias.asname or alias.name).startswith("_"):
429
+ _bind_def(origin, alias.name, alias.asname or alias.name, functions, classes)
430
+ for func in scope_functions(body):
431
+ functions[func.name] = (func, pm)
432
+ for cls in scope_classes(body):
433
+ classes[cls.name] = (cls, pm)
434
+ # ``time_alias = _impl``: another attribute bound to the function.
435
+ for stmt in body:
436
+ if (
437
+ isinstance(stmt, ast.Assign)
438
+ and len(stmt.targets) == 1
439
+ and isinstance(stmt.targets[0], ast.Name)
440
+ and isinstance(stmt.value, ast.Name)
441
+ and stmt.value.id in functions
442
+ ):
443
+ functions[stmt.targets[0].id] = functions[stmt.value.id]
444
+ # The module's ``setup``/``teardown``, defined there or imported
445
+ # (``from .common import setup``), run around each benchmark.
446
+ module_deps = [pm.module] + [
447
+ owner.member_id(func.name)
448
+ for name, (func, owner) in sorted(functions.items())
449
+ if name in LIFECYCLE_NAMES
450
+ ]
451
+ custom = _benchmark_names(body)
452
+ for bound, (func, owner) in sorted(functions.items()):
453
+ if bound.startswith("_"):
454
+ continue
455
+ # ASV matches the attribute name, or the last part of a
456
+ # ``benchmark_name``, and names the benchmark by that name or
457
+ # the function's own (``func.__name__``).
458
+ name = custom.get(func.name) if owner is pm else None
459
+ if not _is_benchmark(name.split(".")[-1] if name else bound):
460
+ continue
461
+ add(
462
+ name or qualified(bench_module, func.name),
463
+ owner.member_id(func.name),
464
+ module_deps + _timeraw_deps(func, index),
465
+ )
466
+ for bound, (cls, owner) in sorted(classes.items()):
467
+ if bound.startswith("_"):
468
+ continue
469
+ runner_prefix = qualified(bench_module, cls.name) # ``klass.__name__``
470
+ # ASV reads the class's attributes, inherited ones included; the
471
+ # subclass's own definitions win, so bases are applied first.
472
+ methods: dict[str, str] = {}
473
+ nodes: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {}
474
+ for base_cls, base_mod in [
475
+ *reversed(bases_of(cls, owner, runner_prefix)),
476
+ (cls, owner),
477
+ ]:
478
+ base_id = base_mod.member_id(base_cls.name)
479
+ _class_methods(base_cls, base_id, methods)
480
+ nodes.update({f"{base_id}.{f.name}": f for f in scope_functions(base_cls.body)})
481
+ deps = module_deps + [
482
+ symbol for name, symbol in methods.items() if name in LIFECYCLE_NAMES
483
+ ]
484
+ if owner is not pm:
485
+ deps.append(owner.module)
486
+ custom = _benchmark_names(cls.body)
487
+ for name, symbol in sorted(methods.items()):
488
+ renamed = custom.get(name)
489
+ if not _is_benchmark(renamed.split(".")[-1] if renamed else name):
490
+ continue
491
+ code_deps = _timeraw_deps(nodes[symbol], index) if symbol in nodes else []
492
+ add(renamed or f"{runner_prefix}.{name}", symbol, deps + code_deps)
493
+ result.targets.sort()
494
+ return result
@@ -0,0 +1,130 @@
1
+ """Shared AST helpers for static discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from collections.abc import Iterator
7
+ from dataclasses import dataclass, field
8
+
9
+ from diffcone.indexer import DEF_NODES, FUNC_NODES, decode_source, iter_scope_statements
10
+ from diffcone.snapshot import Snapshot, child_modules, member_symbol_id, module_name_for
11
+
12
+
13
+ @dataclass
14
+ class ParsedModule:
15
+ path: str
16
+ module: str
17
+ tree: ast.Module
18
+ # Submodule names of this module (a package): see member_symbol_id.
19
+ submodules: frozenset[str] = field(default_factory=frozenset)
20
+
21
+ def member_id(self, name: str) -> str:
22
+ """The index identity of this module's top-level ``name``."""
23
+ return member_symbol_id(self.module, name, self.submodules)
24
+
25
+
26
+ # Set on a module's function nodes when its source never mentions
27
+ # getfixturevalue, so discovery can skip walking each test body for it.
28
+ NO_GETFIXTUREVALUE = "_diffcone_no_getfixturevalue"
29
+
30
+
31
+ def _mark_functions(body: list[ast.stmt]) -> None:
32
+ """Mark the functions defined at statement level (module, class and
33
+ block bodies), without walking expressions."""
34
+ stack = list(body)
35
+ while stack:
36
+ node = stack.pop()
37
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
38
+ setattr(node, NO_GETFIXTUREVALUE, True)
39
+ stack.extend(node.body)
40
+ elif isinstance(node, ast.ClassDef):
41
+ stack.extend(node.body)
42
+ else:
43
+ for name in ("body", "orelse", "finalbody", "handlers"):
44
+ stack.extend(getattr(node, name, ()) or ())
45
+
46
+
47
+ def parse_modules(snapshot: Snapshot, paths: list[str]) -> tuple[list[ParsedModule], list[str]]:
48
+ """Parse the given snapshot paths. Returns (parsed, failed paths)."""
49
+ parsed: list[ParsedModule] = []
50
+ failed: list[str] = []
51
+ children = child_modules(snapshot)
52
+ for path in sorted(paths):
53
+ module = module_name_for(path, snapshot.source_roots)
54
+ if module is None:
55
+ failed.append(path)
56
+ continue
57
+ try:
58
+ tree = ast.parse(decode_source(snapshot.files[path]), filename=path)
59
+ except (SyntaxError, UnicodeDecodeError, ValueError):
60
+ failed.append(path)
61
+ continue
62
+ if b"getfixturevalue" not in snapshot.files[path]:
63
+ _mark_functions(tree.body)
64
+ parsed.append(ParsedModule(path, module, tree, children.get(module, frozenset())))
65
+ return parsed, failed
66
+
67
+
68
+ def decorator_chain(node: ast.expr) -> tuple[list[str], ast.Call | None]:
69
+ """Return the dotted name parts of a decorator and its call node, if any.
70
+
71
+ ``@pytest.fixture(name="x")`` -> (["pytest", "fixture"], Call)
72
+ ``@fixture`` -> (["fixture"], None)
73
+ """
74
+ call = None
75
+ if isinstance(node, ast.Call):
76
+ call = node
77
+ node = node.func
78
+ parts: list[str] = []
79
+ while isinstance(node, ast.Attribute):
80
+ parts.append(node.attr)
81
+ node = node.value
82
+ if isinstance(node, ast.Name):
83
+ parts.append(node.id)
84
+ return list(reversed(parts)), call
85
+ return [], call
86
+
87
+
88
+ def keyword_value(call: ast.Call | None, name: str) -> ast.expr | None:
89
+ if call is None:
90
+ return None
91
+ for kw in call.keywords:
92
+ if kw.arg == name:
93
+ return kw.value
94
+ return None
95
+
96
+
97
+ def string_literals(nodes: list[ast.expr]) -> list[str]:
98
+ out: list[str] = []
99
+ for n in nodes:
100
+ if isinstance(n, ast.Constant) and isinstance(n.value, str):
101
+ out.append(n.value)
102
+ elif isinstance(n, (ast.List, ast.Tuple)):
103
+ out.extend(string_literals(list(n.elts)))
104
+ return out
105
+
106
+
107
+ def scope_functions(body: list[ast.stmt]) -> Iterator[ast.FunctionDef | ast.AsyncFunctionDef]:
108
+ for stmt in iter_scope_statements(body):
109
+ if isinstance(stmt, FUNC_NODES):
110
+ yield stmt
111
+
112
+
113
+ def scope_classes(body: list[ast.stmt]) -> Iterator[ast.ClassDef]:
114
+ for stmt in iter_scope_statements(body):
115
+ if isinstance(stmt, ast.ClassDef):
116
+ yield stmt
117
+
118
+
119
+ def scope_assignments(body: list[ast.stmt]) -> Iterator[tuple[str, ast.expr]]:
120
+ """Yield (name, value) for simple ``name = value`` statements in a scope."""
121
+ for stmt in iter_scope_statements(body):
122
+ if isinstance(stmt, DEF_NODES):
123
+ continue
124
+ if isinstance(stmt, ast.Assign):
125
+ for target in stmt.targets:
126
+ if isinstance(target, ast.Name):
127
+ yield target.id, stmt.value
128
+ elif isinstance(stmt, ast.AnnAssign) and stmt.value is not None:
129
+ if isinstance(stmt.target, ast.Name):
130
+ yield stmt.target.id, stmt.value