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/graph.py ADDED
@@ -0,0 +1,380 @@
1
+ """Column queries over the rich operation graph and its column projection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from dataclasses import asdict
7
+ from typing import Any
8
+
9
+ import networkx as nx
10
+
11
+ from .lineage_models import Column, LineageReport
12
+
13
+ _CONFIDENCE = {"unresolved": 0, "inferred": 1, "confirmed": 2}
14
+ _COMPLETENESS = {"unresolved": 0, "partial": 1, "complete": 2}
15
+
16
+
17
+ class ColumnNotFoundError(ValueError):
18
+ """No column matched the requested identity and filters."""
19
+
20
+
21
+ class AmbiguousColumnError(ValueError):
22
+ """Several DataFrame identities matched; callers must disambiguate."""
23
+
24
+ def __init__(self, selector: str, candidates: tuple[Column, ...]) -> None:
25
+ self.candidates = candidates
26
+ super().__init__(f"Column {selector!r} is ambiguous ({len(candidates)} identities).")
27
+
28
+
29
+ def _weakest(values: list[str], ranks: dict[str, int]) -> str:
30
+ return min(values, key=ranks.__getitem__)
31
+
32
+
33
+ class LineageGraph:
34
+ """Build queryable graphs without equating columns by their textual names.
35
+
36
+ ``rich`` contains column and operation nodes. ``columns`` projects each
37
+ read/write pair into one relation, preserving parallel operations. Selection
38
+ by name chooses the latest version *within each frame*; distinct frame IDs
39
+ remain ambiguous. Selecting an ID always refers to that exact version.
40
+ """
41
+
42
+ def __init__(self, report: LineageReport) -> None:
43
+ self.report = report
44
+ self.by_id = {column.id: column for column in report.columns}
45
+ self.rich: nx.MultiDiGraph[str] = nx.MultiDiGraph()
46
+ self.columns: nx.MultiDiGraph[str] = nx.MultiDiGraph()
47
+ for column in report.columns:
48
+ self.rich.add_node(column.id, node_type="column", **asdict(column))
49
+ self.columns.add_node(column.id, **asdict(column))
50
+ for operation in report.operations:
51
+ self.rich.add_node(operation.id, node_type="operation", **asdict(operation))
52
+ for edge in report.edges:
53
+ self.rich.add_edge(edge.source, edge.target, **asdict(edge))
54
+ for operation in report.operations:
55
+ reads = [
56
+ (source, data)
57
+ for source, _, data in self.rich.in_edges(operation.id, data=True)
58
+ if data["kind"] == "read" and source in self.by_id
59
+ ]
60
+ writes = [
61
+ (target, data)
62
+ for _, target, data in self.rich.out_edges(operation.id, data=True)
63
+ if data["kind"] == "write" and target in self.by_id
64
+ ]
65
+ relation = operation.kind if operation.kind in {"rename", "pass"} else "derive"
66
+ for source, read in reads:
67
+ for target, write in writes:
68
+ evidence = _weakest(
69
+ [
70
+ operation.evidence,
71
+ read["evidence"],
72
+ write["evidence"],
73
+ self.by_id[source].evidence,
74
+ self.by_id[target].evidence,
75
+ ],
76
+ _CONFIDENCE,
77
+ )
78
+ self.columns.add_edge(
79
+ source,
80
+ target,
81
+ kind=relation,
82
+ operation_id=operation.id,
83
+ location=asdict(operation.location),
84
+ evidence=evidence,
85
+ completeness=_weakest(
86
+ [
87
+ operation.completeness,
88
+ self.by_id[source].completeness,
89
+ self.by_id[target].completeness,
90
+ ],
91
+ _COMPLETENESS,
92
+ ),
93
+ )
94
+ self._passes: nx.DiGraph[str] = nx.DiGraph()
95
+ self._passes.add_nodes_from(self.by_id)
96
+ for source, target, data in self.columns.edges(data=True):
97
+ if data["kind"] == "pass" and self.by_id[source].name == self.by_id[target].name:
98
+ self._passes.add_edge(source, target)
99
+
100
+ def select(
101
+ self,
102
+ selector: str,
103
+ *,
104
+ dataframe: str | None = None,
105
+ module: str | None = None,
106
+ function: str | None = None,
107
+ version: int | None = None,
108
+ ) -> Column:
109
+ """Select exactly one column; filters use exact, case-sensitive names."""
110
+ candidates = [
111
+ column
112
+ for column in self.report.columns
113
+ if (column.id == selector if selector in self.by_id else column.name == selector)
114
+ and (dataframe is None or column.dataframe_id == dataframe)
115
+ and (module is None or column.module == module)
116
+ and (function is None or column.scope == function)
117
+ and (version is None or column.version == version)
118
+ ]
119
+ if selector not in self.by_id and version is None:
120
+ latest: dict[tuple[str, str], Column] = {}
121
+ for column in candidates:
122
+ key = (column.dataframe_id, column.name)
123
+ if key not in latest or column.version > latest[key].version:
124
+ latest[key] = column
125
+ candidates = list(latest.values())
126
+ candidates.sort(key=lambda column: column.id)
127
+ if not candidates:
128
+ raise ColumnNotFoundError(f"No column matches {selector!r} and these filters.")
129
+ if len(candidates) > 1:
130
+ family = nx.node_connected_component(self._passes.to_undirected(), candidates[0].id)
131
+ if (
132
+ selector not in self.by_id
133
+ and version is None
134
+ and all(column.id in family for column in candidates)
135
+ ):
136
+ # Copies/projections of the same values are one selectable
137
+ # lineage family. A same-name value derivation or an unrelated
138
+ # DataFrame still requires explicit disambiguation.
139
+ candidates.sort(
140
+ key=lambda column: (len(nx.ancestors(self._passes, column.id)), column.id)
141
+ )
142
+ else:
143
+ raise AmbiguousColumnError(selector, tuple(candidates))
144
+ return candidates[0]
145
+
146
+ def _filtered(self, relation: str | None, evidence: str) -> nx.MultiDiGraph[str]:
147
+ if relation not in {None, "derive", "rename", "pass"}:
148
+ raise ValueError("relation must be derive, rename or pass")
149
+ if evidence not in {"all", "confirmed"}:
150
+ raise ValueError("evidence must be all or confirmed")
151
+ graph: nx.MultiDiGraph[str] = nx.MultiDiGraph()
152
+ graph.add_nodes_from(self.columns.nodes(data=True))
153
+ for source, target, key, data in self.columns.edges(keys=True, data=True):
154
+ if (relation is None or data["kind"] == relation) and (
155
+ evidence == "all" or data["evidence"] == "confirmed"
156
+ ):
157
+ graph.add_edge(source, target, key=key, **data)
158
+ return graph
159
+
160
+ def _walk(self, graph: nx.MultiDiGraph[str], start: str, depth: int | None) -> dict[str, Any]:
161
+ # Track evidence states separately: a longer confirmed path must not be
162
+ # hidden by an earlier, shorter inferred path. Finite states stop cycles.
163
+ queue = deque([(start, "confirmed", "complete", 0)])
164
+ seen = {(start, "confirmed", "complete"): 0}
165
+ reached: dict[str, tuple[int, str, str]] = {}
166
+ while queue:
167
+ node, confidence, completeness, distance = queue.popleft()
168
+ if depth is not None and distance >= depth:
169
+ continue
170
+ for _, target, data in graph.out_edges(node, data=True):
171
+ new_confidence = _weakest([confidence, data["evidence"]], _CONFIDENCE)
172
+ new_completeness = _weakest([completeness, data["completeness"]], _COMPLETENESS)
173
+ next_distance = distance + 1
174
+ state = (target, new_confidence, new_completeness)
175
+ if state in seen and seen[state] <= next_distance:
176
+ continue
177
+ seen[state] = next_distance
178
+ queue.append((target, new_confidence, new_completeness, next_distance))
179
+ if target == start:
180
+ continue
181
+ previous = reached.get(target)
182
+ if previous is None:
183
+ reached[target] = (next_distance, new_confidence, new_completeness)
184
+ else:
185
+ best = max(
186
+ [(previous[1], previous[2]), (new_confidence, new_completeness)],
187
+ key=lambda item: (_CONFIDENCE[item[0]], _COMPLETENESS[item[1]]),
188
+ )
189
+ reached[target] = (min(previous[0], next_distance), *best)
190
+ direct: list[dict[str, Any]] = []
191
+ transitive: list[dict[str, Any]] = []
192
+ for node, (distance, confidence, completeness) in sorted(
193
+ reached.items(), key=lambda item: (item[1][0], item[0])
194
+ ):
195
+ entry = {
196
+ **asdict(self.by_id[node]),
197
+ "distance": distance,
198
+ "path_evidence": confidence,
199
+ "path_completeness": completeness,
200
+ }
201
+ (direct if distance == 1 else transitive).append(entry)
202
+ return {"direct": direct, "transitive": transitive}
203
+
204
+ def trace(
205
+ self,
206
+ column_id: str,
207
+ *,
208
+ direction: str = "both",
209
+ depth: int | None = None,
210
+ relation: str | None = None,
211
+ evidence: str = "all",
212
+ ) -> dict[str, Any]:
213
+ """Return provenance and potential consumers, counted in column hops.
214
+
215
+ Path evidence describes the strongest available path inside the chosen
216
+ depth, whose weakest relation determines that path's evidence. Distance
217
+ is independently the shortest eligible path. Impact is potential: an
218
+ edge does not establish that changed inputs change a consumer's output.
219
+ """
220
+ if column_id not in self.by_id:
221
+ raise ColumnNotFoundError(f"Unknown column ID: {column_id}")
222
+ if direction not in {"both", "upstream", "downstream"}:
223
+ raise ValueError("direction must be both, upstream or downstream")
224
+ if depth is not None and (type(depth) is not int or depth < 0):
225
+ raise ValueError("depth must be a non-negative integer")
226
+ graph = self._filtered(relation, evidence)
227
+ upstream = (
228
+ self._walk(graph.reverse(copy=False), column_id, depth)
229
+ if direction in {"both", "upstream"}
230
+ else {"direct": [], "transitive": []}
231
+ )
232
+ downstream = (
233
+ self._walk(graph, column_id, depth)
234
+ if direction in {"both", "downstream"}
235
+ else {"direct": [], "transitive": []}
236
+ )
237
+ included = {column_id}
238
+ for result in (upstream, downstream):
239
+ included.update(item["id"] for group in result.values() for item in group)
240
+ relations = [
241
+ {"source": source, "target": target, **data}
242
+ for source, target, data in graph.edges(data=True)
243
+ if source in included and target in included
244
+ ]
245
+ relations.sort(key=lambda item: (item["source"], item["target"], item["operation_id"]))
246
+ consumers = [item for item in self.report.consumptions if item.column_id in included]
247
+ relevant_operations = {item["operation_id"] for item in relations}
248
+ functions = {(self.by_id[node].module, self.by_id[node].scope) for node in included}
249
+ functions.update((item.module, item.scope) for item in consumers)
250
+ for operation in self.report.operations:
251
+ if operation.id in relevant_operations:
252
+ functions.add((operation.module, operation.scope))
253
+ from .history import build_history, lifetime_versions
254
+ from .journey import build_journey
255
+
256
+ # A latest-version value query cannot reach a derivative created before
257
+ # a constant overwrite. The lifetime view separately includes earlier
258
+ # versions, under the same graph filters. An explicitly older version
259
+ # does not acquire derivatives of a later independent reset.
260
+ selected = self.by_id[column_id]
261
+ journey_included = set(included)
262
+ versions = lifetime_versions(self.report, selected)
263
+ journey_seeds = {column_id}
264
+ for earlier in self.report.columns:
265
+ if (
266
+ earlier.id not in versions
267
+ or earlier.version > selected.version
268
+ or (evidence == "confirmed" and earlier.evidence != "confirmed")
269
+ ):
270
+ continue
271
+ journey_seeds.add(earlier.id)
272
+ journey_included.add(earlier.id)
273
+ for adjacent, eligible in (
274
+ (graph, direction in {"both", "downstream"}),
275
+ (graph.reverse(copy=False), direction in {"both", "upstream"}),
276
+ ):
277
+ if eligible:
278
+ reached = self._walk(adjacent, earlier.id, depth)
279
+ journey_included.update(
280
+ item["id"] for group in reached.values() for item in group
281
+ )
282
+ journey_relations = [
283
+ {"source": source, "target": target, **data}
284
+ for source, target, data in graph.edges(data=True)
285
+ if source in journey_included and target in journey_included
286
+ ]
287
+ history = build_history(self.report, column_id, journey_included, evidence=evidence)
288
+ journey = build_journey(
289
+ self.report,
290
+ column_id,
291
+ journey_included,
292
+ evidence=evidence,
293
+ relations=journey_relations,
294
+ )
295
+ journey["seed_column_ids"] = sorted(journey_seeds)
296
+ locations = []
297
+ for node in history["nodes"]:
298
+ place = node["location"]
299
+ if node.get("kind") == "call" or node.get("label") == "callback input":
300
+ place = {**place, "end_line": place["line"]}
301
+ locations.append((place, node["scope"]))
302
+ for item in consumers:
303
+ place = asdict(item.location)
304
+ if item.kind == "callback_input":
305
+ place["end_line"] = place["line"]
306
+ locations.append((place, item.scope))
307
+
308
+ def relevant_diagnostic(location: dict[str, Any], scope: str) -> bool:
309
+ # A diagnostic elsewhere in a large function is not a statement
310
+ # about this column. Recorded boundaries and exact source spans
311
+ # identify the relevant uncertainty instead of scope-wide noise.
312
+ return any(
313
+ scope == other_scope
314
+ and location["path"] == other["path"]
315
+ and location["line"] <= (other["end_line"] or other["line"])
316
+ and other["line"] <= (location["end_line"] or location["line"])
317
+ for other, other_scope in locations
318
+ )
319
+
320
+ return {
321
+ "column": asdict(self.by_id[column_id]),
322
+ "upstream": upstream,
323
+ "downstream": downstream,
324
+ "consumptions": [asdict(item) for item in consumers],
325
+ "functions": [
326
+ {"module": module, "scope": scope} for module, scope in sorted(functions)
327
+ ],
328
+ "diagnostics": [
329
+ asdict(item)
330
+ for item in self.report.diagnostics
331
+ if relevant_diagnostic(asdict(item.location), item.scope)
332
+ and not (
333
+ item.code == "CT_UNSUPPORTED_CONTROL_FLOW"
334
+ and any(
335
+ str(node.get("kind", "")).startswith("callback_")
336
+ and node.get("label", "").startswith("Type guard;")
337
+ and node["location"]["path"] == item.location.path
338
+ and node["location"]["line"] == item.location.line
339
+ and node["scope"] == item.scope
340
+ for node in history["nodes"]
341
+ )
342
+ )
343
+ ],
344
+ "relations": relations,
345
+ "depth": depth,
346
+ "direction": direction,
347
+ "evidence": evidence,
348
+ "relation": relation,
349
+ "history": history,
350
+ "journey": journey,
351
+ }
352
+
353
+ def impact(self, column_id: str, **kwargs: Any) -> dict[str, Any]:
354
+ """List potential direct and transitive downstream impact."""
355
+ result = self.trace(column_id, direction="downstream", **kwargs)
356
+ direct = {(item["module"], item["scope"]) for item in result["downstream"]["direct"]}
357
+ transitive = {
358
+ (item["module"], item["scope"]) for item in result["downstream"]["transitive"]
359
+ }
360
+ direct.update(
361
+ (item["module"], item["scope"])
362
+ for item in result["consumptions"]
363
+ if item["column_id"] == column_id
364
+ )
365
+ transitive.update(
366
+ (item["module"], item["scope"])
367
+ for item in result["consumptions"]
368
+ if item["column_id"] != column_id
369
+ )
370
+ transitive.difference_update(direct)
371
+ result["direct_functions"] = [
372
+ {"module": module, "scope": scope} for module, scope in sorted(direct)
373
+ ]
374
+ result["transitive_functions"] = [
375
+ {"module": module, "scope": scope} for module, scope in sorted(transitive)
376
+ ]
377
+ result["functions"] = [
378
+ {"module": module, "scope": scope} for module, scope in sorted(direct | transitive)
379
+ ]
380
+ return result