coltrace 0.1.0.dev4__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.
- coltrace/__init__.py +9 -0
- coltrace/__main__.py +6 -0
- coltrace/analysis.py +2206 -0
- coltrace/callbacks.py +579 -0
- coltrace/cli.py +668 -0
- coltrace/collector.py +561 -0
- coltrace/exporters.py +676 -0
- coltrace/graph.py +380 -0
- coltrace/history.py +441 -0
- coltrace/journey.py +578 -0
- coltrace/lineage_models.py +112 -0
- coltrace/models.py +92 -0
- coltrace/py.typed +0 -0
- coltrace/scanner.py +236 -0
- coltrace/templates/graph.html +418 -0
- coltrace/visual_layout.py +159 -0
- coltrace-0.1.0.dev4.dist-info/METADATA +241 -0
- coltrace-0.1.0.dev4.dist-info/RECORD +22 -0
- coltrace-0.1.0.dev4.dist-info/WHEEL +5 -0
- coltrace-0.1.0.dev4.dist-info/entry_points.txt +2 -0
- coltrace-0.1.0.dev4.dist-info/licenses/LICENSE +21 -0
- coltrace-0.1.0.dev4.dist-info/top_level.txt +1 -0
coltrace/callbacks.py
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""Bounded value-dependency summaries for explicitly resolved scalar callbacks.
|
|
2
|
+
|
|
3
|
+
This module inspects syntax only. Text and regex methods are an *inferred*
|
|
4
|
+
scalar-text contract, not a proof about arbitrary Python objects or monkey
|
|
5
|
+
patching. Calls outside that contract are rejected. Callers must supply resolved
|
|
6
|
+
import names in ``symbols``; spelling a global ``re`` does not establish its API.
|
|
7
|
+
General control flow is rejected. A constant isinstance fallback is accepted
|
|
8
|
+
only when every returned component also has an explicit input value dependency;
|
|
9
|
+
otherwise omitting the condition could hide an actual dependency.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
from .lineage_models import Completeness, Confidence
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class CallbackComponent:
|
|
24
|
+
depends_on_input: bool
|
|
25
|
+
completeness: Completeness = "complete"
|
|
26
|
+
evidence: Confidence = "confirmed"
|
|
27
|
+
steps: tuple[str, ...] = ()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CallbackStep:
|
|
32
|
+
"""A callback step with a semantic display role, separate from column edges."""
|
|
33
|
+
|
|
34
|
+
id: str
|
|
35
|
+
line: int
|
|
36
|
+
column: int
|
|
37
|
+
end_line: int | None
|
|
38
|
+
end_column: int | None
|
|
39
|
+
label: str
|
|
40
|
+
expression: str
|
|
41
|
+
parents: tuple[str, ...] = ()
|
|
42
|
+
depends_on_input: bool = False
|
|
43
|
+
role: Literal["value", "control", "configuration", "context"] = "value"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class CallbackSummary:
|
|
48
|
+
components: tuple[CallbackComponent, ...] | None
|
|
49
|
+
supported: bool
|
|
50
|
+
may_mutate: bool
|
|
51
|
+
reason: str
|
|
52
|
+
steps: tuple[CallbackStep, ...] = ()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class _HistoryValue:
|
|
57
|
+
parents: tuple[str, ...] = ()
|
|
58
|
+
direct: bool = False
|
|
59
|
+
items: tuple[_HistoryValue, ...] | None = None
|
|
60
|
+
depends: bool = False
|
|
61
|
+
known: bool = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _CallbackHistory:
|
|
65
|
+
"""Record references and semantic roles after the bounded summary accepts it.
|
|
66
|
+
|
|
67
|
+
Control parents describe why a step may run. They do not turn a known,
|
|
68
|
+
input-independent preparation into a value transformation.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, parameter: str, symbols: Mapping[str, str], local_names: set[str]) -> None:
|
|
72
|
+
self.parameter = parameter
|
|
73
|
+
self.env: dict[str, _HistoryValue] = {
|
|
74
|
+
parameter: _HistoryValue(direct=True, depends=True, known=True)
|
|
75
|
+
}
|
|
76
|
+
self.semantic = _Summarizer(parameter, symbols)
|
|
77
|
+
self.semantic.local_names.update(local_names)
|
|
78
|
+
self.steps: list[CallbackStep] = []
|
|
79
|
+
self.guards: tuple[str, ...] = ()
|
|
80
|
+
|
|
81
|
+
def _references(self, node: ast.AST) -> _HistoryValue:
|
|
82
|
+
if isinstance(node, ast.Name):
|
|
83
|
+
return self.env.get(node.id, _HistoryValue())
|
|
84
|
+
if isinstance(node, ast.Subscript):
|
|
85
|
+
value = self._references(node.value)
|
|
86
|
+
if value.items is not None and isinstance(node.slice, ast.Constant):
|
|
87
|
+
index = node.slice.value
|
|
88
|
+
if type(index) is int and -len(value.items) <= index < len(value.items):
|
|
89
|
+
return value.items[index]
|
|
90
|
+
children = tuple(self._references(child) for child in ast.iter_child_nodes(node))
|
|
91
|
+
return _HistoryValue(
|
|
92
|
+
tuple(dict.fromkeys(parent for child in children for parent in child.parents)),
|
|
93
|
+
any(child.direct for child in children),
|
|
94
|
+
tuple(self._references(item) for item in node.elts)
|
|
95
|
+
if isinstance(node, ast.Tuple)
|
|
96
|
+
else None,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _semantic_value(self, value: _HistoryValue, semantic: _Value) -> _HistoryValue:
|
|
100
|
+
items = (
|
|
101
|
+
tuple(
|
|
102
|
+
self._semantic_value(item, component)
|
|
103
|
+
for item, component in zip(value.items, semantic.items, strict=True)
|
|
104
|
+
)
|
|
105
|
+
if value.items is not None and semantic.kind == "tuple"
|
|
106
|
+
else value.items
|
|
107
|
+
)
|
|
108
|
+
return _HistoryValue(
|
|
109
|
+
value.parents,
|
|
110
|
+
value.direct,
|
|
111
|
+
items,
|
|
112
|
+
semantic.depends,
|
|
113
|
+
semantic.completeness == "complete" and semantic.evidence != "unresolved",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def expression(self, node: ast.AST) -> _HistoryValue:
|
|
117
|
+
return self._semantic_value(self._references(node), self.semantic.expression(node))
|
|
118
|
+
|
|
119
|
+
def record(
|
|
120
|
+
self,
|
|
121
|
+
node: ast.AST,
|
|
122
|
+
label: str,
|
|
123
|
+
value: _HistoryValue,
|
|
124
|
+
*,
|
|
125
|
+
role: Literal["value", "control", "configuration", "context"] | None = None,
|
|
126
|
+
) -> _HistoryValue:
|
|
127
|
+
line, column = getattr(node, "lineno", 1), getattr(node, "col_offset", 0)
|
|
128
|
+
identifier = f"callback:{line}:{column}:{len(self.steps)}"
|
|
129
|
+
self.steps.append(
|
|
130
|
+
CallbackStep(
|
|
131
|
+
identifier,
|
|
132
|
+
line,
|
|
133
|
+
column,
|
|
134
|
+
getattr(node, "end_lineno", None),
|
|
135
|
+
getattr(node, "end_col_offset", None),
|
|
136
|
+
label,
|
|
137
|
+
ast.unparse(node),
|
|
138
|
+
tuple(dict.fromkeys(self.guards + value.parents)),
|
|
139
|
+
value.direct,
|
|
140
|
+
role
|
|
141
|
+
or ("value" if value.depends else "configuration" if value.known else "context"),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
return _HistoryValue(
|
|
145
|
+
(identifier,), items=value.items, depends=value.depends, known=value.known
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def assign(self, target: ast.AST, value: _HistoryValue, expression: ast.AST) -> None:
|
|
149
|
+
if isinstance(target, ast.Name):
|
|
150
|
+
if value.items is not None:
|
|
151
|
+
parts = tuple(
|
|
152
|
+
self.record(
|
|
153
|
+
expression.elts[index] if isinstance(expression, ast.Tuple) else expression,
|
|
154
|
+
f"{target.id}[{index}] = tuple component {index + 1}",
|
|
155
|
+
item,
|
|
156
|
+
)
|
|
157
|
+
for index, item in enumerate(value.items)
|
|
158
|
+
)
|
|
159
|
+
self.env[target.id] = _HistoryValue(
|
|
160
|
+
tuple(parent for part in parts for parent in part.parents),
|
|
161
|
+
items=parts,
|
|
162
|
+
depends=any(part.depends for part in parts),
|
|
163
|
+
known=all(part.known for part in parts),
|
|
164
|
+
)
|
|
165
|
+
return
|
|
166
|
+
self.env[target.id] = self.record(
|
|
167
|
+
expression, f"{target.id} = {ast.unparse(expression)}", value
|
|
168
|
+
)
|
|
169
|
+
elif isinstance(target, (ast.Tuple, ast.List)) and value.items is not None:
|
|
170
|
+
for item, component in zip(target.elts, value.items, strict=True):
|
|
171
|
+
self.assign(item, component, expression)
|
|
172
|
+
|
|
173
|
+
def body(self, statements: list[ast.stmt]) -> tuple[tuple[str, ...], ...]:
|
|
174
|
+
for statement in statements:
|
|
175
|
+
if isinstance(statement, ast.Assign):
|
|
176
|
+
semantic = self.semantic.expression(statement.value)
|
|
177
|
+
value = self.expression(statement.value)
|
|
178
|
+
for target in statement.targets:
|
|
179
|
+
self.assign(target, value, statement.value)
|
|
180
|
+
self.semantic.assign(target, semantic)
|
|
181
|
+
elif isinstance(statement, ast.AnnAssign) and statement.value is not None:
|
|
182
|
+
self.assign(statement.target, self.expression(statement.value), statement.value)
|
|
183
|
+
self.semantic.assign(statement.target, self.semantic.expression(statement.value))
|
|
184
|
+
elif isinstance(statement, ast.If):
|
|
185
|
+
# The semantic summary already proved this exact type guard.
|
|
186
|
+
# Its dependency controls execution rather than transforming values.
|
|
187
|
+
guard = self.record(
|
|
188
|
+
statement,
|
|
189
|
+
"Type guard; non-string input returns fallback constants",
|
|
190
|
+
self._references(statement.test),
|
|
191
|
+
role="control",
|
|
192
|
+
)
|
|
193
|
+
self.guards += guard.parents
|
|
194
|
+
self.semantic.env[self.parameter] = _Value(depends=True, kind="string")
|
|
195
|
+
elif isinstance(statement, ast.Return):
|
|
196
|
+
if statement.value is None:
|
|
197
|
+
return (
|
|
198
|
+
self.record(
|
|
199
|
+
statement, "Return None", _HistoryValue(known=True), role="value"
|
|
200
|
+
).parents,
|
|
201
|
+
)
|
|
202
|
+
value = self.expression(statement.value)
|
|
203
|
+
parts = value.items if value.items is not None else (value,)
|
|
204
|
+
expressions = (
|
|
205
|
+
statement.value.elts
|
|
206
|
+
if isinstance(statement.value, ast.Tuple)
|
|
207
|
+
else [statement.value] * len(parts)
|
|
208
|
+
)
|
|
209
|
+
return tuple(
|
|
210
|
+
self.record(
|
|
211
|
+
expression, f"Return component {index + 1}", part, role="value"
|
|
212
|
+
).parents
|
|
213
|
+
for index, (part, expression) in enumerate(zip(parts, expressions, strict=True))
|
|
214
|
+
)
|
|
215
|
+
return ()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True)
|
|
219
|
+
class _Value:
|
|
220
|
+
depends: bool = False
|
|
221
|
+
completeness: Completeness = "complete"
|
|
222
|
+
evidence: Confidence = "confirmed"
|
|
223
|
+
kind: Literal["scalar", "string", "collection", "tuple", "regex", "unknown"] = "scalar"
|
|
224
|
+
items: tuple[_Value, ...] = ()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class _Unsupported(Exception):
|
|
228
|
+
def __init__(self, reason: str, *, may_mutate: bool = False) -> None:
|
|
229
|
+
super().__init__(reason)
|
|
230
|
+
self.may_mutate = may_mutate
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _combine(values: tuple[_Value, ...], *, inferred: bool = False) -> _Value:
|
|
234
|
+
complete: Completeness = "complete"
|
|
235
|
+
if any(value.completeness != "complete" for value in values):
|
|
236
|
+
complete = "partial" if any(value.depends for value in values) else "unresolved"
|
|
237
|
+
evidence: Confidence = "inferred" if inferred else "confirmed"
|
|
238
|
+
if any(value.evidence == "unresolved" for value in values):
|
|
239
|
+
evidence = "inferred" if any(value.depends for value in values) else "unresolved"
|
|
240
|
+
elif any(value.evidence == "inferred" for value in values):
|
|
241
|
+
evidence = "inferred"
|
|
242
|
+
return _Value(any(value.depends for value in values), complete, evidence)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class _Summarizer:
|
|
246
|
+
TEXT_METHODS = frozenset(
|
|
247
|
+
{
|
|
248
|
+
"lower",
|
|
249
|
+
"upper",
|
|
250
|
+
"casefold",
|
|
251
|
+
"strip",
|
|
252
|
+
"lstrip",
|
|
253
|
+
"rstrip",
|
|
254
|
+
"replace",
|
|
255
|
+
"removeprefix",
|
|
256
|
+
"removesuffix",
|
|
257
|
+
"capitalize",
|
|
258
|
+
"title",
|
|
259
|
+
"swapcase",
|
|
260
|
+
"split",
|
|
261
|
+
"rsplit",
|
|
262
|
+
"splitlines",
|
|
263
|
+
"join",
|
|
264
|
+
"startswith",
|
|
265
|
+
"endswith",
|
|
266
|
+
"find",
|
|
267
|
+
"rfind",
|
|
268
|
+
"count",
|
|
269
|
+
"isalnum",
|
|
270
|
+
"isalpha",
|
|
271
|
+
"isdigit",
|
|
272
|
+
"isspace",
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
REGEX_CALLS = frozenset({"sub", "subn", "findall", "split", "escape", "compile"})
|
|
276
|
+
REGEX_METHODS = frozenset({"sub", "subn", "findall", "split"})
|
|
277
|
+
|
|
278
|
+
def __init__(self, parameter: str, symbols: Mapping[str, str]) -> None:
|
|
279
|
+
self.parameter = parameter
|
|
280
|
+
self.symbols = symbols
|
|
281
|
+
self.env = {parameter: _Value(depends=True)}
|
|
282
|
+
self.local_names: set[str] = {parameter}
|
|
283
|
+
self.inferred = False
|
|
284
|
+
|
|
285
|
+
def _builtin(self, name: str) -> bool:
|
|
286
|
+
return name not in self.local_names and self.symbols.get(name, f"builtins.{name}") == (
|
|
287
|
+
f"builtins.{name}"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _qualified(self, node: ast.AST) -> str | None:
|
|
291
|
+
if isinstance(node, ast.Name) and node.id not in self.local_names:
|
|
292
|
+
return self.symbols.get(node.id)
|
|
293
|
+
if isinstance(node, ast.Attribute):
|
|
294
|
+
base = self._qualified(node.value)
|
|
295
|
+
return f"{base}.{node.attr}" if base else None
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
def expression(self, node: ast.AST) -> _Value:
|
|
299
|
+
if isinstance(node, ast.Constant):
|
|
300
|
+
return _Value(kind="string" if isinstance(node.value, str) else "scalar")
|
|
301
|
+
if isinstance(node, ast.Name):
|
|
302
|
+
return self.env.get(
|
|
303
|
+
node.id, _Value(completeness="unresolved", evidence="unresolved", kind="unknown")
|
|
304
|
+
)
|
|
305
|
+
if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
|
|
306
|
+
values = tuple(self.expression(item) for item in node.elts)
|
|
307
|
+
combined = _combine(values)
|
|
308
|
+
return _Value(
|
|
309
|
+
combined.depends,
|
|
310
|
+
combined.completeness,
|
|
311
|
+
combined.evidence,
|
|
312
|
+
"tuple" if isinstance(node, ast.Tuple) else "collection",
|
|
313
|
+
values,
|
|
314
|
+
)
|
|
315
|
+
if isinstance(node, ast.Dict):
|
|
316
|
+
values = tuple(
|
|
317
|
+
self.expression(item) for item in (*node.keys, *node.values) if item is not None
|
|
318
|
+
)
|
|
319
|
+
combined = _combine(values)
|
|
320
|
+
return _Value(combined.depends, combined.completeness, combined.evidence, "collection")
|
|
321
|
+
if isinstance(node, ast.BinOp):
|
|
322
|
+
values = (self.expression(node.left), self.expression(node.right))
|
|
323
|
+
combined = _combine(values)
|
|
324
|
+
if any(value.kind in {"collection", "tuple"} for value in values):
|
|
325
|
+
return _Value(combined.depends, combined.completeness, combined.evidence, "unknown")
|
|
326
|
+
return combined
|
|
327
|
+
if isinstance(node, ast.UnaryOp):
|
|
328
|
+
return self.expression(node.operand)
|
|
329
|
+
if isinstance(node, ast.Subscript):
|
|
330
|
+
value = self.expression(node.value)
|
|
331
|
+
index = self.expression(node.slice)
|
|
332
|
+
if value.kind == "tuple" and isinstance(node.slice, ast.Constant):
|
|
333
|
+
position = node.slice.value
|
|
334
|
+
if isinstance(position, int) and -len(value.items) <= position < len(value.items):
|
|
335
|
+
return value.items[position]
|
|
336
|
+
combined = _combine((value, index), inferred=True)
|
|
337
|
+
self.inferred = True
|
|
338
|
+
return combined
|
|
339
|
+
if isinstance(node, ast.Slice):
|
|
340
|
+
return _combine(
|
|
341
|
+
tuple(
|
|
342
|
+
self.expression(item)
|
|
343
|
+
for item in (node.lower, node.upper, node.step)
|
|
344
|
+
if item is not None
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
if isinstance(node, ast.JoinedStr):
|
|
348
|
+
combined = _combine(tuple(self.expression(item) for item in node.values), inferred=True)
|
|
349
|
+
self.inferred = True
|
|
350
|
+
return _Value(combined.depends, combined.completeness, combined.evidence, "string")
|
|
351
|
+
if isinstance(node, ast.FormattedValue):
|
|
352
|
+
value = self.expression(node.value)
|
|
353
|
+
if node.format_spec is None:
|
|
354
|
+
return value
|
|
355
|
+
# A format spec is itself an f-string: nested replacement fields
|
|
356
|
+
# may execute calls and must not disappear from the summary.
|
|
357
|
+
return _combine((value, self.expression(node.format_spec)), inferred=True)
|
|
358
|
+
if isinstance(node, ast.Call):
|
|
359
|
+
return self.call(node)
|
|
360
|
+
raise _Unsupported(f"Unsupported callback expression: {type(node).__name__}.")
|
|
361
|
+
|
|
362
|
+
def call(self, node: ast.Call) -> _Value:
|
|
363
|
+
if any(isinstance(arg, ast.Starred) for arg in node.args) or any(
|
|
364
|
+
keyword.arg is None for keyword in node.keywords
|
|
365
|
+
):
|
|
366
|
+
raise _Unsupported("Dynamic callback arguments are not summarized.", may_mutate=True)
|
|
367
|
+
qualified = self._qualified(node.func)
|
|
368
|
+
method: str | None = None
|
|
369
|
+
receiver: _Value | None = None
|
|
370
|
+
if qualified and (
|
|
371
|
+
qualified in {f"re.{name}" for name in self.REGEX_CALLS}
|
|
372
|
+
or qualified in {"emoji.replace_emoji", "emoji.demojize"}
|
|
373
|
+
):
|
|
374
|
+
method = qualified
|
|
375
|
+
elif isinstance(node.func, ast.Attribute):
|
|
376
|
+
receiver = self.expression(node.func.value)
|
|
377
|
+
if receiver.kind == "regex" and node.func.attr in self.REGEX_METHODS:
|
|
378
|
+
method = f"regex.{node.func.attr}"
|
|
379
|
+
elif receiver.kind in {"scalar", "string"} and node.func.attr in self.TEXT_METHODS:
|
|
380
|
+
method = f"str.{node.func.attr}"
|
|
381
|
+
elif isinstance(node.func, ast.Name) and node.func.id in {"len", "str"}:
|
|
382
|
+
if self._builtin(node.func.id):
|
|
383
|
+
method = f"builtins.{node.func.id}"
|
|
384
|
+
if method is None:
|
|
385
|
+
raise _Unsupported(
|
|
386
|
+
"Callback calls an unresolved or unsupported callable.", may_mutate=True
|
|
387
|
+
)
|
|
388
|
+
arguments = tuple(self.expression(arg) for arg in node.args) + tuple(
|
|
389
|
+
self.expression(keyword.value) for keyword in node.keywords
|
|
390
|
+
)
|
|
391
|
+
# re.sub and emoji.replace_emoji explicitly accept callable replacements.
|
|
392
|
+
# A trusted library identity does not establish that argument's purity.
|
|
393
|
+
if method in {"re.sub", "re.subn", "regex.sub", "regex.subn", "emoji.replace_emoji"}:
|
|
394
|
+
position = 1 if method.startswith("re.") else 0
|
|
395
|
+
if method == "emoji.replace_emoji":
|
|
396
|
+
position = 1
|
|
397
|
+
replacement = None
|
|
398
|
+
if len(node.args) > position:
|
|
399
|
+
replacement = self.expression(node.args[position])
|
|
400
|
+
keyword_name = "replace" if method.startswith("emoji.") else "repl"
|
|
401
|
+
for keyword in node.keywords:
|
|
402
|
+
if keyword.arg == keyword_name:
|
|
403
|
+
replacement = self.expression(keyword.value)
|
|
404
|
+
if replacement is not None and replacement.kind == "unknown":
|
|
405
|
+
raise _Unsupported(
|
|
406
|
+
"Callback replacement may invoke an unresolved callable.", may_mutate=True
|
|
407
|
+
)
|
|
408
|
+
values = arguments if receiver is None else (receiver, *arguments)
|
|
409
|
+
combined = _combine(values, inferred=True)
|
|
410
|
+
self.inferred = True
|
|
411
|
+
kind: Literal["scalar", "string", "collection", "tuple", "regex", "unknown"] = "string"
|
|
412
|
+
if method == "re.compile":
|
|
413
|
+
kind = "regex"
|
|
414
|
+
elif method.rsplit(".", 1)[-1] in {"findall", "split", "rsplit", "splitlines"}:
|
|
415
|
+
kind = "collection"
|
|
416
|
+
elif method.endswith(".subn"):
|
|
417
|
+
# Its result is a tuple, but its component contracts need a separate rule.
|
|
418
|
+
kind = "unknown"
|
|
419
|
+
return _Value(combined.depends, combined.completeness, combined.evidence, kind)
|
|
420
|
+
|
|
421
|
+
def assign(self, target: ast.AST, value: _Value) -> None:
|
|
422
|
+
if isinstance(target, ast.Name):
|
|
423
|
+
self.env[target.id] = value
|
|
424
|
+
return
|
|
425
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
426
|
+
if value.kind != "tuple" or len(target.elts) != len(value.items):
|
|
427
|
+
raise _Unsupported("Callback unpacking has an unresolved shape.")
|
|
428
|
+
for child, item in zip(target.elts, value.items, strict=True):
|
|
429
|
+
self.assign(child, item)
|
|
430
|
+
return
|
|
431
|
+
raise _Unsupported("Callback writes through an object or subscript.", may_mutate=True)
|
|
432
|
+
|
|
433
|
+
def _guard(self, node: ast.If) -> bool:
|
|
434
|
+
test = node.test
|
|
435
|
+
if not (
|
|
436
|
+
isinstance(test, ast.UnaryOp)
|
|
437
|
+
and isinstance(test.op, ast.Not)
|
|
438
|
+
and isinstance(test.operand, ast.Call)
|
|
439
|
+
):
|
|
440
|
+
return False
|
|
441
|
+
call = test.operand
|
|
442
|
+
return (
|
|
443
|
+
isinstance(call.func, ast.Name)
|
|
444
|
+
and call.func.id == "isinstance"
|
|
445
|
+
and self._builtin("isinstance")
|
|
446
|
+
and self._builtin("str")
|
|
447
|
+
and len(call.args) == 2
|
|
448
|
+
and not call.keywords
|
|
449
|
+
and isinstance(call.args[0], ast.Name)
|
|
450
|
+
and call.args[0].id == self.parameter
|
|
451
|
+
and isinstance(call.args[1], ast.Name)
|
|
452
|
+
and call.args[1].id == "str"
|
|
453
|
+
and len(node.body) == 1
|
|
454
|
+
and isinstance(node.body[0], ast.Return)
|
|
455
|
+
and not node.orelse
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def body(self, statements: list[ast.stmt]) -> tuple[_Value, ...]:
|
|
459
|
+
returns: list[_Value] = []
|
|
460
|
+
for statement in statements:
|
|
461
|
+
if isinstance(statement, ast.Assign):
|
|
462
|
+
value = self.expression(statement.value)
|
|
463
|
+
for target in statement.targets:
|
|
464
|
+
self.assign(target, value)
|
|
465
|
+
elif isinstance(statement, ast.AnnAssign):
|
|
466
|
+
if statement.value is not None:
|
|
467
|
+
self.assign(statement.target, self.expression(statement.value))
|
|
468
|
+
elif isinstance(statement, ast.Return):
|
|
469
|
+
returns.append(self.expression(statement.value) if statement.value else _Value())
|
|
470
|
+
break
|
|
471
|
+
elif isinstance(statement, ast.Expr):
|
|
472
|
+
self.expression(statement.value)
|
|
473
|
+
elif isinstance(statement, ast.Pass):
|
|
474
|
+
continue
|
|
475
|
+
elif isinstance(statement, ast.If) and self._guard(statement):
|
|
476
|
+
early = statement.body[0]
|
|
477
|
+
assert isinstance(early, ast.Return)
|
|
478
|
+
result = self.expression(early.value) if early.value else _Value()
|
|
479
|
+
if result.depends or result.completeness != "complete":
|
|
480
|
+
raise _Unsupported("The isinstance fallback must return only local constants.")
|
|
481
|
+
returns.append(result)
|
|
482
|
+
self.env[self.parameter] = _Value(depends=True, kind="string")
|
|
483
|
+
else:
|
|
484
|
+
raise _Unsupported(
|
|
485
|
+
f"Unsupported callback statement: {type(statement).__name__}.",
|
|
486
|
+
may_mutate=True,
|
|
487
|
+
)
|
|
488
|
+
else:
|
|
489
|
+
returns.append(_Value())
|
|
490
|
+
return tuple(returns)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def summarize_callback(
|
|
494
|
+
node: ast.FunctionDef,
|
|
495
|
+
*,
|
|
496
|
+
input_parameter: str | None = None,
|
|
497
|
+
symbols: Mapping[str, str] | None = None,
|
|
498
|
+
) -> CallbackSummary:
|
|
499
|
+
"""Summarize a direct, scalar callback without evaluating any project code.
|
|
500
|
+
|
|
501
|
+
Tuple return components are positionally distinct. Scalar returns have one
|
|
502
|
+
component; dynamic or inconsistent return arity is rejected. ``symbols``
|
|
503
|
+
maps names to statically resolved imports, not guessed module spellings.
|
|
504
|
+
"""
|
|
505
|
+
parameters = (*node.args.posonlyargs, *node.args.args)
|
|
506
|
+
if (
|
|
507
|
+
len(parameters) != 1
|
|
508
|
+
or node.args.vararg
|
|
509
|
+
or node.args.kwarg
|
|
510
|
+
or node.args.kwonlyargs
|
|
511
|
+
or node.args.defaults
|
|
512
|
+
or node.decorator_list
|
|
513
|
+
):
|
|
514
|
+
return CallbackSummary(None, False, False, "Callback requires one plain scalar parameter.")
|
|
515
|
+
parameter = parameters[0].arg
|
|
516
|
+
if input_parameter is not None and parameter != input_parameter:
|
|
517
|
+
return CallbackSummary(None, False, False, "Callback parameter could not be bound.")
|
|
518
|
+
nodes = list(ast.walk(node))
|
|
519
|
+
if len(nodes) > 1000:
|
|
520
|
+
return CallbackSummary(None, False, False, "Callback exceeds the static summary budget.")
|
|
521
|
+
if any(isinstance(item, (ast.Global, ast.Nonlocal)) for item in nodes):
|
|
522
|
+
return CallbackSummary(None, False, True, "Callback declares global or nonlocal state.")
|
|
523
|
+
if any(
|
|
524
|
+
isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef))
|
|
525
|
+
for item in nodes
|
|
526
|
+
if item is not node
|
|
527
|
+
):
|
|
528
|
+
return CallbackSummary(None, False, False, "Nested callback scopes are not summarized.")
|
|
529
|
+
summarizer = _Summarizer(parameter, symbols or {})
|
|
530
|
+
summarizer.local_names.update(
|
|
531
|
+
item.id for item in nodes if isinstance(item, ast.Name) and isinstance(item.ctx, ast.Store)
|
|
532
|
+
)
|
|
533
|
+
try:
|
|
534
|
+
returned = summarizer.body(node.body)
|
|
535
|
+
except (_Unsupported, RecursionError) as error:
|
|
536
|
+
return CallbackSummary(
|
|
537
|
+
None,
|
|
538
|
+
False,
|
|
539
|
+
error.may_mutate if isinstance(error, _Unsupported) else False,
|
|
540
|
+
str(error)
|
|
541
|
+
if isinstance(error, _Unsupported)
|
|
542
|
+
else "Callback nesting exceeds the budget.",
|
|
543
|
+
)
|
|
544
|
+
shapes = {len(value.items) if value.kind == "tuple" else 1 for value in returned}
|
|
545
|
+
if len(shapes) != 1 or any(value.kind in {"collection", "unknown"} for value in returned):
|
|
546
|
+
return CallbackSummary(
|
|
547
|
+
None, False, False, "Callback return shape is not statically stable."
|
|
548
|
+
)
|
|
549
|
+
arity = shapes.pop()
|
|
550
|
+
rows = tuple(value.items if value.kind == "tuple" else (value,) for value in returned)
|
|
551
|
+
components = tuple(
|
|
552
|
+
CallbackComponent(merged.depends, merged.completeness, merged.evidence)
|
|
553
|
+
for index in range(arity)
|
|
554
|
+
for merged in (_combine(tuple(row[index] for row in rows)),)
|
|
555
|
+
)
|
|
556
|
+
if len(returned) > 1 and any(not component.depends_on_input for component in components):
|
|
557
|
+
return CallbackSummary(
|
|
558
|
+
None,
|
|
559
|
+
False,
|
|
560
|
+
False,
|
|
561
|
+
"Conditional callback components require unsupported control dependencies.",
|
|
562
|
+
)
|
|
563
|
+
reason = (
|
|
564
|
+
"Inferred value dependencies under the bounded scalar-text API contract."
|
|
565
|
+
if summarizer.inferred
|
|
566
|
+
else "Explicit callback return value dependencies."
|
|
567
|
+
)
|
|
568
|
+
history = _CallbackHistory(parameter, symbols or {}, summarizer.local_names)
|
|
569
|
+
outputs = history.body(node.body)
|
|
570
|
+
components = tuple(
|
|
571
|
+
CallbackComponent(
|
|
572
|
+
component.depends_on_input,
|
|
573
|
+
component.completeness,
|
|
574
|
+
component.evidence,
|
|
575
|
+
outputs[index] if index < len(outputs) else (),
|
|
576
|
+
)
|
|
577
|
+
for index, component in enumerate(components)
|
|
578
|
+
)
|
|
579
|
+
return CallbackSummary(components, True, False, reason, tuple(history.steps))
|