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.
- diffcone/__init__.py +13 -0
- diffcone/cache.py +528 -0
- diffcone/check.py +399 -0
- diffcone/classify.py +255 -0
- diffcone/cli.py +886 -0
- diffcone/collect.py +846 -0
- diffcone/cython.py +757 -0
- diffcone/declarations.py +111 -0
- diffcone/discovery/__init__.py +121 -0
- diffcone/discovery/asv_static.py +494 -0
- diffcone/discovery/common.py +130 -0
- diffcone/discovery/pytest_static.py +2680 -0
- diffcone/evidence.py +738 -0
- diffcone/evidence_plan.py +1487 -0
- diffcone/execution.py +1681 -0
- diffcone/indexer/__init__.py +48 -0
- diffcone/indexer/core.py +286 -0
- diffcone/indexer/definitions.py +292 -0
- diffcone/indexer/dynamics.py +406 -0
- diffcone/indexer/facts.py +373 -0
- diffcone/indexer/literals.py +388 -0
- diffcone/indexer/references.py +895 -0
- diffcone/indexer/resolver.py +1001 -0
- diffcone/indexer/scopes.py +276 -0
- diffcone/indexer/state.py +83 -0
- diffcone/indexer/symbols.py +441 -0
- diffcone/indexer/syntax.py +173 -0
- diffcone/manifest.py +166 -0
- diffcone/model.py +191 -0
- diffcone/planner.py +1453 -0
- diffcone/report.py +256 -0
- diffcone/selection.py +133 -0
- diffcone/snapshot.py +739 -0
- diffcone/testing.py +200 -0
- diffcone-0.1.0.dist-info/METADATA +133 -0
- diffcone-0.1.0.dist-info/RECORD +39 -0
- diffcone-0.1.0.dist-info/WHEEL +4 -0
- diffcone-0.1.0.dist-info/entry_points.txt +3 -0
- diffcone-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|