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/journey.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""Usage-first column lifetimes, kept separate from value ancestry.
|
|
2
|
+
|
|
3
|
+
An input propagated through a callback summary is not a literal column read.
|
|
4
|
+
Similarly, preserving a column in a filtered frame does not mean that the
|
|
5
|
+
filter predicate read that column. These distinctions belong in the public
|
|
6
|
+
query, rather than being left for a visual renderer to guess.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
from collections import defaultdict, deque
|
|
13
|
+
from dataclasses import asdict
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .history import _fallback_events, lifetime_versions
|
|
17
|
+
from .lineage_models import Column, LineageReport
|
|
18
|
+
|
|
19
|
+
_PASS_KINDS = {"pass", "copy", "filter", "dropna", "projection"}
|
|
20
|
+
_WRITE_KINDS = {"derive", "write", "assign", "apply", "overwrite", "rename", "source"}
|
|
21
|
+
_BOUNDARY_KINDS = {"boundary", "opaque", "opaque_call", "unresolved"}
|
|
22
|
+
_BOOKKEEPING = {"argument", "apply input", "callback input", "string transform"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _relations(report: LineageReport) -> list[dict[str, Any]]:
|
|
26
|
+
reads: dict[str, list[str]] = defaultdict(list)
|
|
27
|
+
writes: dict[str, list[str]] = defaultdict(list)
|
|
28
|
+
for edge in report.edges:
|
|
29
|
+
(reads[edge.target] if edge.kind == "read" else writes[edge.source]).append(
|
|
30
|
+
edge.source if edge.kind == "read" else edge.target
|
|
31
|
+
)
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
"source": source,
|
|
35
|
+
"target": target,
|
|
36
|
+
"kind": operation.kind if operation.kind in {"pass", "rename"} else "derive",
|
|
37
|
+
"evidence": operation.evidence,
|
|
38
|
+
}
|
|
39
|
+
for operation in report.operations
|
|
40
|
+
for source in reads[operation.id]
|
|
41
|
+
for target in writes[operation.id]
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _binding(event: dict[str, Any]) -> str:
|
|
46
|
+
try:
|
|
47
|
+
tree = ast.parse(event["expression"])
|
|
48
|
+
except (SyntaxError, ValueError, RecursionError):
|
|
49
|
+
return ""
|
|
50
|
+
if len(tree.body) == 1:
|
|
51
|
+
statement = tree.body[0]
|
|
52
|
+
if isinstance(statement, ast.Assign) and isinstance(statement.targets[0], ast.Name):
|
|
53
|
+
return statement.targets[0].id
|
|
54
|
+
if isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Name):
|
|
55
|
+
return statement.value.id
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _projection_names(event: dict[str, Any]) -> set[str] | None:
|
|
60
|
+
"""Read literal projection syntax; never inspect user data or execute code."""
|
|
61
|
+
try:
|
|
62
|
+
tree = ast.parse(event["expression"])
|
|
63
|
+
except (SyntaxError, ValueError, RecursionError):
|
|
64
|
+
return None
|
|
65
|
+
expression: ast.AST | None = None
|
|
66
|
+
if len(tree.body) == 1 and isinstance(tree.body[0], ast.Expr):
|
|
67
|
+
expression = tree.body[0].value
|
|
68
|
+
if isinstance(expression, ast.Subscript) and isinstance(expression.slice, ast.List):
|
|
69
|
+
labels = expression.slice.elts
|
|
70
|
+
if all(isinstance(item, ast.Constant) and isinstance(item.value, str) for item in labels):
|
|
71
|
+
return {
|
|
72
|
+
item.value
|
|
73
|
+
for item in labels
|
|
74
|
+
if isinstance(item, ast.Constant) and isinstance(item.value, str)
|
|
75
|
+
}
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _stage(event: dict[str, Any], relationship: str, explanation: str) -> dict[str, Any]:
|
|
80
|
+
category = {
|
|
81
|
+
"origin": "source",
|
|
82
|
+
"application": "transform",
|
|
83
|
+
"direct_write": "transform",
|
|
84
|
+
"derivation": "derivation",
|
|
85
|
+
"frame_pass": "frame",
|
|
86
|
+
"boundary": "boundary",
|
|
87
|
+
}.get(relationship, "use")
|
|
88
|
+
return {
|
|
89
|
+
**event,
|
|
90
|
+
"category": category,
|
|
91
|
+
"relationship": relationship,
|
|
92
|
+
"explanation": explanation,
|
|
93
|
+
"event_ids": [event["id"]],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _contains(outer: dict[str, Any], inner: dict[str, Any]) -> bool:
|
|
98
|
+
"""Check code spans so a different expression on the same line cannot match."""
|
|
99
|
+
return bool(
|
|
100
|
+
outer["path"] == inner["path"]
|
|
101
|
+
and (outer["line"], outer["column"]) <= (inner["line"], inner["column"])
|
|
102
|
+
and (
|
|
103
|
+
outer["end_line"] or outer["line"],
|
|
104
|
+
outer["end_column"] if outer["end_column"] is not None else 10**9,
|
|
105
|
+
)
|
|
106
|
+
>= (
|
|
107
|
+
inner["end_line"] or inner["line"],
|
|
108
|
+
inner["end_column"] if inner["end_column"] is not None else inner["column"],
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _compact(steps: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
114
|
+
"""Combine recorder bookkeeping without merging independent value actions."""
|
|
115
|
+
result: list[dict[str, Any]] = []
|
|
116
|
+
previous_key: tuple[str, str, int, str, str] | None = None
|
|
117
|
+
for step in steps:
|
|
118
|
+
# A direct application/string method already describes its receiver's
|
|
119
|
+
# read. Keep standalone reads where no richer action exists.
|
|
120
|
+
if (
|
|
121
|
+
step["relationship"] == "direct_read"
|
|
122
|
+
and step["kind"] == "read"
|
|
123
|
+
and any(
|
|
124
|
+
(
|
|
125
|
+
other["relationship"] in {"application", "direct_write", "derivation"}
|
|
126
|
+
or (other["relationship"] == "direct_read" and other["kind"] == "consume")
|
|
127
|
+
)
|
|
128
|
+
and other["location"]["path"] == step["location"]["path"]
|
|
129
|
+
and other["location"]["line"] == step["location"]["line"]
|
|
130
|
+
and other["context"] == step["context"]
|
|
131
|
+
and set(other["inputs"]).intersection(step["inputs"])
|
|
132
|
+
for other in steps
|
|
133
|
+
)
|
|
134
|
+
):
|
|
135
|
+
continue
|
|
136
|
+
relation = step["relationship"]
|
|
137
|
+
key = (
|
|
138
|
+
step["context"],
|
|
139
|
+
step["location"]["path"],
|
|
140
|
+
step["location"]["line"],
|
|
141
|
+
relation,
|
|
142
|
+
step["kind"] if relation == "frame_pass" else "",
|
|
143
|
+
)
|
|
144
|
+
merge = relation in {"export", "boundary", "frame_pass", "projection_exclusion"}
|
|
145
|
+
# A call and its later result binding may share one source line while
|
|
146
|
+
# a whole function body lies between them. Contracting non-adjacent
|
|
147
|
+
# events can turn that acyclic causal path into a graph cycle.
|
|
148
|
+
previous = result[-1] if merge and previous_key == key else None
|
|
149
|
+
if previous is None:
|
|
150
|
+
result.append(step)
|
|
151
|
+
previous_key = key if merge else None
|
|
152
|
+
else:
|
|
153
|
+
for field in ("inputs", "outputs", "dataframe_ids", "event_ids"):
|
|
154
|
+
previous[field] = list(dict.fromkeys((*previous[field], *step[field])))
|
|
155
|
+
if len(step["expression"]) > len(previous["expression"]):
|
|
156
|
+
previous["label"], previous["expression"] = step["label"], step["expression"]
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build_journey(
|
|
161
|
+
report: LineageReport,
|
|
162
|
+
selected_id: str,
|
|
163
|
+
included_ids: set[str] | None = None,
|
|
164
|
+
*,
|
|
165
|
+
evidence: str = "all",
|
|
166
|
+
relations: list[dict[str, Any]] | None = None,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Explain actual recorded uses and destinations of one logical column.
|
|
169
|
+
|
|
170
|
+
Families join explicit unchanged passes/renames and versions of one frame's
|
|
171
|
+
label. They never join unrelated same-name columns. Filtered graph relations
|
|
172
|
+
are supplied by ``LineageGraph`` so evidence/depth restrictions remain in
|
|
173
|
+
force. A lifetime can include a constant overwrite without inventing a
|
|
174
|
+
value-dependency edge between its old and new values.
|
|
175
|
+
"""
|
|
176
|
+
columns = {column.id: column for column in report.columns}
|
|
177
|
+
if selected_id not in columns:
|
|
178
|
+
raise ValueError(f"Unknown column ID: {selected_id}")
|
|
179
|
+
selected = columns[selected_id]
|
|
180
|
+
allowed = set(columns if included_ids is None else included_ids)
|
|
181
|
+
allowed.add(selected_id)
|
|
182
|
+
allowed.update(
|
|
183
|
+
identifier
|
|
184
|
+
for identifier in lifetime_versions(report, selected)
|
|
185
|
+
if evidence != "confirmed" or columns[identifier].evidence == "confirmed"
|
|
186
|
+
)
|
|
187
|
+
allowed.intersection_update(columns)
|
|
188
|
+
relations = _relations(report) if relations is None else relations
|
|
189
|
+
relations = [
|
|
190
|
+
relation
|
|
191
|
+
for relation in relations
|
|
192
|
+
if relation["source"] in allowed
|
|
193
|
+
and relation["target"] in allowed
|
|
194
|
+
and (evidence != "confirmed" or relation.get("evidence") == "confirmed")
|
|
195
|
+
]
|
|
196
|
+
parent = {identifier: identifier for identifier in allowed}
|
|
197
|
+
|
|
198
|
+
def find(identifier: str) -> str:
|
|
199
|
+
while parent[identifier] != identifier:
|
|
200
|
+
parent[identifier] = parent[parent[identifier]]
|
|
201
|
+
identifier = parent[identifier]
|
|
202
|
+
return identifier
|
|
203
|
+
|
|
204
|
+
def union(first: str, second: str) -> None:
|
|
205
|
+
parent[find(second)] = find(first)
|
|
206
|
+
|
|
207
|
+
labels: dict[tuple[str, str], str] = {}
|
|
208
|
+
for identifier in sorted(allowed):
|
|
209
|
+
column = columns[identifier]
|
|
210
|
+
key = (column.dataframe_id, column.name)
|
|
211
|
+
if key in labels:
|
|
212
|
+
union(labels[key], identifier)
|
|
213
|
+
labels[key] = identifier
|
|
214
|
+
for relation in relations:
|
|
215
|
+
source, target = relation["source"], relation["target"]
|
|
216
|
+
if relation["kind"] == "rename" or (
|
|
217
|
+
relation["kind"] == "pass" and columns[source].name == columns[target].name
|
|
218
|
+
):
|
|
219
|
+
union(source, target)
|
|
220
|
+
families: dict[str, set[str]] = defaultdict(set)
|
|
221
|
+
for identifier in allowed:
|
|
222
|
+
families[find(identifier)].add(identifier)
|
|
223
|
+
selected_family = find(selected_id)
|
|
224
|
+
members = families[selected_family]
|
|
225
|
+
forward: dict[str, set[str]] = defaultdict(set)
|
|
226
|
+
for relation in relations:
|
|
227
|
+
source, target = find(relation["source"]), find(relation["target"])
|
|
228
|
+
if source != target:
|
|
229
|
+
forward[source].add(target)
|
|
230
|
+
distances = {selected_family: 0}
|
|
231
|
+
queue = deque([selected_family])
|
|
232
|
+
while queue:
|
|
233
|
+
source = queue.popleft()
|
|
234
|
+
for target in sorted(forward[source]):
|
|
235
|
+
if target not in distances:
|
|
236
|
+
distances[target] = distances[source] + 1
|
|
237
|
+
queue.append(target)
|
|
238
|
+
events = (
|
|
239
|
+
[asdict(event) for event in report.history] if report.history else _fallback_events(report)
|
|
240
|
+
)
|
|
241
|
+
events = [
|
|
242
|
+
event for event in events if evidence != "confirmed" or event["evidence"] == "confirmed"
|
|
243
|
+
]
|
|
244
|
+
events.sort(key=lambda event: (event.get("sequence") is None, event.get("sequence") or 0))
|
|
245
|
+
frames = {frame.id: frame for frame in report.dataframes}
|
|
246
|
+
|
|
247
|
+
def frame_details(family: set[str]) -> list[dict[str, Any]]:
|
|
248
|
+
details = []
|
|
249
|
+
for identifier in sorted({columns[item].dataframe_id for item in family}):
|
|
250
|
+
bindings = [
|
|
251
|
+
{
|
|
252
|
+
"name": name,
|
|
253
|
+
"scope": event["scope"],
|
|
254
|
+
"context": event["context"],
|
|
255
|
+
"location": event["location"],
|
|
256
|
+
}
|
|
257
|
+
for event in events
|
|
258
|
+
if event["kind"] == "bind"
|
|
259
|
+
and identifier in event["dataframe_ids"]
|
|
260
|
+
and (name := _binding(event))
|
|
261
|
+
]
|
|
262
|
+
details.append(
|
|
263
|
+
{
|
|
264
|
+
**(asdict(frames[identifier]) if identifier in frames else {"id": identifier}),
|
|
265
|
+
"bindings": bindings,
|
|
266
|
+
"column_ids": sorted(
|
|
267
|
+
item for item in family if columns[item].dataframe_id == identifier
|
|
268
|
+
),
|
|
269
|
+
"uses": [
|
|
270
|
+
{
|
|
271
|
+
key: event[key]
|
|
272
|
+
for key in ("kind", "scope", "context", "location", "expression")
|
|
273
|
+
}
|
|
274
|
+
for event in events
|
|
275
|
+
if not event["kind"].startswith("callback_")
|
|
276
|
+
and identifier in event["dataframe_ids"]
|
|
277
|
+
and family.intersection((*event["inputs"], *event["outputs"]))
|
|
278
|
+
],
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
return details
|
|
282
|
+
|
|
283
|
+
def active_names(family: set[str], frame_ids: set[str], at: dict[str, Any]) -> set[str]:
|
|
284
|
+
"""A former rename alias may no longer label a column in this frame."""
|
|
285
|
+
active: dict[str, set[str]] = defaultdict(set)
|
|
286
|
+
for event in events:
|
|
287
|
+
if event["id"] == at["id"]:
|
|
288
|
+
break
|
|
289
|
+
for identifier in event["outputs"]:
|
|
290
|
+
if identifier not in columns:
|
|
291
|
+
continue
|
|
292
|
+
column = columns[identifier]
|
|
293
|
+
if identifier in family:
|
|
294
|
+
active[column.dataframe_id].add(column.name)
|
|
295
|
+
else:
|
|
296
|
+
active[column.dataframe_id].discard(column.name)
|
|
297
|
+
if event["kind"] == "rename":
|
|
298
|
+
outputs = [columns[item] for item in event["outputs"] if item in columns]
|
|
299
|
+
for identifier in event["inputs"]:
|
|
300
|
+
if identifier in family:
|
|
301
|
+
before = columns[identifier]
|
|
302
|
+
if any(
|
|
303
|
+
after.dataframe_id == before.dataframe_id and after.name != before.name
|
|
304
|
+
for after in outputs
|
|
305
|
+
):
|
|
306
|
+
active[before.dataframe_id].discard(before.name)
|
|
307
|
+
return set().union(*(active[identifier] for identifier in frame_ids))
|
|
308
|
+
|
|
309
|
+
def family_steps(family: set[str], *, derivations: bool) -> list[dict[str, Any]]:
|
|
310
|
+
frame_ids = {columns[item].dataframe_id for item in family}
|
|
311
|
+
creators = [event for event in events if family.intersection(event["outputs"])]
|
|
312
|
+
start = min((event.get("sequence") or 0 for event in creators), default=0)
|
|
313
|
+
cutoffs: dict[str, int] = {}
|
|
314
|
+
result = []
|
|
315
|
+
for event in events:
|
|
316
|
+
kind = event["kind"]
|
|
317
|
+
if kind.startswith("callback_"):
|
|
318
|
+
continue
|
|
319
|
+
inputs = family.intersection(event["inputs"])
|
|
320
|
+
outputs = family.intersection(event["outputs"])
|
|
321
|
+
related_frames = frame_ids.intersection(event["dataframe_ids"])
|
|
322
|
+
sequence = event.get("sequence") or 0
|
|
323
|
+
beyond = any(frame in cutoffs and sequence > cutoffs[frame] for frame in related_frames)
|
|
324
|
+
if sequence < start:
|
|
325
|
+
continue
|
|
326
|
+
if kind == "sink" and related_frames:
|
|
327
|
+
if inputs and not beyond:
|
|
328
|
+
result.append(
|
|
329
|
+
_stage(event, "export", "The exported DataFrame contains this column.")
|
|
330
|
+
)
|
|
331
|
+
elif beyond:
|
|
332
|
+
stage = _stage(
|
|
333
|
+
event,
|
|
334
|
+
"export",
|
|
335
|
+
"The same DataFrame is exported after an unresolved operation; "
|
|
336
|
+
"survival of this column is unverified.",
|
|
337
|
+
)
|
|
338
|
+
stage.update(
|
|
339
|
+
continuity="unresolved",
|
|
340
|
+
evidence="unresolved",
|
|
341
|
+
completeness="unresolved",
|
|
342
|
+
inputs=[],
|
|
343
|
+
outputs=[],
|
|
344
|
+
parents=[],
|
|
345
|
+
)
|
|
346
|
+
result.append(stage)
|
|
347
|
+
continue
|
|
348
|
+
if kind in _BOUNDARY_KINDS and related_frames:
|
|
349
|
+
if not beyond:
|
|
350
|
+
result.append(
|
|
351
|
+
_stage(
|
|
352
|
+
event,
|
|
353
|
+
"boundary",
|
|
354
|
+
"An unresolved operation receives or may mutate a containing "
|
|
355
|
+
"DataFrame. Its internal column reads and subsequent survival "
|
|
356
|
+
"are unknown.",
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
for frame in related_frames:
|
|
360
|
+
cutoffs.setdefault(frame, sequence)
|
|
361
|
+
continue
|
|
362
|
+
if beyond:
|
|
363
|
+
continue
|
|
364
|
+
if outputs:
|
|
365
|
+
if kind in _PASS_KINDS:
|
|
366
|
+
result.append(
|
|
367
|
+
_stage(
|
|
368
|
+
event,
|
|
369
|
+
"frame_pass",
|
|
370
|
+
"This pass records preserved column values in the resulting "
|
|
371
|
+
"DataFrame; predicate reads are listed separately.",
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
elif kind in {"external", "parameter"}:
|
|
375
|
+
result.append(
|
|
376
|
+
_stage(
|
|
377
|
+
event,
|
|
378
|
+
"origin",
|
|
379
|
+
"First recorded observation; the definition in the external "
|
|
380
|
+
"source is unknown.",
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
else:
|
|
384
|
+
result.append(
|
|
385
|
+
_stage(
|
|
386
|
+
event,
|
|
387
|
+
"direct_write",
|
|
388
|
+
"Creates, overwrites or explicitly renames this column.",
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
elif kind == "projection" and related_frames:
|
|
392
|
+
labels = _projection_names(event)
|
|
393
|
+
names = active_names(family, related_frames, event)
|
|
394
|
+
if labels is not None and names and not names.intersection(labels):
|
|
395
|
+
result.append(
|
|
396
|
+
_stage(
|
|
397
|
+
event,
|
|
398
|
+
"projection_exclusion",
|
|
399
|
+
"This projection excludes the column from the resulting DataFrame.",
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
elif inputs:
|
|
403
|
+
if kind in _WRITE_KINDS and derivations and event["outputs"]:
|
|
404
|
+
if any(
|
|
405
|
+
item in allowed and find(item) in distances and distances[find(item)] == 1
|
|
406
|
+
for item in event["outputs"]
|
|
407
|
+
):
|
|
408
|
+
result.append(
|
|
409
|
+
_stage(
|
|
410
|
+
event,
|
|
411
|
+
"derivation",
|
|
412
|
+
"Creates a new column directly derived from this column.",
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
elif kind == "apply":
|
|
416
|
+
result.append(
|
|
417
|
+
_stage(
|
|
418
|
+
event,
|
|
419
|
+
"application",
|
|
420
|
+
"Applies the callback directly to this column; internal "
|
|
421
|
+
"preparation and control are available in creation context.",
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
elif kind == "string_transform":
|
|
425
|
+
result.append(
|
|
426
|
+
_stage(
|
|
427
|
+
event,
|
|
428
|
+
"application",
|
|
429
|
+
"Applies this Series string method directly to the column. "
|
|
430
|
+
"A temporary result need not overwrite the stored column.",
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
elif kind == "read":
|
|
434
|
+
result.append(
|
|
435
|
+
_stage(event, "direct_read", "Explicit column access in the source code.")
|
|
436
|
+
)
|
|
437
|
+
elif kind == "filter" and not event["outputs"]:
|
|
438
|
+
result.append(
|
|
439
|
+
_stage(
|
|
440
|
+
event,
|
|
441
|
+
"direct_read",
|
|
442
|
+
"Reads this column to select rows; retained column values "
|
|
443
|
+
"are preserved.",
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
elif kind in {"consume", "call", "return"}:
|
|
447
|
+
if event["label"] not in _BOOKKEEPING:
|
|
448
|
+
explicit_read = any(
|
|
449
|
+
other["kind"] == "read"
|
|
450
|
+
and other["context"] == event["context"]
|
|
451
|
+
and inputs.intersection(other["inputs"])
|
|
452
|
+
and _contains(event["location"], other["location"])
|
|
453
|
+
for other in events
|
|
454
|
+
)
|
|
455
|
+
result.append(
|
|
456
|
+
_stage(
|
|
457
|
+
event,
|
|
458
|
+
"direct_read" if explicit_read else "frame_use",
|
|
459
|
+
"Directly consumes an explicitly accessed column; the call "
|
|
460
|
+
"may observe values or column metadata."
|
|
461
|
+
if explicit_read
|
|
462
|
+
else "Uses or passes a containing DataFrame or propagated "
|
|
463
|
+
"value; no explicit column access is established at this event.",
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
elif related_frames and kind in {"bind", "call", "return"}:
|
|
467
|
+
result.append(
|
|
468
|
+
_stage(
|
|
469
|
+
event, "frame_pass", "Binds or passes a DataFrame containing the column."
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
return _compact(result)
|
|
473
|
+
|
|
474
|
+
def fate(steps: list[dict[str, Any]]) -> dict[str, Any]:
|
|
475
|
+
terminal = [
|
|
476
|
+
step
|
|
477
|
+
for step in steps
|
|
478
|
+
if step["relationship"] in {"export", "boundary", "projection_exclusion"}
|
|
479
|
+
]
|
|
480
|
+
last = terminal[-1] if terminal else None
|
|
481
|
+
if last is not None and last["relationship"] == "export":
|
|
482
|
+
status = "unverified_export" if last.get("continuity") == "unresolved" else "exported"
|
|
483
|
+
label = (
|
|
484
|
+
"Exported column survival is unverified"
|
|
485
|
+
if status == "unverified_export"
|
|
486
|
+
else "Included in a recorded DataFrame export"
|
|
487
|
+
)
|
|
488
|
+
elif last is not None and last["relationship"] == "projection_exclusion":
|
|
489
|
+
status, label = (
|
|
490
|
+
"excluded",
|
|
491
|
+
"Excluded from the resulting DataFrame by an explicit projection",
|
|
492
|
+
)
|
|
493
|
+
elif last is not None:
|
|
494
|
+
status, label = (
|
|
495
|
+
"boundary",
|
|
496
|
+
"Tracking reaches an unresolved operation",
|
|
497
|
+
)
|
|
498
|
+
elif steps:
|
|
499
|
+
last, status, label = (
|
|
500
|
+
steps[-1],
|
|
501
|
+
"last_observed",
|
|
502
|
+
"Last recorded use; no later destination was established",
|
|
503
|
+
)
|
|
504
|
+
else:
|
|
505
|
+
return {
|
|
506
|
+
"status": "last_observed",
|
|
507
|
+
"label": "No eligible recorded use under these query filters",
|
|
508
|
+
"location": asdict(selected.location),
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
"status": status,
|
|
512
|
+
"label": label,
|
|
513
|
+
"location": last["location"],
|
|
514
|
+
"event_id": last["id"],
|
|
515
|
+
"expression": last["expression"],
|
|
516
|
+
"evidence": last["evidence"],
|
|
517
|
+
"dataframe_ids": last["dataframe_ids"],
|
|
518
|
+
"scope": last["scope"],
|
|
519
|
+
"context": last["context"],
|
|
520
|
+
"scope_note": "Outcome of this recorded DataFrame branch; earlier "
|
|
521
|
+
"exports remain listed in destinations.",
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
steps = family_steps(members, derivations=True)
|
|
525
|
+
derived = []
|
|
526
|
+
for family_id, distance in sorted(distances.items(), key=lambda item: (item[1], item[0])):
|
|
527
|
+
if family_id == selected_family:
|
|
528
|
+
continue
|
|
529
|
+
family = families[family_id]
|
|
530
|
+
definitions = [
|
|
531
|
+
event
|
|
532
|
+
for event in events
|
|
533
|
+
if family.intersection(event["outputs"]) and event["kind"] not in _PASS_KINDS
|
|
534
|
+
]
|
|
535
|
+
representative: Column = columns[min(family)]
|
|
536
|
+
if definitions:
|
|
537
|
+
representative = columns[
|
|
538
|
+
next(item for item in definitions[0]["outputs"] if item in family)
|
|
539
|
+
]
|
|
540
|
+
uses = family_steps(family, derivations=False)
|
|
541
|
+
derived.append(
|
|
542
|
+
{
|
|
543
|
+
"id": representative.id,
|
|
544
|
+
"name": representative.name,
|
|
545
|
+
"column_ids": sorted(family),
|
|
546
|
+
"distance": distance,
|
|
547
|
+
"relationship": "direct" if distance == 1 else "transitive",
|
|
548
|
+
"definition": definitions[0] if definitions else None,
|
|
549
|
+
"upstream": [
|
|
550
|
+
asdict(columns[item])
|
|
551
|
+
for item in (definitions[0]["inputs"] if definitions else ())
|
|
552
|
+
if item in columns
|
|
553
|
+
],
|
|
554
|
+
"fate": fate(uses),
|
|
555
|
+
"dataframes": frame_details(family),
|
|
556
|
+
"steps": uses,
|
|
557
|
+
}
|
|
558
|
+
)
|
|
559
|
+
return {
|
|
560
|
+
"selected_column_id": selected_id,
|
|
561
|
+
"selected_name": selected.name,
|
|
562
|
+
"column_ids": sorted(members),
|
|
563
|
+
"aliases": sorted({columns[item].name for item in members}),
|
|
564
|
+
"steps": steps,
|
|
565
|
+
"derived_columns": derived,
|
|
566
|
+
"dataframes": frame_details(members),
|
|
567
|
+
"destinations": [step for step in steps if step["relationship"] == "export"],
|
|
568
|
+
"boundaries": [step for step in steps if step["relationship"] == "boundary"],
|
|
569
|
+
"fate": fate(steps),
|
|
570
|
+
"counts": {
|
|
571
|
+
"direct_reads": sum(step["relationship"] == "direct_read" for step in steps),
|
|
572
|
+
"applications": sum(step["relationship"] == "application" for step in steps),
|
|
573
|
+
"writes": sum(step["relationship"] == "direct_write" for step in steps),
|
|
574
|
+
"derived_columns": len(derived),
|
|
575
|
+
"dataframes": len(frame_details(members)),
|
|
576
|
+
},
|
|
577
|
+
"recorded": bool(report.history),
|
|
578
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Public, JSON-serializable lineage facts. IDs are opaque and scan-local."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from .models import Diagnostic, FileInfo, FunctionInfo, Location
|
|
10
|
+
|
|
11
|
+
Confidence = Literal["confirmed", "inferred", "unresolved"]
|
|
12
|
+
Completeness = Literal["complete", "partial", "unresolved"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Frame:
|
|
17
|
+
id: str
|
|
18
|
+
origin: str
|
|
19
|
+
location: Location
|
|
20
|
+
module: str
|
|
21
|
+
scope: str
|
|
22
|
+
context: str
|
|
23
|
+
evidence: Confidence = "confirmed"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Column:
|
|
28
|
+
id: str
|
|
29
|
+
dataframe_id: str
|
|
30
|
+
name: str
|
|
31
|
+
version: int
|
|
32
|
+
location: Location
|
|
33
|
+
module: str
|
|
34
|
+
scope: str
|
|
35
|
+
context: str
|
|
36
|
+
origin: str = "write"
|
|
37
|
+
evidence: Confidence = "confirmed"
|
|
38
|
+
completeness: Completeness = "complete"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Operation:
|
|
43
|
+
id: str
|
|
44
|
+
kind: str
|
|
45
|
+
location: Location
|
|
46
|
+
module: str
|
|
47
|
+
scope: str
|
|
48
|
+
context: str
|
|
49
|
+
evidence: Confidence = "confirmed"
|
|
50
|
+
completeness: Completeness = "complete"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Edge:
|
|
55
|
+
source: str
|
|
56
|
+
target: str
|
|
57
|
+
kind: Literal["read", "write"]
|
|
58
|
+
evidence: Confidence = "confirmed"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Consumption:
|
|
63
|
+
column_id: str
|
|
64
|
+
location: Location
|
|
65
|
+
module: str
|
|
66
|
+
scope: str
|
|
67
|
+
context: str
|
|
68
|
+
kind: str = "read"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class HistoryEvent:
|
|
73
|
+
"""A recorded analysis step; ordering describes analysis, not a runtime log."""
|
|
74
|
+
|
|
75
|
+
id: str
|
|
76
|
+
sequence: int
|
|
77
|
+
kind: str
|
|
78
|
+
label: str
|
|
79
|
+
location: Location
|
|
80
|
+
module: str
|
|
81
|
+
scope: str
|
|
82
|
+
context: str
|
|
83
|
+
dataframe_ids: tuple[str, ...] = ()
|
|
84
|
+
inputs: tuple[str, ...] = ()
|
|
85
|
+
outputs: tuple[str, ...] = ()
|
|
86
|
+
expression: str = ""
|
|
87
|
+
evidence: Confidence = "confirmed"
|
|
88
|
+
completeness: Completeness = "complete"
|
|
89
|
+
parents: tuple[str, ...] = ()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class LineageReport:
|
|
94
|
+
files: tuple[FileInfo, ...] = ()
|
|
95
|
+
functions: tuple[FunctionInfo, ...] = ()
|
|
96
|
+
dataframes: tuple[Frame, ...] = ()
|
|
97
|
+
columns: tuple[Column, ...] = ()
|
|
98
|
+
operations: tuple[Operation, ...] = ()
|
|
99
|
+
edges: tuple[Edge, ...] = ()
|
|
100
|
+
consumptions: tuple[Consumption, ...] = ()
|
|
101
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
102
|
+
exclude: tuple[str, ...] = ()
|
|
103
|
+
history: tuple[HistoryEvent, ...] = ()
|
|
104
|
+
schema_version: str = "0.3"
|
|
105
|
+
report_kind: str = "column_lineage"
|
|
106
|
+
tool_version: str = "0.1.0.dev4"
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict[str, Any]:
|
|
109
|
+
return asdict(self)
|
|
110
|
+
|
|
111
|
+
def to_json(self, *, indent: int | None = 2) -> str:
|
|
112
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, sort_keys=True, indent=indent)
|