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/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