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,985 @@
|
|
|
1
|
+
"""CPython symbol-plane collector on `sys.monitoring` (PEP 669, Python >= 3.12).
|
|
2
|
+
|
|
3
|
+
Complete-fidelity capture of in-scope logical calls, with stand-in calls attributed to the
|
|
4
|
+
in-scope function that made them. This module is allowed to know about CPython and
|
|
5
|
+
`unittest.mock`; what it emits is the language-neutral protocol only.
|
|
6
|
+
|
|
7
|
+
Scope: a code object is in scope when its file lies under a source root (`REPO`) or a
|
|
8
|
+
test root (`TEST`). Everything else (stdlib, site-packages, generated `<string>` code) is
|
|
9
|
+
out of scope and its events are disabled at the first hit, so it costs nothing afterwards.
|
|
10
|
+
|
|
11
|
+
Known limits (see docs/experiment-01.md): generators/coroutines are not modelled (a
|
|
12
|
+
stack repair is counted instead), `side_effect`/`wraps` bodies run inside the stand-in
|
|
13
|
+
call and are attributed to it. A stand-in call's arguments and outcome are observed by
|
|
14
|
+
wrapping `unittest.mock`'s private `_mock_call` while tracing (a runtime-adapter detail,
|
|
15
|
+
restored on stop).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import dataclasses
|
|
22
|
+
import hashlib
|
|
23
|
+
import io
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from types import CodeType, ModuleType
|
|
29
|
+
from typing import Any
|
|
30
|
+
from unittest import mock
|
|
31
|
+
|
|
32
|
+
from diffgenome.model import (
|
|
33
|
+
ArgShapes,
|
|
34
|
+
BranchObs,
|
|
35
|
+
CallNode,
|
|
36
|
+
Collector,
|
|
37
|
+
Fidelity,
|
|
38
|
+
Node,
|
|
39
|
+
Origin,
|
|
40
|
+
OsEventKind,
|
|
41
|
+
OsEventNode,
|
|
42
|
+
Plane,
|
|
43
|
+
SourceLocation,
|
|
44
|
+
StateFacts,
|
|
45
|
+
SubstitutionMechanism,
|
|
46
|
+
SubstitutionNode,
|
|
47
|
+
Symbol,
|
|
48
|
+
SymbolId,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
COLLECTOR = Collector("py-sys-monitoring", Plane.SYMBOL, Fidelity.COMPLETE)
|
|
52
|
+
EGRESS_GUARD = Collector("py-egress-guard", Plane.OS, Fidelity.COMPLETE)
|
|
53
|
+
"""Connect attempts seen from inside the runtime. Kernel-boundary facts (OS plane) observed
|
|
54
|
+
by a runtime hook rather than by the kernel; the collector name says so."""
|
|
55
|
+
_TOOL_ID = 4 # sys.monitoring tool slot; coverage.py uses 2/3 by convention
|
|
56
|
+
_MISSING = sys.monitoring.MISSING
|
|
57
|
+
_DISABLE = sys.monitoring.DISABLE
|
|
58
|
+
# Captured at import: targets patch builtins.open (mock_open) during tests, and site maps
|
|
59
|
+
# are built lazily on the capture path.
|
|
60
|
+
_READ_SOURCE = io.open
|
|
61
|
+
_MAX_ARGS = 8
|
|
62
|
+
# Never repository code even when located under a source root: installed dependencies and
|
|
63
|
+
# hidden tool directories (.venv, .tox, .git...). "What counts as the repository" is a
|
|
64
|
+
# known open problem; this is the minimal rule that keeps dependencies out.
|
|
65
|
+
_EXCLUDED_PARTS = {"site-packages", "dist-packages", "node_modules", "__pycache__"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def summarize_value(value: Any) -> str:
|
|
69
|
+
"""Bounded, deterministic shape of a value for seam matching. Never repr()s user data."""
|
|
70
|
+
if value is None or isinstance(value, bool | int | float | str | bytes):
|
|
71
|
+
return type(value).__name__
|
|
72
|
+
if isinstance(value, mock.NonCallableMock):
|
|
73
|
+
spec = getattr(value, "_spec_class", None)
|
|
74
|
+
return f"stand-in:{spec.__qualname__}" if spec else "stand-in"
|
|
75
|
+
if isinstance(value, list | tuple | set | frozenset | dict):
|
|
76
|
+
return f"{type(value).__name__}[{len(value)}]"
|
|
77
|
+
return type(value).__qualname__
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_DIGEST_DEPTH = 3
|
|
81
|
+
_DIGEST_WIDTH = 16 # elements/fields considered per container level
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _canonical(value: Any, depth: int) -> str | None:
|
|
85
|
+
"""Canonical string of a value to a bounded depth, or None when not summarizable.
|
|
86
|
+
Anything None at any level makes the whole digest unavailable: a partial digest would
|
|
87
|
+
let two different values look equal."""
|
|
88
|
+
if value is None or isinstance(value, bool | int | str | bytes):
|
|
89
|
+
return f"{type(value).__name__}:{value!r}"
|
|
90
|
+
if isinstance(value, float):
|
|
91
|
+
return f"float:{value.hex()}"
|
|
92
|
+
if isinstance(value, mock.NonCallableMock) or depth == 0:
|
|
93
|
+
return None
|
|
94
|
+
if isinstance(value, list | tuple | set | frozenset):
|
|
95
|
+
if len(value) > _DIGEST_WIDTH:
|
|
96
|
+
return None
|
|
97
|
+
parts: list[str] = []
|
|
98
|
+
for v in value:
|
|
99
|
+
c = _canonical(v, depth - 1)
|
|
100
|
+
if c is None:
|
|
101
|
+
return None
|
|
102
|
+
parts.append(c)
|
|
103
|
+
if isinstance(value, set | frozenset):
|
|
104
|
+
parts.sort()
|
|
105
|
+
return f"{type(value).__name__}[{','.join(parts)}]"
|
|
106
|
+
if isinstance(value, dict):
|
|
107
|
+
if len(value) > _DIGEST_WIDTH:
|
|
108
|
+
return None
|
|
109
|
+
items: list[str] = []
|
|
110
|
+
for k, v in value.items():
|
|
111
|
+
ck, cv = _canonical(k, depth - 1), _canonical(v, depth - 1)
|
|
112
|
+
if ck is None or cv is None:
|
|
113
|
+
return None
|
|
114
|
+
items.append(f"{ck}={cv}")
|
|
115
|
+
return f"dict{{{','.join(sorted(items))}}}"
|
|
116
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
117
|
+
fields: list[str] = []
|
|
118
|
+
for f in dataclasses.fields(value)[:_DIGEST_WIDTH]:
|
|
119
|
+
c = _canonical(getattr(value, f.name), depth - 1)
|
|
120
|
+
if c is None:
|
|
121
|
+
return None
|
|
122
|
+
fields.append(f"{f.name}={c}")
|
|
123
|
+
return f"{type(value).__qualname__}({','.join(fields)})"
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_STATE_WIDTH = 16
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def bucket(value: Any) -> tuple[str, str] | None:
|
|
131
|
+
"""Language-neutral low-cardinality bucket of a value, or None when the value is not
|
|
132
|
+
branch-relevant enough to summarize (free-form objects, callables). Returns
|
|
133
|
+
(bucket, digest) where digest is filled only for public identifiers (enum members,
|
|
134
|
+
type names). Scalars are never stored: only their bucket."""
|
|
135
|
+
import enum
|
|
136
|
+
|
|
137
|
+
if value is None:
|
|
138
|
+
return "none", ""
|
|
139
|
+
if isinstance(value, bool):
|
|
140
|
+
return f"bool:{'true' if value else 'false'}", ""
|
|
141
|
+
if isinstance(value, enum.Enum):
|
|
142
|
+
return f"enum:{value.name}", digest_value(value.name)
|
|
143
|
+
if isinstance(value, int | float):
|
|
144
|
+
return ("num:zero" if value == 0 else "num:pos" if value > 0 else "num:neg"), ""
|
|
145
|
+
if isinstance(value, str | bytes):
|
|
146
|
+
return ("str:empty" if not value else "str:nonempty"), ""
|
|
147
|
+
if isinstance(value, list | tuple | set | frozenset | dict):
|
|
148
|
+
n = len(value)
|
|
149
|
+
return ("coll:empty" if n == 0 else "coll:one" if n == 1 else "coll:many"), ""
|
|
150
|
+
if isinstance(value, mock.NonCallableMock):
|
|
151
|
+
spec = getattr(value, "_spec_class", None)
|
|
152
|
+
return f"obj:stand-in{':' + spec.__qualname__ if spec else ''}", ""
|
|
153
|
+
if callable(value) and not isinstance(value, type):
|
|
154
|
+
return None
|
|
155
|
+
name = type(value).__qualname__
|
|
156
|
+
return f"obj:{name}", digest_value(name)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def state_facts(
|
|
160
|
+
receiver: Any, code: CodeType | None, module_globals: dict[str, Any] | None
|
|
161
|
+
) -> StateFacts:
|
|
162
|
+
"""Receiver fields (first _STATE_WIDTH) and module globals the code reads, bucketed."""
|
|
163
|
+
facts: list[tuple[str, str, str]] = []
|
|
164
|
+
if receiver is not None and not isinstance(receiver, mock.NonCallableMock):
|
|
165
|
+
facts.append(
|
|
166
|
+
(
|
|
167
|
+
"self:type",
|
|
168
|
+
f"obj:{type(receiver).__qualname__}",
|
|
169
|
+
digest_value(type(receiver).__qualname__),
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
d = getattr(receiver, "__dict__", None)
|
|
173
|
+
if isinstance(d, dict):
|
|
174
|
+
for name, value in list(d.items())[:_STATE_WIDTH]:
|
|
175
|
+
b = bucket(value)
|
|
176
|
+
if b is not None:
|
|
177
|
+
facts.append((f"self.{name}", b[0], b[1]))
|
|
178
|
+
if code is not None and module_globals:
|
|
179
|
+
names = code.co_names[: _STATE_WIDTH * 2]
|
|
180
|
+
for name in names:
|
|
181
|
+
if name in module_globals and not name.startswith("__"):
|
|
182
|
+
value = module_globals[name]
|
|
183
|
+
if isinstance(value, ModuleType | type):
|
|
184
|
+
# state read through an imported module or a class attribute:
|
|
185
|
+
# settings.STRICT, ModelManager._instance. Own namespace only.
|
|
186
|
+
for attr in names:
|
|
187
|
+
if attr != name and attr in vars(value):
|
|
188
|
+
inner = vars(value)[attr]
|
|
189
|
+
if isinstance(
|
|
190
|
+
inner, type | ModuleType | property | classmethod | staticmethod
|
|
191
|
+
) or callable(inner):
|
|
192
|
+
continue
|
|
193
|
+
b = bucket(inner)
|
|
194
|
+
if b is not None:
|
|
195
|
+
facts.append((f"global.{name}.{attr}", b[0], b[1]))
|
|
196
|
+
continue
|
|
197
|
+
if callable(value):
|
|
198
|
+
continue
|
|
199
|
+
own = getattr(value, "__dict__", None)
|
|
200
|
+
if isinstance(own, dict) and not isinstance(value, mock.NonCallableMock):
|
|
201
|
+
# a global object (settings, config): the attributes this code reads
|
|
202
|
+
# through it, from its own instance dict only (no descriptors run)
|
|
203
|
+
for attr in names:
|
|
204
|
+
if attr != name and attr in own:
|
|
205
|
+
inner = own[attr]
|
|
206
|
+
if isinstance(inner, type | ModuleType) or callable(inner):
|
|
207
|
+
continue
|
|
208
|
+
b = bucket(inner)
|
|
209
|
+
if b is not None:
|
|
210
|
+
facts.append((f"global.{name}.{attr}", b[0], b[1]))
|
|
211
|
+
b = bucket(value)
|
|
212
|
+
if b is not None:
|
|
213
|
+
facts.append((f"global.{name}", b[0], b[1]))
|
|
214
|
+
return tuple(facts[: _STATE_WIDTH * 2])
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def digest_value(value: Any) -> str:
|
|
218
|
+
"""Stable content digest of a value, or "" when unavailable. Uses sha256 (not hash())
|
|
219
|
+
so digests agree across processes and runs. Short scalars are trivially reversible by
|
|
220
|
+
brute force; the digest hides values from casual reading, not from an adversary."""
|
|
221
|
+
canonical = _canonical(value, _DIGEST_DEPTH)
|
|
222
|
+
return "" if canonical is None else hashlib.sha256(canonical.encode()).hexdigest()[:16]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass
|
|
226
|
+
class _Scope:
|
|
227
|
+
origin: Origin
|
|
228
|
+
symbol: SymbolId
|
|
229
|
+
location: SourceLocation
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
Pos = tuple[int, int] # (line, 1-based byte column)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass(frozen=True)
|
|
236
|
+
class _IfSite:
|
|
237
|
+
site: str
|
|
238
|
+
test: tuple[Pos, Pos] # [start, end) of the condition
|
|
239
|
+
body: tuple[Pos, Pos] # [start, end) of the then-block
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def if_sites(source: str, rel_path: str) -> list[_IfSite]:
|
|
243
|
+
"""Every `if` statement's condition and then-block span in original source."""
|
|
244
|
+
from diffgenome.sites import site_id
|
|
245
|
+
|
|
246
|
+
out: list[_IfSite] = []
|
|
247
|
+
for node in ast.walk(ast.parse(source)):
|
|
248
|
+
if not isinstance(node, ast.If) or node.test.end_lineno is None:
|
|
249
|
+
continue
|
|
250
|
+
t = node.test
|
|
251
|
+
span = (t.lineno, t.col_offset + 1, t.end_lineno or t.lineno, (t.end_col_offset or 0) + 1)
|
|
252
|
+
first, last = node.body[0], node.body[-1]
|
|
253
|
+
out.append(
|
|
254
|
+
_IfSite(
|
|
255
|
+
site_id(rel_path, span),
|
|
256
|
+
((span[0], span[1]), (span[2], span[3])),
|
|
257
|
+
(
|
|
258
|
+
(first.lineno, first.col_offset + 1),
|
|
259
|
+
(last.end_lineno or last.lineno, (last.end_col_offset or 0) + 1),
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _inside(p: Pos, span: tuple[Pos, Pos]) -> bool:
|
|
267
|
+
return span[0] <= p < span[1]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass
|
|
271
|
+
class TraceResult:
|
|
272
|
+
symbols: tuple[Symbol, ...]
|
|
273
|
+
nodes: tuple[Node, ...]
|
|
274
|
+
stack_repairs: int = 0 # returns that did not match the shadow-stack top
|
|
275
|
+
attribution_disagreements: int = (
|
|
276
|
+
0 # calls where a shadow stack would have chosen another parent
|
|
277
|
+
)
|
|
278
|
+
branches: tuple[BranchObs, ...] = ()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@dataclass
|
|
282
|
+
class Tracer:
|
|
283
|
+
"""One tracer per process. `start()`/`stop()` bracket a single stimulus."""
|
|
284
|
+
|
|
285
|
+
repo_root: Path
|
|
286
|
+
source_roots: tuple[Path, ...]
|
|
287
|
+
test_roots: tuple[Path, ...]
|
|
288
|
+
_scopes: dict[CodeType, _Scope | None] = field(default_factory=dict, repr=False)
|
|
289
|
+
_symbols: dict[SymbolId, Symbol] = field(default_factory=dict, repr=False)
|
|
290
|
+
_nodes: list[Node] = field(default_factory=list, repr=False)
|
|
291
|
+
_stacks: dict[int, list[tuple[CodeType | None, int]]] = field(default_factory=dict, repr=False)
|
|
292
|
+
_threads: dict[int, int] = field(default_factory=dict, repr=False)
|
|
293
|
+
_frame_nodes: dict[int, int] = field(default_factory=dict, repr=False) # id(frame) -> node
|
|
294
|
+
_fake_wrappers: dict[int, int] = field(
|
|
295
|
+
default_factory=dict, repr=False
|
|
296
|
+
) # call node -> sub node
|
|
297
|
+
_root_code: CodeType | None = None
|
|
298
|
+
_stack_repairs: int = 0
|
|
299
|
+
_attribution_disagreements: int = 0
|
|
300
|
+
_pending_mock_calls: dict[int, list[int]] = field(default_factory=dict, repr=False)
|
|
301
|
+
_orig_mock_call: Any = None
|
|
302
|
+
_patched: dict[int, tuple[Any, str, Any]] = field(default_factory=dict, repr=False)
|
|
303
|
+
_orig_patch_enter: Any = None
|
|
304
|
+
_orig_patch_exit: Any = None
|
|
305
|
+
_egress_guard_installed: bool = False
|
|
306
|
+
_branches: list[BranchObs] = field(default_factory=list, repr=False)
|
|
307
|
+
_file_sites: dict[str, list[_IfSite]] = field(default_factory=dict, repr=False)
|
|
308
|
+
_positions: dict[CodeType, list[Any]] = field(default_factory=dict, repr=False)
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------ symbol naming
|
|
311
|
+
|
|
312
|
+
def __post_init__(self) -> None:
|
|
313
|
+
# Everything below runs on the capture path and must not touch os.path, pathlib or
|
|
314
|
+
# inspect: those read patchable globals, and a target that patches os.path.join
|
|
315
|
+
# renamed our symbols after its temp file (Kokoro-FastAPI, experiment 02).
|
|
316
|
+
self._repo_prefix = str(self.repo_root).rstrip("/") + "/"
|
|
317
|
+
self._root_prefixes = [(Origin.TEST, str(r).rstrip("/") + "/") for r in self.test_roots] + [
|
|
318
|
+
(Origin.REPO, str(r).rstrip("/") + "/") for r in self.source_roots
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
def _module_for(self, filename: str) -> tuple[Origin, str] | None:
|
|
322
|
+
for origin, prefix in self._root_prefixes:
|
|
323
|
+
if not filename.startswith(prefix):
|
|
324
|
+
continue
|
|
325
|
+
rel = filename[len(prefix) :]
|
|
326
|
+
parts = rel.split("/")
|
|
327
|
+
if any(part in _EXCLUDED_PARTS or part.startswith(".") for part in parts):
|
|
328
|
+
return None
|
|
329
|
+
if parts[-1].endswith(".py"):
|
|
330
|
+
parts[-1] = parts[-1][:-3]
|
|
331
|
+
if parts and parts[-1] == "__init__":
|
|
332
|
+
parts.pop()
|
|
333
|
+
if origin is Origin.TEST:
|
|
334
|
+
parts = prefix[len(self._repo_prefix) :].rstrip("/").split("/") + parts
|
|
335
|
+
return origin, ".".join(p for p in parts if p)
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
def _relative(self, filename: str) -> str:
|
|
339
|
+
return (
|
|
340
|
+
filename[len(self._repo_prefix) :]
|
|
341
|
+
if filename.startswith(self._repo_prefix)
|
|
342
|
+
else filename
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def scope_for(self, code: CodeType) -> _Scope | None:
|
|
346
|
+
if code in self._scopes:
|
|
347
|
+
return self._scopes[code]
|
|
348
|
+
scope = None
|
|
349
|
+
filename = code.co_filename
|
|
350
|
+
if not filename.startswith("<"):
|
|
351
|
+
named = self._module_for(filename)
|
|
352
|
+
if named:
|
|
353
|
+
origin, module = named
|
|
354
|
+
qualname = code.co_qualname
|
|
355
|
+
if qualname.rsplit(".", 1)[-1].startswith("<"):
|
|
356
|
+
# Anonymous code (<lambda>, <genexpr>, <listcomp>...) has no name of its
|
|
357
|
+
# own: two lambdas in one function share a qualname. Its identity is
|
|
358
|
+
# its definition site. Found as an IdentityMismatch on Kokoro-FastAPI.
|
|
359
|
+
qualname = f"{qualname}@{code.co_firstlineno}"
|
|
360
|
+
scope = _Scope(
|
|
361
|
+
origin,
|
|
362
|
+
f"py:{module}.{qualname}",
|
|
363
|
+
SourceLocation(self._relative(filename), code.co_firstlineno),
|
|
364
|
+
)
|
|
365
|
+
self._scopes[code] = scope
|
|
366
|
+
return scope
|
|
367
|
+
|
|
368
|
+
def symbol_for_code(self, code: CodeType) -> SymbolId | None:
|
|
369
|
+
scope = self.scope_for(code)
|
|
370
|
+
return scope.symbol if scope else None
|
|
371
|
+
|
|
372
|
+
def symbol_for_object(self, obj: Any) -> Symbol:
|
|
373
|
+
"""Identity of a class/function reached by *object*, not by frame, with the origin
|
|
374
|
+
and location the collector can observe for it. Uses the same file-based naming as
|
|
375
|
+
frames when the object has a code object or an in-scope module file; otherwise a
|
|
376
|
+
module-based name, which is a second identity scheme (see exp 01 findings).
|
|
377
|
+
Reads only attributes, never os.path/inspect (see __post_init__)."""
|
|
378
|
+
code = getattr(obj, "__code__", None)
|
|
379
|
+
if isinstance(code, CodeType) and (scope := self.scope_for(code)):
|
|
380
|
+
return Symbol(scope.symbol, scope.origin, scope.location)
|
|
381
|
+
if isinstance(obj, ModuleType):
|
|
382
|
+
# A patched module attribute (patch("pkg.mod.torch")) claims the module itself.
|
|
383
|
+
file = getattr(obj, "__file__", None)
|
|
384
|
+
named = self._module_for(file) if isinstance(file, str) else None
|
|
385
|
+
if named and isinstance(file, str):
|
|
386
|
+
return Symbol(f"py:{named[1]}", named[0], SourceLocation(self._relative(file), 0))
|
|
387
|
+
return Symbol(f"py:{obj.__name__}", Origin.EXTERNAL, None)
|
|
388
|
+
qualname = getattr(obj, "__qualname__", type(obj).__qualname__)
|
|
389
|
+
owner = getattr(obj, "__objclass__", None)
|
|
390
|
+
module_name = getattr(obj, "__module__", None) or (owner.__module__ if owner else None)
|
|
391
|
+
module = sys.modules.get(module_name) if module_name else None
|
|
392
|
+
file = getattr(module, "__file__", None)
|
|
393
|
+
if isinstance(file, str):
|
|
394
|
+
named = self._module_for(file)
|
|
395
|
+
if named:
|
|
396
|
+
line = code.co_firstlineno if isinstance(code, CodeType) else 0
|
|
397
|
+
loc = SourceLocation(self._relative(file), line)
|
|
398
|
+
return Symbol(f"py:{named[1]}.{qualname}", named[0], loc)
|
|
399
|
+
if module_name is None:
|
|
400
|
+
return Symbol(f"py:{type(obj).__module__}.{qualname}", Origin.UNKNOWN, None)
|
|
401
|
+
# A real object in this process whose module is absent or out of scope: external.
|
|
402
|
+
# C-implemented members carry no __module__; their defining class does (exp 01).
|
|
403
|
+
return Symbol(f"py:{module_name}.{qualname}", Origin.EXTERNAL, None)
|
|
404
|
+
|
|
405
|
+
def _intern(
|
|
406
|
+
self,
|
|
407
|
+
symbol: SymbolId,
|
|
408
|
+
origin: Origin,
|
|
409
|
+
location: SourceLocation | None,
|
|
410
|
+
kind: str = "callable",
|
|
411
|
+
) -> None:
|
|
412
|
+
if symbol not in self._symbols or (
|
|
413
|
+
kind != "callable" and self._symbols[symbol].kind == "callable"
|
|
414
|
+
):
|
|
415
|
+
self._symbols[symbol] = Symbol(symbol, origin, location, kind)
|
|
416
|
+
|
|
417
|
+
def _intern_symbol(self, symbol: Symbol) -> SymbolId:
|
|
418
|
+
self._intern(symbol.id, symbol.origin, symbol.location)
|
|
419
|
+
return symbol.id
|
|
420
|
+
|
|
421
|
+
# ------------------------------------------------------------------ stand-ins
|
|
422
|
+
|
|
423
|
+
def _describe_mock(
|
|
424
|
+
self, m: Any, frame: Any = None
|
|
425
|
+
) -> tuple[SubstitutionMechanism, SymbolId | None, str, tuple[str, ...]]:
|
|
426
|
+
path: list[str] = []
|
|
427
|
+
root = m
|
|
428
|
+
while True:
|
|
429
|
+
name = root._mock_name or root._mock_new_name
|
|
430
|
+
if name:
|
|
431
|
+
path.append(name)
|
|
432
|
+
parent = root._mock_parent or root._mock_new_parent
|
|
433
|
+
if parent is None:
|
|
434
|
+
break
|
|
435
|
+
root = parent
|
|
436
|
+
path.reverse()
|
|
437
|
+
patched = self._patched.get(id(root))
|
|
438
|
+
if patched is not None:
|
|
439
|
+
# Installed by unittest.mock.patch: the patcher saved the original, which is
|
|
440
|
+
# exactly what the stand-in replaces. path[0] is the patched attribute.
|
|
441
|
+
_, _, original = patched
|
|
442
|
+
claim = None
|
|
443
|
+
if original is not mock.DEFAULT:
|
|
444
|
+
original = getattr(original, "__func__", getattr(original, "fget", original))
|
|
445
|
+
if len(path) <= 1 and isinstance(original, type):
|
|
446
|
+
# Calling a patched *class* constructs it; the claim is its __init__
|
|
447
|
+
# when that is in-repo Python (same rule as spec'd class stand-ins).
|
|
448
|
+
init = _static_attr(original, "__init__")
|
|
449
|
+
if getattr(init, "__code__", None) is not None:
|
|
450
|
+
original = init
|
|
451
|
+
else:
|
|
452
|
+
sym = self.symbol_for_object(original)
|
|
453
|
+
self._intern(sym.id, sym.origin, sym.location, "declaration")
|
|
454
|
+
return (
|
|
455
|
+
SubstitutionMechanism.INTERPOSITION,
|
|
456
|
+
sym.id,
|
|
457
|
+
"patch-target",
|
|
458
|
+
tuple(path),
|
|
459
|
+
)
|
|
460
|
+
claim = self._intern_symbol(self.symbol_for_object(original))
|
|
461
|
+
return SubstitutionMechanism.INTERPOSITION, claim, "patch-target", tuple(path)
|
|
462
|
+
spec = getattr(root, "_spec_class", None)
|
|
463
|
+
if spec is None:
|
|
464
|
+
bound = self._bound_attribute(root, frame)
|
|
465
|
+
if bound is not None:
|
|
466
|
+
# The root stand-in is stored as attribute `name` of a real object whose
|
|
467
|
+
# class defines `name`: it replaced that member. Identity, not naming.
|
|
468
|
+
_, name, bound_member = bound
|
|
469
|
+
bound_member = getattr(
|
|
470
|
+
bound_member, "__func__", getattr(bound_member, "fget", bound_member)
|
|
471
|
+
)
|
|
472
|
+
claim = self._intern_symbol(self.symbol_for_object(bound_member))
|
|
473
|
+
return (
|
|
474
|
+
SubstitutionMechanism.INTERPOSITION,
|
|
475
|
+
claim,
|
|
476
|
+
"instance-attribute",
|
|
477
|
+
(name, *path[1:]) if path and path[0] == name else (name, *path),
|
|
478
|
+
)
|
|
479
|
+
return SubstitutionMechanism.MOCK_OBJECT, None, "none", tuple(path)
|
|
480
|
+
member: Any = None
|
|
481
|
+
if path and path[0] != "()":
|
|
482
|
+
member = _static_attr(spec, path[0])
|
|
483
|
+
member = getattr(member, "__func__", getattr(member, "fget", member))
|
|
484
|
+
elif not path and isinstance(spec, type):
|
|
485
|
+
# Calling a stand-in for a class is constructing it: what really runs is the
|
|
486
|
+
# class's own __init__ (when it has one in Python), so that is the claim.
|
|
487
|
+
init = _static_attr(spec, "__init__")
|
|
488
|
+
if getattr(init, "__code__", None) is not None:
|
|
489
|
+
member = init
|
|
490
|
+
else:
|
|
491
|
+
# No in-repo constructor body: the claim is a declaration, not a gap.
|
|
492
|
+
sym = self.symbol_for_object(spec)
|
|
493
|
+
self._intern(sym.id, sym.origin, sym.location, "declaration")
|
|
494
|
+
return SubstitutionMechanism.MOCK_OBJECT, sym.id, "spec", tuple(path)
|
|
495
|
+
target = self._intern_symbol(self.symbol_for_object(member if member is not None else spec))
|
|
496
|
+
return SubstitutionMechanism.MOCK_OBJECT, target, "spec", tuple(path)
|
|
497
|
+
|
|
498
|
+
def _bound_attribute(self, root: Any, frame: Any) -> tuple[type, str, Any] | None:
|
|
499
|
+
"""Find a real (non-mock) object among the caller's locals, `self` first, whose
|
|
500
|
+
instance dict holds `root` under a name its class also defines. Bounded scan; reads
|
|
501
|
+
only __dict__ so no descriptor or property runs."""
|
|
502
|
+
if frame is None:
|
|
503
|
+
return None
|
|
504
|
+
locals_ = frame.f_locals
|
|
505
|
+
candidates = [locals_[n] for n in ("self", "cls") if n in locals_]
|
|
506
|
+
candidates += [v for k, v in list(locals_.items())[:_MAX_ARGS] if k not in ("self", "cls")]
|
|
507
|
+
for obj in candidates:
|
|
508
|
+
if isinstance(obj, mock.NonCallableMock | type(None) | int | str | float | bool):
|
|
509
|
+
continue
|
|
510
|
+
d = getattr(obj, "__dict__", None)
|
|
511
|
+
if not isinstance(d, dict):
|
|
512
|
+
continue
|
|
513
|
+
for name, value in d.items():
|
|
514
|
+
if value is root:
|
|
515
|
+
cls = obj if isinstance(obj, type) else type(obj)
|
|
516
|
+
member = _static_attr(cls, name)
|
|
517
|
+
if member is not None:
|
|
518
|
+
return cls, name, member
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
def _seam_state(self, callable_: Any, frame: Any, relation: str) -> StateFacts:
|
|
522
|
+
"""State the real target would see at this seam, when the seed exposes it."""
|
|
523
|
+
if frame is None:
|
|
524
|
+
return ()
|
|
525
|
+
root = callable_
|
|
526
|
+
while isinstance(root, mock.NonCallableMock) and (
|
|
527
|
+
root._mock_parent or root._mock_new_parent
|
|
528
|
+
):
|
|
529
|
+
root = root._mock_parent or root._mock_new_parent
|
|
530
|
+
if relation == "instance-attribute":
|
|
531
|
+
bound = self._bound_attribute(root, frame)
|
|
532
|
+
if bound is not None:
|
|
533
|
+
obj = None
|
|
534
|
+
for cand in list(frame.f_locals.values())[: _MAX_ARGS + 2]:
|
|
535
|
+
d = getattr(cand, "__dict__", None)
|
|
536
|
+
if isinstance(d, dict) and any(v is root for v in d.values()):
|
|
537
|
+
obj = cand
|
|
538
|
+
break
|
|
539
|
+
member = bound[2]
|
|
540
|
+
func = getattr(member, "__func__", getattr(member, "fget", member))
|
|
541
|
+
code = getattr(func, "__code__", None)
|
|
542
|
+
return state_facts(obj, code, getattr(func, "__globals__", None))
|
|
543
|
+
if relation == "patch-target":
|
|
544
|
+
patched = self._patched.get(id(root))
|
|
545
|
+
if patched is not None:
|
|
546
|
+
target_obj, _, original = patched
|
|
547
|
+
if (
|
|
548
|
+
isinstance(target_obj, ModuleType)
|
|
549
|
+
and getattr(original, "__code__", None) is not None
|
|
550
|
+
):
|
|
551
|
+
# the original's own globals: a function patched where it was imported
|
|
552
|
+
# still reads state from the module that defined it
|
|
553
|
+
return state_facts(
|
|
554
|
+
None, original.__code__, getattr(original, "__globals__", vars(target_obj))
|
|
555
|
+
)
|
|
556
|
+
if not isinstance(target_obj, type | ModuleType):
|
|
557
|
+
func = getattr(original, "__func__", getattr(original, "fget", original))
|
|
558
|
+
return state_facts(
|
|
559
|
+
target_obj,
|
|
560
|
+
getattr(func, "__code__", None),
|
|
561
|
+
getattr(func, "__globals__", None),
|
|
562
|
+
)
|
|
563
|
+
return ()
|
|
564
|
+
|
|
565
|
+
def _record_substitution(
|
|
566
|
+
self,
|
|
567
|
+
parent: int,
|
|
568
|
+
mechanism: SubstitutionMechanism,
|
|
569
|
+
callable_: Any,
|
|
570
|
+
args: ArgShapes,
|
|
571
|
+
frame: Any = None,
|
|
572
|
+
) -> int:
|
|
573
|
+
state: StateFacts = ()
|
|
574
|
+
if isinstance(callable_, mock.NonCallableMock):
|
|
575
|
+
substitute = f"py:{type(callable_).__module__}.{type(callable_).__qualname__}"
|
|
576
|
+
mechanism, claimed, relation, path = self._describe_mock(callable_, frame)
|
|
577
|
+
state = self._seam_state(callable_, frame, relation)
|
|
578
|
+
else:
|
|
579
|
+
substitute = self._intern_symbol(self.symbol_for_object(callable_))
|
|
580
|
+
claimed, relation, path = None, "none", ()
|
|
581
|
+
node = SubstitutionNode(
|
|
582
|
+
id=len(self._nodes),
|
|
583
|
+
parent=parent,
|
|
584
|
+
collector=0,
|
|
585
|
+
mechanism=mechanism,
|
|
586
|
+
substitute=substitute,
|
|
587
|
+
claimed_target=claimed,
|
|
588
|
+
relation=relation,
|
|
589
|
+
path=path,
|
|
590
|
+
args=args,
|
|
591
|
+
state=state,
|
|
592
|
+
)
|
|
593
|
+
self._nodes.append(node)
|
|
594
|
+
return node.id
|
|
595
|
+
|
|
596
|
+
# ------------------------------------------------------------------ attribution
|
|
597
|
+
|
|
598
|
+
def _thread(self) -> int:
|
|
599
|
+
ident = threading.get_ident()
|
|
600
|
+
if ident not in self._threads:
|
|
601
|
+
self._threads[ident] = len(self._threads)
|
|
602
|
+
self._stacks[ident] = []
|
|
603
|
+
return ident
|
|
604
|
+
|
|
605
|
+
def _ancestor_node(self, frame: Any) -> int:
|
|
606
|
+
"""Node of the nearest in-scope frame on the real call stack above `frame`, or the
|
|
607
|
+
root. Correct under generators and coroutines, where a shadow stack is not: a
|
|
608
|
+
suspended frame is not an ancestor of what runs while it is suspended."""
|
|
609
|
+
f = frame.f_back
|
|
610
|
+
while f is not None:
|
|
611
|
+
node = self._frame_nodes.get(id(f))
|
|
612
|
+
if node is not None:
|
|
613
|
+
return node
|
|
614
|
+
f = f.f_back
|
|
615
|
+
return 0
|
|
616
|
+
|
|
617
|
+
def _shadow_parent(self, ident: int) -> int:
|
|
618
|
+
stack = self._stacks[ident]
|
|
619
|
+
return stack[-1][1] if stack else 0
|
|
620
|
+
|
|
621
|
+
# ------------------------------------------------------------------ callbacks
|
|
622
|
+
|
|
623
|
+
def _on_start(self, code: CodeType, _offset: int) -> Any:
|
|
624
|
+
scope = self.scope_for(code)
|
|
625
|
+
if scope is None:
|
|
626
|
+
return _DISABLE
|
|
627
|
+
ident = self._thread()
|
|
628
|
+
frame = sys._getframe(1)
|
|
629
|
+
stack = self._stacks[ident]
|
|
630
|
+
if (
|
|
631
|
+
code is self._root_code
|
|
632
|
+
and self._threads[ident] == 0
|
|
633
|
+
and 0 not in self._frame_nodes.values()
|
|
634
|
+
):
|
|
635
|
+
self._frame_nodes[id(frame)] = 0
|
|
636
|
+
stack.append((code, 0))
|
|
637
|
+
return None
|
|
638
|
+
names = code.co_varnames[: code.co_argcount + code.co_kwonlyargcount][:_MAX_ARGS]
|
|
639
|
+
args: ArgShapes = tuple(
|
|
640
|
+
(n, summarize_value(frame.f_locals[n]), digest_value(frame.f_locals[n]))
|
|
641
|
+
for n in names
|
|
642
|
+
if n not in ("self", "cls") and n in frame.f_locals
|
|
643
|
+
)
|
|
644
|
+
parent = self._ancestor_node(frame)
|
|
645
|
+
if parent != self._shadow_parent(ident):
|
|
646
|
+
self._attribution_disagreements += 1
|
|
647
|
+
parent_symbol = getattr(self._nodes[parent], "symbol", None)
|
|
648
|
+
parent_origin = self._symbols[parent_symbol].origin if parent_symbol else Origin.UNKNOWN
|
|
649
|
+
sub_id = None
|
|
650
|
+
if scope.origin is Origin.TEST and parent_origin is Origin.REPO:
|
|
651
|
+
# Production code called into test-defined code: a fake/stub that executes.
|
|
652
|
+
mechanism = (
|
|
653
|
+
SubstitutionMechanism.FAKE
|
|
654
|
+
if "." in code.co_qualname
|
|
655
|
+
else SubstitutionMechanism.STUB
|
|
656
|
+
)
|
|
657
|
+
sub_id = self._record_substitution(parent, mechanism, _CodeHolder(code), args)
|
|
658
|
+
recv = (
|
|
659
|
+
frame.f_locals.get("self")
|
|
660
|
+
if code.co_argcount and code.co_varnames[0] == "self"
|
|
661
|
+
else None
|
|
662
|
+
)
|
|
663
|
+
if recv is not None:
|
|
664
|
+
sub = self._nodes[sub_id]
|
|
665
|
+
assert isinstance(sub, SubstitutionNode)
|
|
666
|
+
self._nodes[sub_id] = dataclasses.replace(sub, state=state_facts(recv, None, None))
|
|
667
|
+
parent = sub_id
|
|
668
|
+
self._intern(scope.symbol, scope.origin, scope.location)
|
|
669
|
+
receiver = (
|
|
670
|
+
frame.f_locals.get("self")
|
|
671
|
+
if code.co_argcount and code.co_varnames[0] == "self"
|
|
672
|
+
else None
|
|
673
|
+
)
|
|
674
|
+
node = CallNode(
|
|
675
|
+
id=len(self._nodes),
|
|
676
|
+
parent=parent,
|
|
677
|
+
symbol=scope.symbol,
|
|
678
|
+
collector=0,
|
|
679
|
+
args=args,
|
|
680
|
+
thread=self._threads[ident],
|
|
681
|
+
state=state_facts(receiver, code, frame.f_globals),
|
|
682
|
+
)
|
|
683
|
+
self._nodes.append(node)
|
|
684
|
+
self._frame_nodes[id(frame)] = node.id
|
|
685
|
+
if sub_id is not None:
|
|
686
|
+
self._fake_wrappers[node.id] = sub_id
|
|
687
|
+
stack.append((code, node.id))
|
|
688
|
+
return None
|
|
689
|
+
|
|
690
|
+
def _set_outcome(self, node_id: int, outcome: str, result: str = "") -> None:
|
|
691
|
+
node = self._nodes[node_id]
|
|
692
|
+
assert not isinstance(node, OsEventNode)
|
|
693
|
+
self._nodes[node_id] = dataclasses.replace(node, outcome=outcome, result=result)
|
|
694
|
+
|
|
695
|
+
def _finish(self, code: CodeType, outcome: str, result: str = "") -> None:
|
|
696
|
+
frame = sys._getframe(2)
|
|
697
|
+
node_id = self._frame_nodes.pop(id(frame), None)
|
|
698
|
+
if node_id:
|
|
699
|
+
self._set_outcome(node_id, outcome, result)
|
|
700
|
+
node = self._nodes[node_id]
|
|
701
|
+
if isinstance(node, CallNode):
|
|
702
|
+
receiver = (
|
|
703
|
+
frame.f_locals.get("self")
|
|
704
|
+
if code.co_argcount and code.co_varnames[0] == "self"
|
|
705
|
+
else None
|
|
706
|
+
)
|
|
707
|
+
# the same bounded, value-free view as on entry: an observed delta, not a
|
|
708
|
+
# complete write set (nested mutations can be invisible to it)
|
|
709
|
+
self._nodes[node_id] = dataclasses.replace(
|
|
710
|
+
node, state_after=state_facts(receiver, code, frame.f_globals)
|
|
711
|
+
)
|
|
712
|
+
wrapper = self._fake_wrappers.pop(node_id, None)
|
|
713
|
+
if wrapper is not None:
|
|
714
|
+
self._set_outcome(wrapper, outcome, result)
|
|
715
|
+
# Shadow stack, kept only to measure how often it would have misattributed.
|
|
716
|
+
stack = self._stacks.get(threading.get_ident())
|
|
717
|
+
if not stack:
|
|
718
|
+
return
|
|
719
|
+
if stack[-1][0] is not code:
|
|
720
|
+
self._stack_repairs += 1
|
|
721
|
+
while stack and stack[-1][0] is not code:
|
|
722
|
+
stack.pop()
|
|
723
|
+
if stack:
|
|
724
|
+
stack.pop()
|
|
725
|
+
|
|
726
|
+
# PY_RETURN/PY_UNWIND cannot be disabled per location (CPython raises), so
|
|
727
|
+
# out-of-scope returns are simply ignored; PY_START's DISABLE keeps the cost down.
|
|
728
|
+
def _on_return(self, code: CodeType, _offset: int, value: object) -> Any:
|
|
729
|
+
if self.scope_for(code) is not None:
|
|
730
|
+
self._finish(code, "returned", digest_value(value))
|
|
731
|
+
return None
|
|
732
|
+
|
|
733
|
+
def _on_unwind(self, code: CodeType, _offset: int, exc: BaseException) -> Any:
|
|
734
|
+
if self.scope_for(code) is not None:
|
|
735
|
+
self._finish(code, "raised:" + self._intern_symbol(self.symbol_for_object(type(exc))))
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
def _mock_call(self, m: Any, /, *args: Any, **kwargs: Any) -> Any:
|
|
739
|
+
"""Wraps unittest.mock's call path while tracing, so a stand-in call's arguments and
|
|
740
|
+
outcome are observed rather than inferred. The CALL event already recorded the node."""
|
|
741
|
+
pending = self._pending_mock_calls.get(threading.get_ident())
|
|
742
|
+
node_id = pending.pop() if pending else None
|
|
743
|
+
if node_id is not None:
|
|
744
|
+
shapes: ArgShapes = tuple(
|
|
745
|
+
[
|
|
746
|
+
(f"arg{i}", summarize_value(a), digest_value(a))
|
|
747
|
+
for i, a in enumerate(args[:_MAX_ARGS])
|
|
748
|
+
]
|
|
749
|
+
+ [
|
|
750
|
+
(k, summarize_value(v), digest_value(v))
|
|
751
|
+
for k, v in list(kwargs.items())[:_MAX_ARGS]
|
|
752
|
+
]
|
|
753
|
+
)
|
|
754
|
+
node = self._nodes[node_id]
|
|
755
|
+
assert isinstance(node, SubstitutionNode)
|
|
756
|
+
self._nodes[node_id] = dataclasses.replace(node, args=shapes)
|
|
757
|
+
try:
|
|
758
|
+
result = self._orig_mock_call(m, *args, **kwargs)
|
|
759
|
+
except BaseException as exc:
|
|
760
|
+
if node_id is not None:
|
|
761
|
+
self._set_outcome(
|
|
762
|
+
node_id, "raised:" + self._intern_symbol(self.symbol_for_object(type(exc)))
|
|
763
|
+
)
|
|
764
|
+
raise
|
|
765
|
+
if node_id is not None:
|
|
766
|
+
self._set_outcome(node_id, "returned", digest_value(result))
|
|
767
|
+
return result
|
|
768
|
+
|
|
769
|
+
def _sites_for(self, filename: str) -> list[_IfSite]:
|
|
770
|
+
sites = self._file_sites.get(filename)
|
|
771
|
+
if sites is None:
|
|
772
|
+
try:
|
|
773
|
+
with _READ_SOURCE(filename, encoding="utf-8") as fh:
|
|
774
|
+
sites = if_sites(fh.read(), self._relative(filename))
|
|
775
|
+
except (OSError, SyntaxError, ValueError):
|
|
776
|
+
sites = []
|
|
777
|
+
self._file_sites[filename] = sites
|
|
778
|
+
return sites
|
|
779
|
+
|
|
780
|
+
def _on_branch(self, code: CodeType, offset: int, destination: int) -> Any:
|
|
781
|
+
"""A conditional jump in production code. Mapped to the `if` whose condition
|
|
782
|
+
contains the jump instruction; the outcome is whether control goes into that
|
|
783
|
+
if's then-block. Jumps that stay inside the condition (short-circuit operands)
|
|
784
|
+
are not decisions and are ignored; jumps of anything that is not an `if`
|
|
785
|
+
condition are disabled at that location."""
|
|
786
|
+
scope = self.scope_for(code)
|
|
787
|
+
if scope is None or scope.origin is not Origin.REPO:
|
|
788
|
+
return _DISABLE
|
|
789
|
+
positions = self._positions.get(code)
|
|
790
|
+
if positions is None:
|
|
791
|
+
positions = self._positions[code] = list(code.co_positions())
|
|
792
|
+
try:
|
|
793
|
+
line, _eline, col, _ecol = positions[offset // 2]
|
|
794
|
+
dline, _deline, dcol, _decol = positions[destination // 2]
|
|
795
|
+
except IndexError:
|
|
796
|
+
return None
|
|
797
|
+
if line is None or col is None:
|
|
798
|
+
return None
|
|
799
|
+
here = (line, col + 1)
|
|
800
|
+
match = None
|
|
801
|
+
for site in self._sites_for(code.co_filename):
|
|
802
|
+
if _inside(here, site.test) and (
|
|
803
|
+
match is None or site.test[0] >= match.test[0] # innermost
|
|
804
|
+
):
|
|
805
|
+
match = site
|
|
806
|
+
if match is None:
|
|
807
|
+
return _DISABLE
|
|
808
|
+
if dline is None or dcol is None:
|
|
809
|
+
return None
|
|
810
|
+
there = (dline, dcol + 1)
|
|
811
|
+
if _inside(there, match.test):
|
|
812
|
+
return None # short-circuit inside the condition
|
|
813
|
+
outcome = _inside(there, match.body)
|
|
814
|
+
node = self._frame_nodes.get(id(sys._getframe(1)), 0)
|
|
815
|
+
self._branches.append(BranchObs(match.site, outcome, node, len(self._nodes)))
|
|
816
|
+
return None
|
|
817
|
+
|
|
818
|
+
def _on_call(self, code: CodeType, _offset: int, callable_: object, arg0: object) -> Any:
|
|
819
|
+
if self.scope_for(code) is None:
|
|
820
|
+
return _DISABLE
|
|
821
|
+
# create_autospec(function) returns a plain function wrapper whose `.mock` is the
|
|
822
|
+
# mock; without this it was invisible to the tracer (found by ground truth, exp 05).
|
|
823
|
+
if not isinstance(callable_, mock.NonCallableMock) and isinstance(
|
|
824
|
+
getattr(callable_, "mock", None), mock.NonCallableMock
|
|
825
|
+
):
|
|
826
|
+
inner = callable_.mock # type: ignore[attr-defined]
|
|
827
|
+
if id(callable_) in self._patched and id(inner) not in self._patched:
|
|
828
|
+
self._patched[id(inner)] = self._patched[id(callable_)]
|
|
829
|
+
callable_ = inner
|
|
830
|
+
if isinstance(callable_, mock.NonCallableMock):
|
|
831
|
+
args: ArgShapes = (
|
|
832
|
+
() if arg0 is _MISSING else (("arg0", summarize_value(arg0), digest_value(arg0)),)
|
|
833
|
+
)
|
|
834
|
+
ident = self._thread()
|
|
835
|
+
frame = sys._getframe(1)
|
|
836
|
+
parent = self._frame_nodes.get(id(frame), self._ancestor_node(frame))
|
|
837
|
+
node_id = self._record_substitution(
|
|
838
|
+
parent, SubstitutionMechanism.MOCK_OBJECT, callable_, args, frame
|
|
839
|
+
)
|
|
840
|
+
self._pending_mock_calls.setdefault(ident, []).append(node_id)
|
|
841
|
+
return None
|
|
842
|
+
|
|
843
|
+
# ------------------------------------------------------------------ egress guard
|
|
844
|
+
|
|
845
|
+
def install_egress_guard(self) -> None:
|
|
846
|
+
"""Refuse outbound connections and record each attempt as an OS-plane event. The
|
|
847
|
+
sandbox is the enforcement layer; this makes attempts *evidence* attributed to the
|
|
848
|
+
in-scope caller. Installed for the whole session."""
|
|
849
|
+
import socket
|
|
850
|
+
|
|
851
|
+
tracer = self
|
|
852
|
+
|
|
853
|
+
def connect(sock: Any, address: Any) -> Any:
|
|
854
|
+
target = address if isinstance(address, str) else ":".join(str(a) for a in address[:2])
|
|
855
|
+
if tracer._orig_mock_call is not None: # tracing a stimulus
|
|
856
|
+
frame = sys._getframe(1)
|
|
857
|
+
parent = tracer._frame_nodes.get(id(frame), tracer._ancestor_node(frame))
|
|
858
|
+
tracer._nodes.append(
|
|
859
|
+
OsEventNode(
|
|
860
|
+
id=len(tracer._nodes),
|
|
861
|
+
parent=parent,
|
|
862
|
+
collector=1,
|
|
863
|
+
kind=OsEventKind.CONNECT,
|
|
864
|
+
target=target,
|
|
865
|
+
outcome="refused:egress-guard",
|
|
866
|
+
)
|
|
867
|
+
)
|
|
868
|
+
raise PermissionError(f"diffgenome egress guard: connect to {target} refused")
|
|
869
|
+
|
|
870
|
+
socket.socket.connect = connect # type: ignore[method-assign]
|
|
871
|
+
self._egress_guard_installed = True
|
|
872
|
+
|
|
873
|
+
# ------------------------------------------------------------------ patch registry
|
|
874
|
+
|
|
875
|
+
def install_patch_hook(self) -> None:
|
|
876
|
+
"""Record what `unittest.mock.patch` installs, for the whole session: fixtures may
|
|
877
|
+
enter patches before a test's call phase begins. A runtime-adapter detail."""
|
|
878
|
+
if self._orig_patch_enter is not None:
|
|
879
|
+
return
|
|
880
|
+
patch_cls: Any = mock._patch
|
|
881
|
+
tracer = self
|
|
882
|
+
self._orig_patch_enter, self._orig_patch_exit = patch_cls.__enter__, patch_cls.__exit__
|
|
883
|
+
|
|
884
|
+
def enter(patcher: Any) -> Any:
|
|
885
|
+
new = tracer._orig_patch_enter(patcher)
|
|
886
|
+
original = getattr(patcher, "temp_original", mock.DEFAULT)
|
|
887
|
+
tracer._patched[id(new)] = (patcher.target, patcher.attribute, original)
|
|
888
|
+
return new
|
|
889
|
+
|
|
890
|
+
def exit_(patcher: Any, *exc_info: Any) -> Any:
|
|
891
|
+
try:
|
|
892
|
+
current = getattr(patcher.target, patcher.attribute, None)
|
|
893
|
+
tracer._patched.pop(id(current), None)
|
|
894
|
+
except Exception:
|
|
895
|
+
pass
|
|
896
|
+
return tracer._orig_patch_exit(patcher, *exc_info)
|
|
897
|
+
|
|
898
|
+
patch_cls.__enter__, patch_cls.__exit__ = enter, exit_
|
|
899
|
+
|
|
900
|
+
# ------------------------------------------------------------------ lifecycle
|
|
901
|
+
|
|
902
|
+
def start(self, root_symbol: SymbolId, root_code: CodeType | None) -> None:
|
|
903
|
+
m = sys.monitoring
|
|
904
|
+
self._nodes.clear()
|
|
905
|
+
self._symbols.clear()
|
|
906
|
+
self._stacks.clear()
|
|
907
|
+
self._threads.clear()
|
|
908
|
+
self._frame_nodes.clear()
|
|
909
|
+
self._fake_wrappers.clear()
|
|
910
|
+
self._stack_repairs = 0
|
|
911
|
+
self._attribution_disagreements = 0
|
|
912
|
+
self._pending_mock_calls.clear()
|
|
913
|
+
self._root_code = root_code
|
|
914
|
+
origin = Origin.TEST
|
|
915
|
+
location = None
|
|
916
|
+
if root_code is not None and (scope := self.scope_for(root_code)):
|
|
917
|
+
origin, location = scope.origin, scope.location
|
|
918
|
+
self._intern(root_symbol, origin, location)
|
|
919
|
+
self._nodes.append(CallNode(0, None, root_symbol, 0, (), 0))
|
|
920
|
+
self._thread()
|
|
921
|
+
m.use_tool_id(_TOOL_ID, "diffgenome")
|
|
922
|
+
m.register_callback(_TOOL_ID, m.events.PY_START, self._on_start)
|
|
923
|
+
m.register_callback(_TOOL_ID, m.events.PY_RETURN, self._on_return)
|
|
924
|
+
m.register_callback(_TOOL_ID, m.events.PY_UNWIND, self._on_unwind)
|
|
925
|
+
m.register_callback(_TOOL_ID, m.events.CALL, self._on_call)
|
|
926
|
+
m.register_callback(_TOOL_ID, m.events.BRANCH, self._on_branch)
|
|
927
|
+
self._branches.clear()
|
|
928
|
+
mixin: Any = mock.CallableMixin # private attribute, absent from typeshed
|
|
929
|
+
self._orig_mock_call = mixin._mock_call
|
|
930
|
+
tracer = self
|
|
931
|
+
|
|
932
|
+
def _traced_mock_call(mock_self: Any, /, *args: Any, **kwargs: Any) -> Any:
|
|
933
|
+
return tracer._mock_call(mock_self, *args, **kwargs)
|
|
934
|
+
|
|
935
|
+
mixin._mock_call = _traced_mock_call
|
|
936
|
+
m.restart_events()
|
|
937
|
+
m.set_events(
|
|
938
|
+
_TOOL_ID,
|
|
939
|
+
m.events.PY_START
|
|
940
|
+
| m.events.PY_RETURN
|
|
941
|
+
| m.events.PY_UNWIND
|
|
942
|
+
| m.events.CALL
|
|
943
|
+
| m.events.BRANCH,
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
def stop(self) -> TraceResult:
|
|
947
|
+
m = sys.monitoring
|
|
948
|
+
m.set_events(_TOOL_ID, 0)
|
|
949
|
+
for ev in (
|
|
950
|
+
m.events.PY_START,
|
|
951
|
+
m.events.PY_RETURN,
|
|
952
|
+
m.events.PY_UNWIND,
|
|
953
|
+
m.events.CALL,
|
|
954
|
+
m.events.BRANCH,
|
|
955
|
+
):
|
|
956
|
+
m.register_callback(_TOOL_ID, ev, None)
|
|
957
|
+
m.free_tool_id(_TOOL_ID)
|
|
958
|
+
if self._orig_mock_call is not None:
|
|
959
|
+
mixin: Any = mock.CallableMixin
|
|
960
|
+
mixin._mock_call = self._orig_mock_call
|
|
961
|
+
self._orig_mock_call = None
|
|
962
|
+
return TraceResult(
|
|
963
|
+
symbols=tuple(self._symbols[k] for k in sorted(self._symbols)),
|
|
964
|
+
nodes=tuple(self._nodes),
|
|
965
|
+
stack_repairs=self._stack_repairs,
|
|
966
|
+
attribution_disagreements=self._attribution_disagreements,
|
|
967
|
+
branches=tuple(self._branches),
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _static_attr(cls: Any, name: str) -> Any:
|
|
972
|
+
"""Attribute lookup through the MRO without invoking descriptors or inspect."""
|
|
973
|
+
for klass in getattr(cls, "__mro__", (cls,)):
|
|
974
|
+
d = getattr(klass, "__dict__", {})
|
|
975
|
+
if name in d:
|
|
976
|
+
return d[name]
|
|
977
|
+
return None
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
class _CodeHolder:
|
|
981
|
+
"""Lets `symbol_for_object` name a fake by its code object without a function object."""
|
|
982
|
+
|
|
983
|
+
def __init__(self, code: CodeType) -> None:
|
|
984
|
+
self.__code__ = code
|
|
985
|
+
self.__qualname__ = code.co_qualname
|