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