diffgenome 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.
- diffgenome/__init__.py +7 -0
- diffgenome/__main__.py +240 -0
- diffgenome/_collectors/go/dg/dg.go +623 -0
- diffgenome/_collectors/go/go.mod +3 -0
- diffgenome/_collectors/go/instrument/facts.go +346 -0
- diffgenome/_collectors/go/instrument/main.go +484 -0
- diffgenome/_collectors/node/instrument.js +289 -0
- diffgenome/_collectors/node/jest-setup.js +40 -0
- diffgenome/_collectors/node/package-lock.json +35 -0
- diffgenome/_collectors/node/package.json +11 -0
- diffgenome/_collectors/node/runtime.js +426 -0
- diffgenome/ambiguity.py +122 -0
- diffgenome/api.py +67 -0
- diffgenome/change.py +86 -0
- diffgenome/change_artifact.py +310 -0
- diffgenome/collect/__init__.py +2 -0
- diffgenome/collect/go_test.py +271 -0
- diffgenome/collect/node_jest.py +319 -0
- diffgenome/collect/py_monitoring.py +985 -0
- diffgenome/collect/py_runtime.py +116 -0
- diffgenome/collect/py_symbols.py +238 -0
- diffgenome/collect/pytest_plugin.py +130 -0
- diffgenome/compose.py +469 -0
- diffgenome/dependence.py +264 -0
- diffgenome/evaluate.py +669 -0
- diffgenome/frontends/__init__.py +0 -0
- diffgenome/frontends/python_ir.py +335 -0
- diffgenome/genome.py +1016 -0
- diffgenome/genome_pipeline.py +674 -0
- diffgenome/genome_prompt.py +33 -0
- diffgenome/genome_state.py +2118 -0
- diffgenome/graph.py +426 -0
- diffgenome/llm.py +189 -0
- diffgenome/model.py +364 -0
- diffgenome/mvp.py +398 -0
- diffgenome/probe.py +509 -0
- diffgenome/projection.py +308 -0
- diffgenome/py.typed +0 -0
- diffgenome/render.py +118 -0
- diffgenome/report.py +363 -0
- diffgenome/resolve.py +37 -0
- diffgenome/runtime.py +74 -0
- diffgenome/runtime_evidence.py +261 -0
- diffgenome/sandbox.py +166 -0
- diffgenome/serialize.py +96 -0
- diffgenome/sites.py +19 -0
- diffgenome/static_types.py +69 -0
- diffgenome/structure.py +462 -0
- diffgenome-0.1.0.dist-info/METADATA +139 -0
- diffgenome-0.1.0.dist-info/RECORD +53 -0
- diffgenome-0.1.0.dist-info/WHEEL +4 -0
- diffgenome-0.1.0.dist-info/entry_points.txt +2 -0
- diffgenome-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Python/pytest runtime adapter: the CPython collector as a `RuntimeAdapter`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from diffgenome.collect.py_symbols import PythonSymbolIndex
|
|
9
|
+
from diffgenome.model import Execution, Stimulus
|
|
10
|
+
from diffgenome.probe import copy_probe_traces
|
|
11
|
+
from diffgenome.runtime import SymbolIndex
|
|
12
|
+
from diffgenome.sandbox import Workspace
|
|
13
|
+
from diffgenome.serialize import execution_from_json
|
|
14
|
+
|
|
15
|
+
DIFFGENOME_SRC = Path(__file__).resolve().parents[2]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PytestRuntime:
|
|
19
|
+
name = "python/pytest"
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
repo: Path,
|
|
24
|
+
python: Path,
|
|
25
|
+
source_root: str,
|
|
26
|
+
test_root: str,
|
|
27
|
+
tests: str,
|
|
28
|
+
pytest_args: list[str],
|
|
29
|
+
pythonpath: list[str] | None = None,
|
|
30
|
+
test_env: dict[str, str] | None = None,
|
|
31
|
+
allow_loopback: bool = False,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.repo = repo
|
|
34
|
+
self.python = python
|
|
35
|
+
self.source_root = source_root
|
|
36
|
+
self.test_root = test_root
|
|
37
|
+
self.tests = tests
|
|
38
|
+
self.pytest_args = pytest_args
|
|
39
|
+
# Generic target-environment options (experiment 10, Baserow): repo-relative import
|
|
40
|
+
# roots resolved inside the disposable workspace; allow-listed, non-secret test
|
|
41
|
+
# variables (e.g. a local disposable database); loopback-only networking for local
|
|
42
|
+
# services. Target code still runs under the OS sandbox with no other network.
|
|
43
|
+
self.pythonpath = list(pythonpath or [])
|
|
44
|
+
self.test_env = dict(test_env or {})
|
|
45
|
+
self.allow_loopback = allow_loopback
|
|
46
|
+
|
|
47
|
+
def prepare(self, ws: Workspace) -> SymbolIndex:
|
|
48
|
+
# Index the workspace copy, i.e. the analyzed revision (`--rev` archives it there), not
|
|
49
|
+
# the checkout: the checkout may sit at another revision, and the index maps the
|
|
50
|
+
# change's diff lines and the executed code's locations onto symbols.
|
|
51
|
+
return PythonSymbolIndex(ws.repo, [ws.repo / self.source_root], [ws.repo / self.test_root])
|
|
52
|
+
|
|
53
|
+
def trace(
|
|
54
|
+
self, ws: Workspace, out_dir: Path, stimulus: Stimulus, only: list[str] | None
|
|
55
|
+
) -> tuple[list[Execution], str, str]:
|
|
56
|
+
tag = "existing" if stimulus is Stimulus.EXISTING_TEST else "probe"
|
|
57
|
+
traces_ws = ws.root / f"traces-{tag}-{len(list(ws.root.glob('traces-*')))}"
|
|
58
|
+
traces_ws.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
argv = [
|
|
60
|
+
str(self.python), "-m", "pytest", "-q", "-p", "no:cacheprovider",
|
|
61
|
+
"-p", "diffgenome.collect.pytest_plugin",
|
|
62
|
+
"--diffgenome-out", str(traces_ws),
|
|
63
|
+
"--diffgenome-repo-root", ".",
|
|
64
|
+
"--diffgenome-source-root", self.source_root,
|
|
65
|
+
"--diffgenome-test-root", self.test_root,
|
|
66
|
+
"--diffgenome-stimulus", stimulus.value,
|
|
67
|
+
"--diffgenome-egress-guard",
|
|
68
|
+
*self.pytest_args,
|
|
69
|
+
*(only if only else [self.tests]),
|
|
70
|
+
] # fmt: skip
|
|
71
|
+
roots = [str(ws.repo / p) for p in self.pythonpath]
|
|
72
|
+
env = {**self.test_env, "PYTHONPATH": ":".join([*roots, str(DIFFGENOME_SRC)])}
|
|
73
|
+
result = ws.run(argv, env=env, timeout=1800, allow_loopback=self.allow_loopback)
|
|
74
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
copy_probe_traces(traces_ws, out_dir)
|
|
76
|
+
executions = [execution_from_json(f.read_text()) for f in sorted(out_dir.glob("*.json"))]
|
|
77
|
+
return executions, result.stdout, result.stderr
|
|
78
|
+
|
|
79
|
+
def probe_relpath(self, tag: str) -> str:
|
|
80
|
+
return f"{self.test_root}/test_diffgenome_probe_{tag}.py"
|
|
81
|
+
|
|
82
|
+
def conventions(self, index: SymbolIndex) -> str:
|
|
83
|
+
parts = ["Runtime: Python, pytest. The probe is a pytest test file."]
|
|
84
|
+
for root in index.test_roots:
|
|
85
|
+
f = root / "conftest.py"
|
|
86
|
+
if f.is_file():
|
|
87
|
+
lines: list[str] = []
|
|
88
|
+
try:
|
|
89
|
+
tree = ast.parse(f.read_text(encoding="utf-8"))
|
|
90
|
+
except SyntaxError:
|
|
91
|
+
tree = None
|
|
92
|
+
for node in tree.body if tree else []:
|
|
93
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and any(
|
|
94
|
+
"fixture" in ast.unparse(d) for d in node.decorator_list
|
|
95
|
+
):
|
|
96
|
+
doc = ast.get_docstring(node) or ""
|
|
97
|
+
params = ", ".join(a.arg for a in node.args.args)
|
|
98
|
+
lines.append(
|
|
99
|
+
f"- {node.name}({params}): {doc.splitlines()[0] if doc else ''}"
|
|
100
|
+
)
|
|
101
|
+
if lines:
|
|
102
|
+
parts.append("conftest fixtures available:\n" + "\n".join(lines[:30]))
|
|
103
|
+
ini = index.repo_root / "pytest.ini"
|
|
104
|
+
if ini.is_file():
|
|
105
|
+
parts.append(
|
|
106
|
+
"pytest.ini:\n" + "\n".join(ini.read_text(encoding="utf-8").splitlines()[:20])
|
|
107
|
+
)
|
|
108
|
+
pyproject = index.repo_root / "pyproject.toml"
|
|
109
|
+
if pyproject.is_file():
|
|
110
|
+
text = pyproject.read_text(encoding="utf-8")
|
|
111
|
+
if "[tool.pytest" in text:
|
|
112
|
+
parts.append(
|
|
113
|
+
"pyproject pytest section:\n"
|
|
114
|
+
+ "\n".join(text[text.index("[tool.pytest") :].splitlines()[:15])
|
|
115
|
+
)
|
|
116
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Python source adapter: static symbol index over a repository's files.
|
|
2
|
+
|
|
3
|
+
Maps source lines to `SymbolId`s with the *same naming scheme the tracer uses* (module from
|
|
4
|
+
path under the roots, dotted qualname), extracts a symbol's source text for probe context,
|
|
5
|
+
and lists test files that import a module. Language-specific by nature; it lives beside
|
|
6
|
+
the collector and is used through `diffgenome.change.SymbolIndex`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from diffgenome.model import SymbolId
|
|
16
|
+
|
|
17
|
+
_EXCLUDED = {"site-packages", "dist-packages", "node_modules", "__pycache__"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Definition:
|
|
22
|
+
symbol: SymbolId
|
|
23
|
+
path: str # repository-relative
|
|
24
|
+
start: int
|
|
25
|
+
end: int
|
|
26
|
+
kind: str # "function" | "class"
|
|
27
|
+
is_async: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PythonSymbolIndex:
|
|
31
|
+
def __init__(self, repo_root: Path, source_roots: list[Path], test_roots: list[Path]) -> None:
|
|
32
|
+
self.repo_root = repo_root.resolve()
|
|
33
|
+
self.source_roots = [p.resolve() for p in source_roots]
|
|
34
|
+
self.test_roots = [p.resolve() for p in test_roots]
|
|
35
|
+
self._defs: dict[str, list[Definition]] = {}
|
|
36
|
+
|
|
37
|
+
# ------------------------------------------------------------------ naming (mirrors tracer)
|
|
38
|
+
|
|
39
|
+
def module_for(self, path: Path) -> str | None:
|
|
40
|
+
path = path.resolve()
|
|
41
|
+
if any(p in _EXCLUDED or p.startswith(".") for p in path.parts[1:]):
|
|
42
|
+
return None
|
|
43
|
+
for roots, is_test in ((self.test_roots, True), (self.source_roots, False)):
|
|
44
|
+
for root in roots:
|
|
45
|
+
try:
|
|
46
|
+
rel = path.relative_to(root)
|
|
47
|
+
except ValueError:
|
|
48
|
+
continue
|
|
49
|
+
parts = list(rel.with_suffix("").parts)
|
|
50
|
+
if parts and parts[-1] == "__init__":
|
|
51
|
+
parts.pop()
|
|
52
|
+
prefix = root.relative_to(self.repo_root).parts if is_test else ()
|
|
53
|
+
return ".".join((*prefix, *parts))
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------ definitions
|
|
57
|
+
|
|
58
|
+
def definitions(self, rel_path: str) -> list[Definition]:
|
|
59
|
+
if rel_path in self._defs:
|
|
60
|
+
return self._defs[rel_path]
|
|
61
|
+
file = self.repo_root / rel_path
|
|
62
|
+
module = self.module_for(file)
|
|
63
|
+
out: list[Definition] = []
|
|
64
|
+
if module is not None and file.suffix == ".py":
|
|
65
|
+
try:
|
|
66
|
+
tree = ast.parse(file.read_text(encoding="utf-8"))
|
|
67
|
+
except (SyntaxError, OSError, UnicodeDecodeError):
|
|
68
|
+
tree = None
|
|
69
|
+
if tree is not None:
|
|
70
|
+
self._collect(tree.body, module, [], rel_path, out)
|
|
71
|
+
self._defs[rel_path] = out
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
def _collect(
|
|
75
|
+
self, body: list[ast.stmt], module: str, scope: list[str], rel: str, out: list[Definition]
|
|
76
|
+
) -> None:
|
|
77
|
+
for node in body:
|
|
78
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
79
|
+
qual = ".".join([*scope, node.name])
|
|
80
|
+
out.append(
|
|
81
|
+
Definition(
|
|
82
|
+
f"py:{module}.{qual}",
|
|
83
|
+
rel,
|
|
84
|
+
node.lineno,
|
|
85
|
+
node.end_lineno or node.lineno,
|
|
86
|
+
"function",
|
|
87
|
+
isinstance(node, ast.AsyncFunctionDef),
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
self._collect(node.body, module, [*scope, node.name, "<locals>"], rel, out)
|
|
91
|
+
elif isinstance(node, ast.ClassDef):
|
|
92
|
+
qual = ".".join([*scope, node.name])
|
|
93
|
+
out.append(
|
|
94
|
+
Definition(
|
|
95
|
+
f"py:{module}.{qual}",
|
|
96
|
+
rel,
|
|
97
|
+
node.lineno,
|
|
98
|
+
node.end_lineno or node.lineno,
|
|
99
|
+
"class",
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
self._collect(node.body, module, [*scope, node.name], rel, out)
|
|
103
|
+
|
|
104
|
+
def symbols_at(self, rel_path: str, lines: set[int]) -> list[Definition]:
|
|
105
|
+
"""Innermost function definitions covering any of the lines (classes only when a
|
|
106
|
+
changed line belongs to no function, e.g. a class attribute)."""
|
|
107
|
+
defs = self.definitions(rel_path)
|
|
108
|
+
hits: dict[SymbolId, Definition] = {}
|
|
109
|
+
for line in lines:
|
|
110
|
+
covering = [d for d in defs if d.start <= line <= d.end]
|
|
111
|
+
funcs = [d for d in covering if d.kind == "function"]
|
|
112
|
+
pick = (
|
|
113
|
+
max(funcs, key=lambda d: d.start)
|
|
114
|
+
if funcs
|
|
115
|
+
else (max(covering, key=lambda d: d.start) if covering else None)
|
|
116
|
+
)
|
|
117
|
+
if pick:
|
|
118
|
+
hits[pick.symbol] = pick
|
|
119
|
+
return sorted(hits.values(), key=lambda d: (d.path, d.start))
|
|
120
|
+
|
|
121
|
+
def find(self, symbol: SymbolId) -> Definition | None:
|
|
122
|
+
rel = self.path_for(symbol)
|
|
123
|
+
if rel is None:
|
|
124
|
+
return None
|
|
125
|
+
for d in self.definitions(rel):
|
|
126
|
+
if d.symbol == symbol:
|
|
127
|
+
return d
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def path_for(self, symbol: SymbolId) -> str | None:
|
|
131
|
+
"""Best-effort inverse of the naming scheme: longest module prefix that is a file."""
|
|
132
|
+
name = symbol.split(":", 1)[1]
|
|
133
|
+
parts = name.split(".")
|
|
134
|
+
for i in range(len(parts), 0, -1):
|
|
135
|
+
candidate = "/".join(parts[:i])
|
|
136
|
+
for roots, is_test in ((self.test_roots, True), (self.source_roots, False)):
|
|
137
|
+
for root in roots:
|
|
138
|
+
base = root
|
|
139
|
+
rel_prefix = root.relative_to(self.repo_root).parts if is_test else ()
|
|
140
|
+
cand_parts = parts[:i]
|
|
141
|
+
if is_test:
|
|
142
|
+
if tuple(cand_parts[: len(rel_prefix)]) != tuple(rel_prefix):
|
|
143
|
+
continue
|
|
144
|
+
cand_parts = cand_parts[len(rel_prefix) :]
|
|
145
|
+
candidate = "/".join(cand_parts)
|
|
146
|
+
for suffix in (".py", "/__init__.py"):
|
|
147
|
+
file = base / (candidate + suffix)
|
|
148
|
+
if file.is_file():
|
|
149
|
+
return str(file.resolve().relative_to(self.repo_root))
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def source(self, symbol: SymbolId, context: int = 0) -> str | None:
|
|
153
|
+
d = self.find(symbol)
|
|
154
|
+
if d is None:
|
|
155
|
+
return None
|
|
156
|
+
lines = (self.repo_root / d.path).read_text(encoding="utf-8").splitlines()
|
|
157
|
+
lo, hi = max(0, d.start - 1 - context), min(len(lines), d.end + context)
|
|
158
|
+
return "\n".join(f"{i + 1:5d} {lines[i]}" for i in range(lo, hi))
|
|
159
|
+
|
|
160
|
+
def enclosing_class(self, symbol: SymbolId) -> Definition | None:
|
|
161
|
+
d = self.find(symbol)
|
|
162
|
+
if d is None:
|
|
163
|
+
return None
|
|
164
|
+
classes = [
|
|
165
|
+
c for c in self.definitions(d.path)
|
|
166
|
+
if c.kind == "class" and c.start <= d.start and c.end >= d.end and c.symbol != d.symbol
|
|
167
|
+
] # fmt: skip
|
|
168
|
+
return max(classes, key=lambda c: c.start) if classes else None
|
|
169
|
+
|
|
170
|
+
def is_test(self, symbol: SymbolId) -> bool:
|
|
171
|
+
d = self.find(symbol)
|
|
172
|
+
if d is None:
|
|
173
|
+
return False
|
|
174
|
+
path = self.repo_root / d.path
|
|
175
|
+
return any(path.is_relative_to(r) for r in self.test_roots)
|
|
176
|
+
|
|
177
|
+
def return_type_of(self, symbol: SymbolId) -> SymbolId | None:
|
|
178
|
+
d = self.find(symbol)
|
|
179
|
+
if d is None or d.kind != "function":
|
|
180
|
+
return None
|
|
181
|
+
try:
|
|
182
|
+
tree = ast.parse((self.repo_root / d.path).read_text(encoding="utf-8"))
|
|
183
|
+
except (SyntaxError, OSError):
|
|
184
|
+
return None
|
|
185
|
+
for node in ast.walk(tree):
|
|
186
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and node.lineno == d.start:
|
|
187
|
+
ann = node.returns
|
|
188
|
+
if ann is None:
|
|
189
|
+
return None
|
|
190
|
+
name = ast.unparse(ann)
|
|
191
|
+
if name.startswith("Optional[") and name.endswith("]"):
|
|
192
|
+
name = name[len("Optional[") : -1]
|
|
193
|
+
if name.endswith(" | None"):
|
|
194
|
+
name = name[: -len(" | None")]
|
|
195
|
+
cls = name.split(".")[-1]
|
|
196
|
+
if not cls.isidentifier():
|
|
197
|
+
return None
|
|
198
|
+
candidates = [
|
|
199
|
+
x
|
|
200
|
+
for x in self.all_definitions()
|
|
201
|
+
if x.kind == "class" and x.symbol.endswith("." + cls)
|
|
202
|
+
]
|
|
203
|
+
return candidates[0].symbol if len(candidates) == 1 else None
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
def all_definitions(self) -> list[Definition]:
|
|
207
|
+
if not getattr(self, "_all", None):
|
|
208
|
+
out: list[Definition] = []
|
|
209
|
+
for root in self.source_roots:
|
|
210
|
+
for f in sorted(root.rglob("*.py")):
|
|
211
|
+
try:
|
|
212
|
+
rel = str(f.resolve().relative_to(self.repo_root))
|
|
213
|
+
except ValueError:
|
|
214
|
+
continue
|
|
215
|
+
out.extend(self.definitions(rel))
|
|
216
|
+
self._all = out
|
|
217
|
+
return self._all
|
|
218
|
+
|
|
219
|
+
def module_of(self, symbol: SymbolId) -> str | None:
|
|
220
|
+
"""Dotted module of a symbol: the longest prefix of its qualified name that is a file."""
|
|
221
|
+
rel = self.path_for(symbol)
|
|
222
|
+
return self.module_for(self.repo_root / rel) if rel else None
|
|
223
|
+
|
|
224
|
+
def tests_importing(self, module: str, limit: int = 3) -> list[str]:
|
|
225
|
+
"""Test files that import the module (or its package), by name. Shallow and cheap."""
|
|
226
|
+
hits: list[str] = []
|
|
227
|
+
needles = {module, module.rsplit(".", 1)[0]}
|
|
228
|
+
for root in self.test_roots:
|
|
229
|
+
for file in sorted(root.rglob("test_*.py")):
|
|
230
|
+
try:
|
|
231
|
+
text = file.read_text(encoding="utf-8")
|
|
232
|
+
except (OSError, UnicodeDecodeError):
|
|
233
|
+
continue
|
|
234
|
+
if any(f"from {n} import" in text or f"import {n}" in text for n in needles):
|
|
235
|
+
hits.append(str(file.resolve().relative_to(self.repo_root)))
|
|
236
|
+
if len(hits) >= limit:
|
|
237
|
+
return hits
|
|
238
|
+
return hits
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""pytest adapter for the CPython collector: traces the call phase of every test and writes
|
|
2
|
+
one `Execution` JSON per test.
|
|
3
|
+
|
|
4
|
+
python -m pytest -p diffgenome.collect.pytest_plugin \\
|
|
5
|
+
--diffgenome-out OUT --diffgenome-source-root . --diffgenome-test-root tests
|
|
6
|
+
|
|
7
|
+
The target repository is never written to: pass `-p no:cacheprovider` and set
|
|
8
|
+
PYTHONDONTWRITEBYTECODE=1 when the target is mounted read-only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
from collections.abc import Generator
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from diffgenome.collect.py_monitoring import COLLECTOR, EGRESS_GUARD, Tracer
|
|
22
|
+
from diffgenome.model import Collector, Execution, Stimulus
|
|
23
|
+
from diffgenome.serialize import execution_to_json
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
27
|
+
group = parser.getgroup("diffgenome")
|
|
28
|
+
group.addoption("--diffgenome-out", required=True, help="directory for Execution JSON")
|
|
29
|
+
group.addoption(
|
|
30
|
+
"--diffgenome-source-root",
|
|
31
|
+
action="append",
|
|
32
|
+
default=[],
|
|
33
|
+
help="repository source root (repeatable); the first is the repository root",
|
|
34
|
+
)
|
|
35
|
+
group.addoption(
|
|
36
|
+
"--diffgenome-test-root", action="append", default=[], help="test root (repeatable)"
|
|
37
|
+
)
|
|
38
|
+
group.addoption(
|
|
39
|
+
"--diffgenome-repo-root",
|
|
40
|
+
default=None,
|
|
41
|
+
help="repository root that site ids and paths are relative to (default: first source root)",
|
|
42
|
+
)
|
|
43
|
+
group.addoption(
|
|
44
|
+
"--diffgenome-stimulus",
|
|
45
|
+
default="existing_test",
|
|
46
|
+
choices=[s.value for s in Stimulus],
|
|
47
|
+
help="how these executions were caused (generated probes must say so)",
|
|
48
|
+
)
|
|
49
|
+
group.addoption(
|
|
50
|
+
"--diffgenome-egress-guard",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="refuse socket connects and record attempts as OS-plane evidence",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _revision(cwd: Path) -> str | None:
|
|
57
|
+
try:
|
|
58
|
+
out = subprocess.run(
|
|
59
|
+
["git", "rev-parse", "HEAD"], cwd=cwd, capture_output=True, text=True, timeout=5
|
|
60
|
+
)
|
|
61
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
62
|
+
return None
|
|
63
|
+
return out.stdout.strip() or None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _Plugin:
|
|
67
|
+
def __init__(self, config: pytest.Config) -> None:
|
|
68
|
+
self.out = Path(config.getoption("--diffgenome-out")).resolve()
|
|
69
|
+
self.out.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
roots = [Path(p).resolve() for p in config.getoption("--diffgenome-source-root")]
|
|
71
|
+
if not roots:
|
|
72
|
+
roots = [Path(config.rootpath).resolve()]
|
|
73
|
+
tests = [Path(p).resolve() for p in config.getoption("--diffgenome-test-root")]
|
|
74
|
+
# Site ids hash the repository-relative path (sites.py); a source root below the
|
|
75
|
+
# repository root (Baserow: backend/src) must not become the base. Experiment 10, F2.
|
|
76
|
+
repo_opt = config.getoption("--diffgenome-repo-root")
|
|
77
|
+
repo_root = Path(repo_opt).resolve() if repo_opt else roots[0]
|
|
78
|
+
self.tracer = Tracer(
|
|
79
|
+
repo_root=repo_root, source_roots=tuple(roots), test_roots=tuple(tests)
|
|
80
|
+
)
|
|
81
|
+
self.tracer.install_patch_hook()
|
|
82
|
+
self.stimulus = Stimulus(config.getoption("--diffgenome-stimulus"))
|
|
83
|
+
self.collectors: tuple[Collector, ...] = (COLLECTOR,)
|
|
84
|
+
if config.getoption("--diffgenome-egress-guard"):
|
|
85
|
+
self.tracer.install_egress_guard()
|
|
86
|
+
self.collectors = (COLLECTOR, EGRESS_GUARD)
|
|
87
|
+
self.revision = _revision(roots[0])
|
|
88
|
+
|
|
89
|
+
@pytest.hookimpl(wrapper=True)
|
|
90
|
+
def pytest_runtest_call(self, item: pytest.Item) -> Generator[None, None, None]:
|
|
91
|
+
function = getattr(item, "function", None)
|
|
92
|
+
code = getattr(function, "__code__", None)
|
|
93
|
+
root_symbol = (code and self.tracer.symbol_for_code(code)) or f"py:{item.nodeid}"
|
|
94
|
+
self.tracer.start(root_symbol, code)
|
|
95
|
+
outcome = "passed"
|
|
96
|
+
try:
|
|
97
|
+
return (yield)
|
|
98
|
+
except BaseException:
|
|
99
|
+
outcome = "failed"
|
|
100
|
+
raise
|
|
101
|
+
finally:
|
|
102
|
+
result = self.tracer.stop()
|
|
103
|
+
execution = Execution(
|
|
104
|
+
id=f"{item.nodeid}@{self.revision or 'unknown'}",
|
|
105
|
+
stimulus=self.stimulus,
|
|
106
|
+
stimulus_ref=item.nodeid,
|
|
107
|
+
outcome=outcome,
|
|
108
|
+
revision=self.revision,
|
|
109
|
+
collectors=self.collectors,
|
|
110
|
+
symbols=result.symbols,
|
|
111
|
+
nodes=result.nodes,
|
|
112
|
+
branches=result.branches,
|
|
113
|
+
diagnostics=(
|
|
114
|
+
("stack_repairs", str(result.stack_repairs)),
|
|
115
|
+
("attribution_disagreements", str(result.attribution_disagreements)),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
# Sanitized ids can collide (parametrized ids differing only in punctuation)
|
|
119
|
+
# and can exceed filesystem limits; the digest keeps every execution distinct.
|
|
120
|
+
digest = hashlib.sha256(item.nodeid.encode()).hexdigest()[:12]
|
|
121
|
+
name = re.sub(r"[^A-Za-z0-9_.-]+", "_", item.nodeid)[:100]
|
|
122
|
+
(self.out / f"{name}-{digest}.json").write_text(execution_to_json(execution))
|
|
123
|
+
if result.stack_repairs:
|
|
124
|
+
item.add_report_section(
|
|
125
|
+
"call", "diffgenome", f"stack repairs: {result.stack_repairs}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
130
|
+
config.pluginmanager.register(_Plugin(config), "diffgenome-collector")
|