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