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/collector.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""Collect syntactic occurrences and conservative evidence about their receivers.
|
|
2
|
+
|
|
3
|
+
This is not a lineage engine: no expression dependencies, column versions or
|
|
4
|
+
interprocedural effects are inferred here. Unsupported contexts stay explicit.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import hashlib
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from .models import (
|
|
16
|
+
ColumnOccurrence,
|
|
17
|
+
DataFrameInfo,
|
|
18
|
+
Diagnostic,
|
|
19
|
+
Evidence,
|
|
20
|
+
FunctionInfo,
|
|
21
|
+
Location,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class _Binding:
|
|
27
|
+
evidence: Evidence = "unresolved"
|
|
28
|
+
reason: str = "No supported DataFrame origin or annotation was resolved."
|
|
29
|
+
qualified: str | None = None
|
|
30
|
+
dataframe_id: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _BoundNames(ast.NodeVisitor):
|
|
34
|
+
"""Find bindings in one Python scope without entering nested scopes."""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self.names: Counter[str] = Counter()
|
|
38
|
+
|
|
39
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
40
|
+
if isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
41
|
+
self.names[node.id] += 1
|
|
42
|
+
|
|
43
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
44
|
+
for item in node.names:
|
|
45
|
+
self.names[item.asname or item.name.split(".")[0]] += 1
|
|
46
|
+
|
|
47
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
48
|
+
for item in node.names:
|
|
49
|
+
self.names[item.asname or item.name] += 1
|
|
50
|
+
|
|
51
|
+
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
52
|
+
self.names[node.name] += 1
|
|
53
|
+
|
|
54
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
55
|
+
|
|
56
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
57
|
+
self.names[node.name] += 1
|
|
58
|
+
|
|
59
|
+
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
63
|
+
if node.name:
|
|
64
|
+
self.names[node.name] += 1
|
|
65
|
+
self.generic_visit(node)
|
|
66
|
+
|
|
67
|
+
def visit_MatchAs(self, node: ast.MatchAs) -> None:
|
|
68
|
+
if node.name:
|
|
69
|
+
self.names[node.name] += 1
|
|
70
|
+
self.generic_visit(node)
|
|
71
|
+
|
|
72
|
+
def visit_MatchStar(self, node: ast.MatchStar) -> None:
|
|
73
|
+
if node.name:
|
|
74
|
+
self.names[node.name] += 1
|
|
75
|
+
|
|
76
|
+
def visit_MatchMapping(self, node: ast.MatchMapping) -> None:
|
|
77
|
+
if node.rest:
|
|
78
|
+
self.names[node.rest] += 1
|
|
79
|
+
self.generic_visit(node)
|
|
80
|
+
|
|
81
|
+
def visit_ListComp(self, node: ast.AST) -> None:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
visit_SetComp = visit_ListComp
|
|
85
|
+
visit_DictComp = visit_ListComp
|
|
86
|
+
visit_GeneratorExp = visit_ListComp
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parameters(args: ast.arguments) -> list[ast.arg]:
|
|
90
|
+
result = args.posonlyargs + args.args + args.kwonlyargs
|
|
91
|
+
if args.vararg:
|
|
92
|
+
result.append(args.vararg)
|
|
93
|
+
if args.kwarg:
|
|
94
|
+
result.append(args.kwarg)
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _literal(node: ast.AST) -> bool:
|
|
99
|
+
if isinstance(node, ast.Constant):
|
|
100
|
+
return True
|
|
101
|
+
if isinstance(node, (ast.List, ast.Tuple)):
|
|
102
|
+
return all(_literal(item) for item in node.elts)
|
|
103
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
|
|
104
|
+
return _literal(node.operand)
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class OccurrenceCollector(ast.NodeVisitor):
|
|
109
|
+
def __init__(self, path: str, module: str, source: str) -> None:
|
|
110
|
+
self.path = path
|
|
111
|
+
self.module = module
|
|
112
|
+
self.source = source
|
|
113
|
+
self.lines = source.split("\n")
|
|
114
|
+
self.scope = "<module>"
|
|
115
|
+
self.env: dict[str, _Binding] = {}
|
|
116
|
+
self._module_env = self.env
|
|
117
|
+
self._module_bindings: Counter[str] = Counter()
|
|
118
|
+
self._uncertain = 0
|
|
119
|
+
self.occurrences: list[ColumnOccurrence] = []
|
|
120
|
+
self.functions: list[FunctionInfo] = []
|
|
121
|
+
self.dataframes: list[DataFrameInfo] = []
|
|
122
|
+
self.diagnostics: list[Diagnostic] = []
|
|
123
|
+
|
|
124
|
+
def _location(self, node: ast.AST) -> Location:
|
|
125
|
+
def column(line: int, byte_offset: int) -> int:
|
|
126
|
+
return len(self.lines[line - 1].encode("utf-8")[:byte_offset].decode("utf-8"))
|
|
127
|
+
|
|
128
|
+
line = node.lineno # type: ignore[attr-defined]
|
|
129
|
+
end_line = getattr(node, "end_lineno", line) or line
|
|
130
|
+
return Location(
|
|
131
|
+
self.path,
|
|
132
|
+
line,
|
|
133
|
+
column(line, node.col_offset), # type: ignore[attr-defined]
|
|
134
|
+
end_line,
|
|
135
|
+
column(end_line, getattr(node, "end_col_offset", 0) or 0),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _diagnose(self, code: str, node: ast.AST, message: str, hint: str) -> None:
|
|
139
|
+
self.diagnostics.append(
|
|
140
|
+
Diagnostic(
|
|
141
|
+
code,
|
|
142
|
+
"warning",
|
|
143
|
+
message,
|
|
144
|
+
self._location(node),
|
|
145
|
+
self.scope,
|
|
146
|
+
hint,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def _qualified(self, node: ast.AST, env: dict[str, _Binding] | None = None) -> str | None:
|
|
151
|
+
bindings = self.env if env is None else env
|
|
152
|
+
if isinstance(node, ast.Name):
|
|
153
|
+
return bindings.get(node.id, _Binding()).qualified
|
|
154
|
+
if isinstance(node, ast.Attribute):
|
|
155
|
+
base = self._qualified(node.value, bindings)
|
|
156
|
+
return f"{base}.{node.attr}" if base else None
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def _dataframe(self, node: ast.AST, origin: str, evidence: Evidence) -> _Binding:
|
|
160
|
+
if self._uncertain:
|
|
161
|
+
return _Binding(reason="The receiver is in an unsupported execution context.")
|
|
162
|
+
location = self._location(node)
|
|
163
|
+
key = f"{self.path}:{self.scope}:{location.line}:{location.column}:{origin}"
|
|
164
|
+
identifier = "df:" + hashlib.sha256(key.encode()).hexdigest()[:24]
|
|
165
|
+
reason = (
|
|
166
|
+
f"Resolved Pandas origin: {origin}."
|
|
167
|
+
if evidence == "confirmed"
|
|
168
|
+
else "DataFrame annotation is an unverified type assumption."
|
|
169
|
+
)
|
|
170
|
+
self.dataframes.append(
|
|
171
|
+
DataFrameInfo(
|
|
172
|
+
identifier,
|
|
173
|
+
self.scope,
|
|
174
|
+
origin,
|
|
175
|
+
evidence,
|
|
176
|
+
reason,
|
|
177
|
+
location,
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
return _Binding(evidence, reason, dataframe_id=identifier)
|
|
181
|
+
|
|
182
|
+
def _infer(self, node: ast.AST) -> _Binding:
|
|
183
|
+
if isinstance(node, ast.Name):
|
|
184
|
+
return self.env.get(node.id, _Binding())
|
|
185
|
+
if isinstance(node, (ast.Constant, ast.Dict, ast.List, ast.Tuple, ast.Set)):
|
|
186
|
+
return _Binding("rejected", "The receiver is a literal, not a DataFrame.")
|
|
187
|
+
if isinstance(node, ast.Call):
|
|
188
|
+
origin = self._qualified(node.func)
|
|
189
|
+
if origin in {"pandas.read_csv", "pandas.read_parquet"}:
|
|
190
|
+
if origin == "pandas.read_csv" and (
|
|
191
|
+
len(node.args) > 1
|
|
192
|
+
or any(isinstance(arg, ast.Starred) for arg in node.args)
|
|
193
|
+
or any(
|
|
194
|
+
kw.arg is None
|
|
195
|
+
or (
|
|
196
|
+
kw.arg == "iterator"
|
|
197
|
+
and not (isinstance(kw.value, ast.Constant) and kw.value.value is False)
|
|
198
|
+
)
|
|
199
|
+
or (
|
|
200
|
+
kw.arg == "chunksize"
|
|
201
|
+
and not (isinstance(kw.value, ast.Constant) and kw.value.value is None)
|
|
202
|
+
)
|
|
203
|
+
for kw in node.keywords
|
|
204
|
+
)
|
|
205
|
+
):
|
|
206
|
+
return _Binding(
|
|
207
|
+
reason="CSV reader options may return an iterator, not a DataFrame."
|
|
208
|
+
)
|
|
209
|
+
return self._dataframe(node, origin, "confirmed")
|
|
210
|
+
if origin == "pandas.DataFrame":
|
|
211
|
+
data = (
|
|
212
|
+
node.args[0]
|
|
213
|
+
if len(node.args) == 1
|
|
214
|
+
else next(
|
|
215
|
+
(kw.value for kw in node.keywords if kw.arg == "data"),
|
|
216
|
+
None,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
if (
|
|
220
|
+
isinstance(data, ast.Dict)
|
|
221
|
+
and all(
|
|
222
|
+
isinstance(key, ast.Constant) and isinstance(key.value, str)
|
|
223
|
+
for key in data.keys
|
|
224
|
+
)
|
|
225
|
+
and all(_literal(value) for value in data.values)
|
|
226
|
+
):
|
|
227
|
+
return self._dataframe(node, origin, "confirmed")
|
|
228
|
+
return _Binding()
|
|
229
|
+
|
|
230
|
+
def _annotation(
|
|
231
|
+
self,
|
|
232
|
+
annotation: ast.expr | None,
|
|
233
|
+
node: ast.AST,
|
|
234
|
+
env: dict[str, _Binding],
|
|
235
|
+
) -> _Binding:
|
|
236
|
+
# Parsing a quoted annotation only inspects syntax; no eval or import.
|
|
237
|
+
if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
|
|
238
|
+
try:
|
|
239
|
+
annotation = ast.parse(annotation.value, mode="eval").body
|
|
240
|
+
except SyntaxError:
|
|
241
|
+
return _Binding()
|
|
242
|
+
if annotation and self._qualified(annotation, env) == "pandas.DataFrame":
|
|
243
|
+
return self._dataframe(node, "annotation:pandas.DataFrame", "inferred")
|
|
244
|
+
return _Binding()
|
|
245
|
+
|
|
246
|
+
def visit_Module(self, node: ast.Module) -> None:
|
|
247
|
+
bindings = _BoundNames()
|
|
248
|
+
bindings.visit(node)
|
|
249
|
+
self._module_bindings = bindings.names
|
|
250
|
+
self.generic_visit(node)
|
|
251
|
+
|
|
252
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
253
|
+
for item in node.names:
|
|
254
|
+
name = item.asname or item.name.split(".")[0]
|
|
255
|
+
self.env[name] = _Binding(qualified="pandas") if item.name == "pandas" else _Binding()
|
|
256
|
+
|
|
257
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
258
|
+
for item in node.names:
|
|
259
|
+
name = item.asname or item.name
|
|
260
|
+
known = (
|
|
261
|
+
node.module == "pandas"
|
|
262
|
+
and node.level == 0
|
|
263
|
+
and item.name
|
|
264
|
+
in {
|
|
265
|
+
"DataFrame",
|
|
266
|
+
"read_csv",
|
|
267
|
+
"read_parquet",
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
self.env[name] = _Binding(qualified=f"pandas.{item.name}") if known else _Binding()
|
|
271
|
+
if any(item.name == "*" for item in node.names):
|
|
272
|
+
self._diagnose(
|
|
273
|
+
"CT_STAR_IMPORT",
|
|
274
|
+
node,
|
|
275
|
+
"Star imports cannot be resolved in Phase 1.",
|
|
276
|
+
"Use explicit imports.",
|
|
277
|
+
)
|
|
278
|
+
for name in self.env:
|
|
279
|
+
self.env[name] = _Binding(reason="A star import can shadow existing bindings.")
|
|
280
|
+
|
|
281
|
+
def _bind_target(self, target: ast.AST, binding: _Binding) -> None:
|
|
282
|
+
if isinstance(target, ast.Name):
|
|
283
|
+
self.env[target.id] = binding
|
|
284
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
285
|
+
for item in target.elts:
|
|
286
|
+
self._bind_target(item, _Binding(reason="Unpacking is not resolved in Phase 1."))
|
|
287
|
+
elif isinstance(target, ast.Attribute):
|
|
288
|
+
root = target.value
|
|
289
|
+
while isinstance(root, ast.Attribute):
|
|
290
|
+
root = root.value
|
|
291
|
+
if isinstance(root, ast.Name):
|
|
292
|
+
affected = self.env.get(root.id, _Binding())
|
|
293
|
+
if affected.qualified:
|
|
294
|
+
for name, value in list(self.env.items()):
|
|
295
|
+
if value.qualified and value.qualified.startswith("pandas"):
|
|
296
|
+
self.env[name] = _Binding(reason="A Pandas API attribute was modified.")
|
|
297
|
+
self._invalidate({affected.dataframe_id})
|
|
298
|
+
|
|
299
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
300
|
+
self.visit(node.value)
|
|
301
|
+
binding = self._infer(node.value)
|
|
302
|
+
for target in node.targets:
|
|
303
|
+
self.visit(target)
|
|
304
|
+
self._bind_target(target, binding)
|
|
305
|
+
|
|
306
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
307
|
+
if node.value:
|
|
308
|
+
self.visit(node.value)
|
|
309
|
+
binding = self._infer(node.value)
|
|
310
|
+
else:
|
|
311
|
+
binding = (
|
|
312
|
+
self.env.get(node.target.id, _Binding())
|
|
313
|
+
if isinstance(node.target, ast.Name)
|
|
314
|
+
else _Binding()
|
|
315
|
+
)
|
|
316
|
+
if binding.evidence == "unresolved":
|
|
317
|
+
binding = self._annotation(node.annotation, node, self.env)
|
|
318
|
+
self.visit(node.target)
|
|
319
|
+
self._bind_target(node.target, binding)
|
|
320
|
+
|
|
321
|
+
def visit_Delete(self, node: ast.Delete) -> None:
|
|
322
|
+
for target in node.targets:
|
|
323
|
+
self.visit(target)
|
|
324
|
+
self._bind_target(target, _Binding(reason="This binding was deleted."))
|
|
325
|
+
|
|
326
|
+
def visit_AugAssign(self, node: ast.AugAssign) -> None:
|
|
327
|
+
if isinstance(node.target, ast.Subscript):
|
|
328
|
+
self._subscript(node.target, "read_write", force_unsupported=True)
|
|
329
|
+
else:
|
|
330
|
+
self.visit(node.target)
|
|
331
|
+
self.visit(node.value)
|
|
332
|
+
self._bind_target(node.target, _Binding(reason="Augmented assignments are unsupported."))
|
|
333
|
+
|
|
334
|
+
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
335
|
+
parent_env, parent_scope, uncertain = self.env, self.scope, self._uncertain
|
|
336
|
+
for expression in [*node.decorator_list, *node.args.defaults]:
|
|
337
|
+
self.visit(expression)
|
|
338
|
+
for default in node.args.kw_defaults:
|
|
339
|
+
if default:
|
|
340
|
+
self.visit(default)
|
|
341
|
+
scope = node.name if parent_scope == "<module>" else f"{parent_scope}.{node.name}"
|
|
342
|
+
parameters = _parameters(node.args)
|
|
343
|
+
self.functions.append(
|
|
344
|
+
FunctionInfo(
|
|
345
|
+
node.name,
|
|
346
|
+
scope,
|
|
347
|
+
self.module,
|
|
348
|
+
tuple(arg.arg for arg in parameters),
|
|
349
|
+
isinstance(node, ast.AsyncFunctionDef),
|
|
350
|
+
self._location(node),
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
locals_ = _BoundNames()
|
|
354
|
+
for statement in node.body:
|
|
355
|
+
locals_.visit(statement)
|
|
356
|
+
self.env = {
|
|
357
|
+
name: binding
|
|
358
|
+
for name, binding in self._module_env.items()
|
|
359
|
+
if binding.qualified and self._module_bindings[name] == 1
|
|
360
|
+
}
|
|
361
|
+
self.env.update({name: _Binding() for name in locals_.names})
|
|
362
|
+
self.scope = scope
|
|
363
|
+
self._uncertain += int(
|
|
364
|
+
parent_scope != "<module>"
|
|
365
|
+
or bool(node.decorator_list)
|
|
366
|
+
or isinstance(node, ast.AsyncFunctionDef)
|
|
367
|
+
)
|
|
368
|
+
for arg in parameters:
|
|
369
|
+
if arg is node.args.vararg or arg is node.args.kwarg:
|
|
370
|
+
self.env[arg.arg] = _Binding(reason="Variadic parameters are containers.")
|
|
371
|
+
else:
|
|
372
|
+
self.env[arg.arg] = self._annotation(arg.annotation, arg, parent_env)
|
|
373
|
+
if self._uncertain:
|
|
374
|
+
self._diagnose(
|
|
375
|
+
"CT_FUNCTION_CONTEXT",
|
|
376
|
+
node,
|
|
377
|
+
"Nested, decorated, async or conditional functions have unresolved context.",
|
|
378
|
+
"Occurrences are candidates; direct function analysis is a later checkpoint.",
|
|
379
|
+
)
|
|
380
|
+
for statement in node.body:
|
|
381
|
+
self.visit(statement)
|
|
382
|
+
self.env, self.scope, self._uncertain = parent_env, parent_scope, uncertain
|
|
383
|
+
self.env[node.name] = _Binding()
|
|
384
|
+
|
|
385
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
386
|
+
|
|
387
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
388
|
+
env, scope, uncertain = self.env, self.scope, self._uncertain
|
|
389
|
+
for expression in [*node.decorator_list, *node.bases]:
|
|
390
|
+
self.visit(expression)
|
|
391
|
+
for keyword in node.keywords:
|
|
392
|
+
self.visit(keyword.value)
|
|
393
|
+
self.env = dict(env)
|
|
394
|
+
self.scope = node.name if scope == "<module>" else f"{scope}.{node.name}"
|
|
395
|
+
self._uncertain += 1
|
|
396
|
+
for statement in node.body:
|
|
397
|
+
self.visit(statement)
|
|
398
|
+
self.env, self.scope, self._uncertain = env, scope, uncertain
|
|
399
|
+
self.env[node.name] = _Binding()
|
|
400
|
+
|
|
401
|
+
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
402
|
+
env, scope, uncertain = self.env, self.scope, self._uncertain
|
|
403
|
+
for default in [*node.args.defaults, *node.args.kw_defaults]:
|
|
404
|
+
if default:
|
|
405
|
+
self.visit(default)
|
|
406
|
+
self.env = dict(env)
|
|
407
|
+
self.env.update({arg.arg: _Binding() for arg in _parameters(node.args)})
|
|
408
|
+
self.scope = f"{scope}.<lambda>@{node.lineno}:{node.col_offset}"
|
|
409
|
+
self._uncertain += 1
|
|
410
|
+
self.visit(node.body)
|
|
411
|
+
self.env, self.scope, self._uncertain = env, scope, uncertain
|
|
412
|
+
|
|
413
|
+
def _invalidate(self, ids: set[str | None]) -> None:
|
|
414
|
+
for name, binding in list(self.env.items()):
|
|
415
|
+
if binding.dataframe_id and binding.dataframe_id in ids:
|
|
416
|
+
self.env[name] = _Binding(reason="A potentially mutating operation is unresolved.")
|
|
417
|
+
|
|
418
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
419
|
+
self.generic_visit(node)
|
|
420
|
+
origin = self._qualified(node.func)
|
|
421
|
+
if origin in {"pandas.read_csv", "pandas.read_parquet", "pandas.DataFrame"}:
|
|
422
|
+
return
|
|
423
|
+
inputs: list[ast.AST] = [*node.args, *(kw.value for kw in node.keywords)]
|
|
424
|
+
if isinstance(node.func, ast.Attribute):
|
|
425
|
+
inputs.append(node.func.value)
|
|
426
|
+
ids = {
|
|
427
|
+
self.env.get(arg.id, _Binding()).dataframe_id
|
|
428
|
+
for arg in inputs
|
|
429
|
+
if isinstance(arg, ast.Name)
|
|
430
|
+
} - {None}
|
|
431
|
+
if ids:
|
|
432
|
+
self._diagnose(
|
|
433
|
+
"CT_OPAQUE_CALL",
|
|
434
|
+
node,
|
|
435
|
+
"Effects or returned DataFrame of this call are not resolved in Phase 1.",
|
|
436
|
+
"Receiver evidence after this call is invalidated, including direct aliases.",
|
|
437
|
+
)
|
|
438
|
+
self._invalidate(ids)
|
|
439
|
+
|
|
440
|
+
def _subscript(
|
|
441
|
+
self,
|
|
442
|
+
node: ast.Subscript,
|
|
443
|
+
access: Literal["read", "write", "delete", "read_write"] | None = None,
|
|
444
|
+
*,
|
|
445
|
+
force_unsupported: bool = False,
|
|
446
|
+
) -> None:
|
|
447
|
+
receiver, label = node.value, node.slice
|
|
448
|
+
syntax: Literal["subscript", "loc"] = "subscript"
|
|
449
|
+
full_rows = True
|
|
450
|
+
if isinstance(receiver, ast.Attribute) and receiver.attr == "loc":
|
|
451
|
+
receiver = receiver.value
|
|
452
|
+
syntax = "loc"
|
|
453
|
+
full_rows = False
|
|
454
|
+
if isinstance(label, ast.Tuple) and len(label.elts) == 2:
|
|
455
|
+
rows, label = label.elts
|
|
456
|
+
full_rows = isinstance(rows, ast.Slice) and all(
|
|
457
|
+
value is None for value in (rows.lower, rows.upper, rows.step)
|
|
458
|
+
)
|
|
459
|
+
binding = (
|
|
460
|
+
self.env.get(receiver.id, _Binding()) if isinstance(receiver, ast.Name) else _Binding()
|
|
461
|
+
)
|
|
462
|
+
if self._uncertain and binding.evidence != "rejected":
|
|
463
|
+
binding = _Binding(reason="This occurrence is in an unsupported execution context.")
|
|
464
|
+
column = (
|
|
465
|
+
label.value
|
|
466
|
+
if isinstance(label, ast.Constant) and isinstance(label.value, str)
|
|
467
|
+
else None
|
|
468
|
+
)
|
|
469
|
+
supported = column is not None and full_rows and not force_unsupported
|
|
470
|
+
mode: Literal["read", "write", "delete", "read_write"] = access or (
|
|
471
|
+
"write"
|
|
472
|
+
if isinstance(node.ctx, ast.Store)
|
|
473
|
+
else "delete"
|
|
474
|
+
if isinstance(node.ctx, ast.Del)
|
|
475
|
+
else "read"
|
|
476
|
+
)
|
|
477
|
+
self.occurrences.append(
|
|
478
|
+
ColumnOccurrence(
|
|
479
|
+
column,
|
|
480
|
+
ast.get_source_segment(self.source, receiver) or "<expression>",
|
|
481
|
+
binding.dataframe_id,
|
|
482
|
+
binding.evidence,
|
|
483
|
+
binding.reason,
|
|
484
|
+
mode,
|
|
485
|
+
syntax,
|
|
486
|
+
supported,
|
|
487
|
+
self.scope,
|
|
488
|
+
self.module,
|
|
489
|
+
self._location(node),
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
if binding.evidence == "unresolved":
|
|
493
|
+
self._diagnose(
|
|
494
|
+
"CT_UNKNOWN_RECEIVER",
|
|
495
|
+
node,
|
|
496
|
+
"The subscript receiver is not a resolved DataFrame.",
|
|
497
|
+
"Provide a recognized Pandas origin or an explicit DataFrame annotation.",
|
|
498
|
+
)
|
|
499
|
+
elif binding.evidence != "rejected" and not supported:
|
|
500
|
+
self._diagnose(
|
|
501
|
+
"CT_UNSUPPORTED_INDEX",
|
|
502
|
+
node,
|
|
503
|
+
"Only literal string columns and full-row .loc accesses are supported.",
|
|
504
|
+
"Dynamic labels, partial rows and augmented writes remain unresolved.",
|
|
505
|
+
)
|
|
506
|
+
self.generic_visit(node)
|
|
507
|
+
|
|
508
|
+
def visit_Subscript(self, node: ast.Subscript) -> None:
|
|
509
|
+
self._subscript(node)
|
|
510
|
+
|
|
511
|
+
def _unsupported_context(self, node: ast.AST) -> None:
|
|
512
|
+
env, uncertain = self.env, self._uncertain
|
|
513
|
+
self.env = dict(env)
|
|
514
|
+
self._uncertain += 1
|
|
515
|
+
self._diagnose(
|
|
516
|
+
"CT_CONTROL_FLOW",
|
|
517
|
+
node,
|
|
518
|
+
"Control flow is not resolved in Phase 1.",
|
|
519
|
+
"Occurrences in this region are candidates; affected evidence is invalidated.",
|
|
520
|
+
)
|
|
521
|
+
self.generic_visit(node)
|
|
522
|
+
self.env, self._uncertain = env, uncertain
|
|
523
|
+
names = _BoundNames()
|
|
524
|
+
names.visit(node)
|
|
525
|
+
for name in names.names:
|
|
526
|
+
self.env[name] = _Binding(reason="The binding is affected by unsupported control flow.")
|
|
527
|
+
self._invalidate({binding.dataframe_id for binding in self.env.values()})
|
|
528
|
+
for name, binding in list(self.env.items()):
|
|
529
|
+
if binding.qualified:
|
|
530
|
+
self.env[name] = _Binding(
|
|
531
|
+
reason="API bindings across this boundary are unresolved."
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
visit_If = _unsupported_context
|
|
535
|
+
visit_For = _unsupported_context
|
|
536
|
+
visit_AsyncFor = _unsupported_context
|
|
537
|
+
visit_While = _unsupported_context
|
|
538
|
+
visit_Try = _unsupported_context
|
|
539
|
+
visit_TryStar = _unsupported_context
|
|
540
|
+
visit_With = _unsupported_context
|
|
541
|
+
visit_AsyncWith = _unsupported_context
|
|
542
|
+
visit_Match = _unsupported_context
|
|
543
|
+
visit_ListComp = _unsupported_context
|
|
544
|
+
visit_SetComp = _unsupported_context
|
|
545
|
+
visit_DictComp = _unsupported_context
|
|
546
|
+
visit_GeneratorExp = _unsupported_context
|
|
547
|
+
visit_NamedExpr = _unsupported_context
|
|
548
|
+
|
|
549
|
+
def visit_Global(self, node: ast.Global | ast.Nonlocal) -> None:
|
|
550
|
+
for name in node.names:
|
|
551
|
+
self.env[name] = _Binding(
|
|
552
|
+
reason="Global/nonlocal bindings are not resolved in Phase 1."
|
|
553
|
+
)
|
|
554
|
+
self._diagnose(
|
|
555
|
+
"CT_EXTERNAL_BINDING",
|
|
556
|
+
node,
|
|
557
|
+
"Global/nonlocal state is not resolved.",
|
|
558
|
+
"Prefer explicit parameters for statically analyzable inputs.",
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
visit_Nonlocal = visit_Global
|