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/cli.py ADDED
@@ -0,0 +1,668 @@
1
+ """Command-line presentation for static column lineage reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import webbrowser
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from enum import StrEnum
9
+ from pathlib import Path
10
+ from typing import Annotated, Any, Literal
11
+
12
+ import typer
13
+
14
+ from .exporters import export_html, export_json, load_report
15
+ from .graph import AmbiguousColumnError, ColumnNotFoundError, LineageGraph
16
+ from .lineage_models import Column, LineageReport
17
+ from .scanner import ScanConfig
18
+
19
+ app = typer.Typer(
20
+ name="coltrace",
21
+ help="Explore static Pandas column lineage and potential change impact.",
22
+ no_args_is_help=True,
23
+ pretty_exceptions_enable=False,
24
+ )
25
+
26
+
27
+ class ExportFormat(StrEnum):
28
+ json = "json"
29
+ html = "html"
30
+
31
+
32
+ class EvidenceFilter(StrEnum):
33
+ all = "all"
34
+ confirmed = "confirmed"
35
+
36
+
37
+ class RelationFilter(StrEnum):
38
+ derive = "derive"
39
+ rename = "rename"
40
+ passthrough = "pass"
41
+
42
+
43
+ class GraphView(StrEnum):
44
+ history = "history"
45
+ columns = "columns"
46
+
47
+
48
+ SnapshotOption = Annotated[
49
+ Path | None,
50
+ typer.Option("--from", help="Snapshot JSON; default: .coltrace/scan.json."),
51
+ ]
52
+ ProjectOption = Annotated[
53
+ Path | None,
54
+ typer.Option("--project", help="Analyze this project now instead of reading a snapshot."),
55
+ ]
56
+ ModuleOption = Annotated[str | None, typer.Option(help="Select an exact module name.")]
57
+ FunctionOption = Annotated[str | None, typer.Option(help="Select a function or qualified scope.")]
58
+ DataFrameOption = Annotated[str | None, typer.Option(help="Select an exact DataFrame identity.")]
59
+ ColumnIDOption = Annotated[
60
+ str | None, typer.Option("--column-id", help="Select an exact column version by its ID.")
61
+ ]
62
+ VersionOption = Annotated[int | None, typer.Option(min=0, help="Select a column version.")]
63
+ DepthOption = Annotated[
64
+ int | None, typer.Option(min=0, help="Maximum dependency distance in column-to-column hops.")
65
+ ]
66
+ RelationOption = Annotated[RelationFilter | None, typer.Option(help="Filter dependency relations.")]
67
+ EvidenceOption = Annotated[EvidenceFilter, typer.Option(help="Filter dependency evidence.")]
68
+ DiagnosticsOption = Annotated[bool, typer.Option(help="Show all diagnostics, including hints.")]
69
+ ColumnArgument = Annotated[
70
+ str | None, typer.Argument(help="Column name; use --column-id to select an exact version.")
71
+ ]
72
+
73
+
74
+ @contextmanager
75
+ def _errors() -> Iterator[None]:
76
+ """Translate expected data/input errors without hiding programming failures."""
77
+ try:
78
+ yield
79
+ except AmbiguousColumnError as exc:
80
+ typer.echo("Ambiguous column. Select one with --column-id:", err=True)
81
+ for candidate in exc.candidates:
82
+ typer.echo(
83
+ f" {candidate.id} {candidate.name}@{candidate.version} "
84
+ f"{candidate.location.path}:{candidate.location.line} "
85
+ f"{candidate.module}::{candidate.scope} "
86
+ f"dataframe={candidate.dataframe_id} context={candidate.context} "
87
+ f"origin={candidate.origin} evidence={candidate.evidence}",
88
+ err=True,
89
+ )
90
+ raise typer.Exit(3) from exc
91
+ except ColumnNotFoundError as exc:
92
+ typer.echo(f"Column not found: {exc}", err=True)
93
+ raise typer.Exit(1) from exc
94
+ except (OSError, ValueError) as exc:
95
+ typer.echo(f"Error: {exc}", err=True)
96
+ raise typer.Exit(2) from exc
97
+
98
+
99
+ def _report(snapshot: Path | None, project: Path | None) -> LineageReport:
100
+ if snapshot is not None and project is not None:
101
+ raise ValueError("Use either --from or --project, not both.")
102
+ if project is not None:
103
+ from .analysis import analyze_project
104
+
105
+ return analyze_project(project)
106
+ path = snapshot if snapshot is not None else Path(".coltrace/scan.json")
107
+ if not path.is_file():
108
+ raise ValueError(
109
+ f"Snapshot not found: {path}. Run 'coltrace scan PROJECT' first, "
110
+ "or use --from FILE or --project DIRECTORY."
111
+ )
112
+ report = load_report(path)
113
+ from . import __version__
114
+
115
+ if report.tool_version != __version__:
116
+ typer.echo(
117
+ f"Snapshot uses ColTrace {report.tool_version}; current tool is {__version__}. "
118
+ "Re-scan the project to include newly supported lineage. Existing facts are retained.",
119
+ err=True,
120
+ )
121
+ return report
122
+
123
+
124
+ def _selection(
125
+ graph: LineageGraph,
126
+ column: str | None,
127
+ column_id: str | None,
128
+ module: str | None,
129
+ function: str | None,
130
+ dataframe: str | None,
131
+ version: int | None,
132
+ *,
133
+ required: bool,
134
+ ) -> Column | None:
135
+ if column is not None and column_id is not None:
136
+ raise ValueError("Use either COLUMN or --column-id, not both.")
137
+ selector = column_id if column_id is not None else column
138
+ if selector is None:
139
+ if required:
140
+ raise ValueError("Provide a column name or --column-id ID.")
141
+ if any(value is not None for value in (module, function, dataframe, version)):
142
+ raise ValueError("Selection filters require COLUMN or --column-id ID.")
143
+ return None
144
+ return graph.select(
145
+ selector, module=module, function=function, dataframe=dataframe, version=version
146
+ )
147
+
148
+
149
+ def _diagnostics(report: LineageReport, *, detailed: bool) -> None:
150
+ errors = sum(item.severity == "error" for item in report.diagnostics)
151
+ warnings = len(report.diagnostics) - errors
152
+ typer.echo(f"Diagnostics: {errors} error(s), {warnings} warning(s).")
153
+ if detailed:
154
+ for diagnostic in report.diagnostics:
155
+ typer.echo(
156
+ f" {diagnostic.code} {diagnostic.location.path}:{diagnostic.location.line} "
157
+ f"[{diagnostic.scope}] {diagnostic.message}"
158
+ )
159
+ if diagnostic.hint:
160
+ typer.echo(f" Hint: {diagnostic.hint}")
161
+ elif report.diagnostics:
162
+ typer.echo("Use --diagnostics for details; unsupported code may leave lineage gaps.")
163
+
164
+
165
+ def _display_columns(
166
+ label: str, columns: list[dict[str, Any]], *, inconclusive: bool = False
167
+ ) -> None:
168
+ typer.echo(f"{label}:")
169
+ if not columns:
170
+ if inconclusive:
171
+ typer.echo(" Not determined: analysis is incomplete.")
172
+ else:
173
+ typer.echo(" None identified within the analyzed subset.")
174
+ for column in columns:
175
+ location = column["location"]
176
+ typer.echo(
177
+ f" {column['name']}@{column['version']} "
178
+ f"{location['path']}:{location['line']} "
179
+ f"{column['module']}::{column['scope']} "
180
+ f"distance={column.get('distance', '?')} "
181
+ f"evidence={column.get('path_evidence', column['evidence'])} "
182
+ f"completeness={column.get('path_completeness', column['completeness'])}"
183
+ )
184
+ typer.echo(f" ID: {column['id']}")
185
+
186
+
187
+ def _display_history(query: dict[str, Any]) -> bool:
188
+ """Present recorded analysis events as a readable source-to-use narrative."""
189
+ history = query.get("history", {})
190
+ if not history.get("recorded"):
191
+ typer.echo(
192
+ "Recorded history unavailable in this snapshot. Re-scan the project to include "
193
+ "source expressions, transformation steps, and uses."
194
+ )
195
+ return False
196
+ typer.echo("Column history (static analysis order):")
197
+ names = {
198
+ node["id"]: f"{node['name']}@{node['version']}"
199
+ for node in history.get("nodes", [])
200
+ if node.get("node_type") == "column"
201
+ }
202
+ stages = history.get("stages", [])
203
+ for index, event in enumerate(stages, 1):
204
+ location = event["location"]
205
+ category = event.get("category", "transform")
206
+ typer.echo(
207
+ f" {index:02d}. [{category}] {event['label']}\n"
208
+ f" {location['path']}:{location['line']} in "
209
+ f"{event['module']}::{event['scope']}"
210
+ )
211
+ if event.get("expression") and (
212
+ event["kind"] != "callback_step"
213
+ or event["label"].startswith("Return")
214
+ or "guard" in event["label"]
215
+ ):
216
+ expression = event["expression"].strip()
217
+ if expression in event["label"]:
218
+ expression = ""
219
+ for line in expression.splitlines():
220
+ typer.echo(f" {line}")
221
+ inputs = [names.get(key, key) for key in event.get("inputs", [])]
222
+ outputs = [names.get(key, key) for key in event.get("outputs", [])]
223
+ if (
224
+ event.get("event_ids")
225
+ and len(event["event_ids"]) > 1
226
+ and event["kind"] in {"filter", "dropna", "projection"}
227
+ ):
228
+ labels = list(dict.fromkeys(name.rsplit("@", 1)[0] for name in outputs))
229
+ if labels:
230
+ typer.echo(f" Retained columns: {', '.join(labels)}")
231
+ elif outputs or (inputs and event["category"] in {"use", "boundary"}):
232
+ typer.echo(
233
+ f" Columns: {', '.join(inputs) or '(source)'} -> "
234
+ f"{', '.join(outputs) or '(use)'}"
235
+ )
236
+ if event.get("evidence") == "unresolved":
237
+ typer.echo(
238
+ f" Evidence: {event.get('evidence', 'inferred')}; "
239
+ f"completeness: {event.get('completeness', 'partial')}."
240
+ )
241
+ if not stages:
242
+ typer.echo(" No relevant events recorded within the selected filters.")
243
+ typer.echo(
244
+ "Stages can branch; graph arrows show causality. Row filters preserve values "
245
+ "while changing which rows continue."
246
+ )
247
+ return True
248
+
249
+
250
+ def _display_journey(query: dict[str, Any]) -> bool:
251
+ """Keep direct column actions separate from creation ancestry and frame use."""
252
+ journey = query.get("journey", {})
253
+ if not journey.get("recorded"):
254
+ typer.echo(
255
+ "Recorded column journey unavailable in this snapshot. Re-scan the project "
256
+ "to include direct actions and destinations."
257
+ )
258
+ return False
259
+ for source in query.get("history", {}).get("sources", []):
260
+ if source["kind"] == "source" and source.get("expression"):
261
+ place = source["location"]
262
+ typer.echo(f"DataFrame source context: {place['path']}:{place['line']}")
263
+ for line in source["expression"].strip().splitlines():
264
+ typer.echo(f" {line}")
265
+ for label, group in (("Direct value inputs", "direct"), ("Earlier value inputs", "transitive")):
266
+ ancestors = query["upstream"][group]
267
+ if ancestors:
268
+ typer.echo(f"{label}:")
269
+ for ancestor in ancestors:
270
+ place = ancestor["location"]
271
+ typer.echo(
272
+ f" {ancestor['name']}@{ancestor['version']} "
273
+ f"{place['path']}:{place['line']} {ancestor['module']}::{ancestor['scope']}"
274
+ )
275
+ typer.echo("Column journey (direct uses and outcomes):")
276
+ for index, step in enumerate(journey.get("steps", []), 1):
277
+ place = step["location"]
278
+ typer.echo(
279
+ f" {index:02d}. [{step['relationship']}] {step['label']}\n"
280
+ f" {place['path']}:{place['line']} in {step['module']}::{step['scope']}"
281
+ )
282
+ typer.echo(f" {step['explanation']}")
283
+ expression = step.get("expression", "").strip()
284
+ if expression and expression not in step["label"]:
285
+ for line in expression.splitlines():
286
+ typer.echo(f" {line}")
287
+ if step.get("evidence") == "unresolved":
288
+ typer.echo(" Evidence: unresolved; completeness: unresolved.")
289
+ if not journey.get("steps"):
290
+ typer.echo(" No eligible recorded action under these query filters.")
291
+ typer.echo("Derived columns and their outcomes:")
292
+ derived = journey.get("derived_columns", [])
293
+ if not derived:
294
+ typer.echo(" None identified within the analyzed subset and query filters.")
295
+ for item in derived:
296
+ fate = item["fate"]
297
+ place = fate["location"]
298
+ typer.echo(
299
+ f" {item['name']} ({item['relationship']}, distance={item['distance']})\n"
300
+ f" {fate['label']} at {place['path']}:{place['line']}"
301
+ )
302
+ if item.get("definition"):
303
+ definition = item["definition"]
304
+ place = definition["location"]
305
+ typer.echo(f" Defined: {place['path']}:{place['line']} in {definition['scope']}")
306
+ if item.get("upstream"):
307
+ inputs = ", ".join(f"{value['name']}@{value['version']}" for value in item["upstream"])
308
+ typer.echo(f" Value inputs: {inputs}")
309
+ for frame in item.get("dataframes", []):
310
+ bindings = ", ".join(
311
+ dict.fromkeys(
312
+ f"{binding['name']} in {binding['scope']}" for binding in frame["bindings"]
313
+ )
314
+ )
315
+ typer.echo(
316
+ f" DataFrame: {bindings or frame.get('origin', 'unknown')} [{frame['id']}]"
317
+ )
318
+ typer.echo("DataFrames carrying the selected column:")
319
+ for frame in journey.get("dataframes", []):
320
+ bindings = frame.get("bindings", [])
321
+ names = ", ".join(
322
+ dict.fromkeys(f"{binding['name']} in {binding['scope']}" for binding in bindings)
323
+ )
324
+ typer.echo(f" {names or frame.get('origin', 'unknown')} [{frame['id']}]")
325
+ place = frame.get("location")
326
+ if place:
327
+ typer.echo(f" {frame.get('origin', 'unknown')} at {place['path']}:{place['line']}")
328
+ fate = journey.get("fate", {})
329
+ if fate:
330
+ place = fate["location"]
331
+ typer.echo(f"Observed outcome: {fate['label']} at {place['path']}:{place['line']}.")
332
+ typer.echo(
333
+ "Frame passage does not prove an internal column read. No later observed use "
334
+ "does not prove the column is unused."
335
+ )
336
+ typer.echo("Use --full-history for creation details, including callback control/configuration.")
337
+ return True
338
+
339
+
340
+ def _display_query(
341
+ query: dict[str, Any],
342
+ report: LineageReport,
343
+ *,
344
+ impact: bool = False,
345
+ detailed: bool = False,
346
+ full_history: bool = False,
347
+ ) -> None:
348
+ column = query["column"]
349
+ location = column["location"]
350
+ typer.echo(f"Column: {column['name']}@{column['version']}")
351
+ typer.echo(f"ID: {column['id']}")
352
+ typer.echo(f"DataFrame: {column['dataframe_id']}")
353
+ origin = "First observed" if column["origin"] in {"external_unknown", "opaque"} else "Defined"
354
+ typer.echo(
355
+ f"{origin}: {location['path']}:{location['line']} in {column['module']}::{column['scope']}"
356
+ )
357
+ typer.echo(f"Evidence: {column['evidence']}; completeness: {column['completeness']}.")
358
+ frame = next((item for item in report.dataframes if item.id == column["dataframe_id"]), None)
359
+ if frame is not None:
360
+ typer.echo(
361
+ f"DataFrame origin: {frame.origin} at {frame.location.path}:{frame.location.line} "
362
+ f"in {frame.module}::{frame.scope} (evidence={frame.evidence})."
363
+ )
364
+ if column["context"].startswith("template:"):
365
+ typer.echo("Analysis context: isolated function template; no concrete caller resolved.")
366
+ inconclusive = (
367
+ column["origin"] == "opaque"
368
+ or column["evidence"] == "unresolved"
369
+ or column["completeness"] == "unresolved"
370
+ )
371
+ if inconclusive:
372
+ typer.echo(
373
+ "Trace status: inconclusive; the column crosses an unresolved analysis boundary."
374
+ )
375
+ typer.echo("Missing upstream/downstream results do not establish absence of dependencies.")
376
+ elif column["completeness"] != "complete" or query["diagnostics"]:
377
+ typer.echo("Trace status: partial; only identified dependencies are shown.")
378
+ if impact:
379
+ typer.echo("Potential impact from identified dependencies; behavior may remain unchanged.")
380
+ recorded = False
381
+ if not impact:
382
+ recorded = _display_history(query) if full_history else _display_journey(query)
383
+ if recorded and not detailed and not inconclusive:
384
+ boundaries = query["history"].get("boundaries", [])
385
+ if boundaries:
386
+ typer.echo(
387
+ "The history reaches an unresolved boundary. Later internal transformations "
388
+ "cannot be inferred from that call."
389
+ )
390
+ typer.echo("Use --diagnostics for dependency details and diagnostic messages.")
391
+ return
392
+ for direction in ("upstream", "downstream"):
393
+ if query["direction"] not in ("both", direction):
394
+ continue
395
+ relationships = query[direction]
396
+ _display_columns(
397
+ f"{direction.capitalize()} - direct",
398
+ relationships["direct"],
399
+ inconclusive=inconclusive,
400
+ )
401
+ _display_columns(
402
+ f"{direction.capitalize()} - transitive",
403
+ relationships["transitive"],
404
+ inconclusive=inconclusive,
405
+ )
406
+ typer.echo("Read/consumption locations:")
407
+ if not query["consumptions"]:
408
+ typer.echo(" None identified.")
409
+ consumed_columns = {column["id"]: f"{column['name']}@{column['version']}"}
410
+ for side in ("upstream", "downstream"):
411
+ for group in ("direct", "transitive"):
412
+ for item in query[side][group]:
413
+ consumed_columns[item["id"]] = f"{item['name']}@{item['version']}"
414
+ for consumption in query["consumptions"]:
415
+ place = consumption["location"]
416
+ label = consumed_columns.get(consumption["column_id"], consumption["column_id"])
417
+ typer.echo(
418
+ f" {place['path']}:{place['line']} "
419
+ f"{consumption['module']}::{consumption['scope']} ({consumption['kind']}: {label})"
420
+ )
421
+ if impact:
422
+ for key, label in (
423
+ ("direct_functions", "Functions/scopes with direct potential impact"),
424
+ ("transitive_functions", "Functions/scopes with transitive potential impact"),
425
+ ):
426
+ typer.echo(f"{label}:")
427
+ functions = query.get(key, [])
428
+ if not functions:
429
+ typer.echo(" None identified.")
430
+ for function in functions:
431
+ typer.echo(f" {function['module']}::{function['scope']}")
432
+ elif query["functions"]:
433
+ label = "identified dependencies" if query["relations"] else "observed column uses"
434
+ typer.echo(f"Functions/scopes with {label}:")
435
+ for function in query["functions"]:
436
+ typer.echo(f" {function['module']}::{function['scope']}")
437
+ if query["diagnostics"]:
438
+ typer.echo("Lineage gaps and related diagnostics:")
439
+ for diagnostic in query["diagnostics"]:
440
+ place = diagnostic["location"]
441
+ typer.echo(
442
+ f" {diagnostic['code']} {place['path']}:{place['line']} {diagnostic['message']}"
443
+ )
444
+
445
+
446
+ @app.callback(invoke_without_command=True)
447
+ def main(
448
+ version: Annotated[
449
+ bool, typer.Option("--version", is_eager=True, help="Show the installed package version.")
450
+ ] = False,
451
+ ) -> None:
452
+ if version:
453
+ from . import __version__
454
+
455
+ typer.echo(f"ColTrace {__version__}")
456
+ raise typer.Exit()
457
+
458
+
459
+ @app.command()
460
+ def scan(
461
+ path: Annotated[Path, typer.Argument(help="Python project directory.")] = Path("."),
462
+ out: Annotated[Path, typer.Option(help="Directory for the scan.json snapshot.")] = Path(
463
+ ".coltrace"
464
+ ),
465
+ exclude: Annotated[
466
+ list[str] | None,
467
+ typer.Option(help="Exclude a path/glob; repeat to override project exclusions."),
468
+ ] = None,
469
+ diagnostics: DiagnosticsOption = False,
470
+ ) -> None:
471
+ """Analyze a project without executing it and save a reusable snapshot."""
472
+ from .analysis import analyze_project
473
+
474
+ with _errors():
475
+ config = ScanConfig(exclude=tuple(exclude)) if exclude is not None else None
476
+ report = analyze_project(path, config=config)
477
+ destination = export_json(report, out / "scan.json")
478
+ typer.echo(
479
+ f"Analyzed {len(report.files)} file(s): {len(report.dataframes)} DataFrame(s), "
480
+ f"{len(report.columns)} column version(s), {len(report.operations)} operation(s)."
481
+ )
482
+ typer.echo(f"Snapshot: {destination.resolve()}")
483
+ _diagnostics(report, detailed=diagnostics)
484
+ if any(item.severity == "error" for item in report.diagnostics):
485
+ raise typer.Exit(1)
486
+
487
+
488
+ @app.command()
489
+ def trace(
490
+ column: ColumnArgument = None,
491
+ snapshot: SnapshotOption = None,
492
+ project: ProjectOption = None,
493
+ module: ModuleOption = None,
494
+ function: FunctionOption = None,
495
+ dataframe: DataFrameOption = None,
496
+ column_id: ColumnIDOption = None,
497
+ version: VersionOption = None,
498
+ depth: DepthOption = None,
499
+ relation: RelationOption = None,
500
+ evidence: EvidenceOption = EvidenceFilter.all,
501
+ upstream: Annotated[bool, typer.Option(help="Show only upstream dependencies.")] = False,
502
+ downstream: Annotated[bool, typer.Option(help="Show only downstream dependencies.")] = False,
503
+ full_history: Annotated[
504
+ bool, typer.Option(help="Include creation ancestry and callback control/configuration.")
505
+ ] = False,
506
+ diagnostics: DiagnosticsOption = False,
507
+ ) -> None:
508
+ """Show definitions, dependencies, consumption sites, and unresolved gaps."""
509
+ with _errors():
510
+ if upstream and downstream:
511
+ raise ValueError(
512
+ "Use either --upstream or --downstream; omit both for both directions."
513
+ )
514
+ report = _report(snapshot, project)
515
+ graph = LineageGraph(report)
516
+ selected = _selection(
517
+ graph, column, column_id, module, function, dataframe, version, required=True
518
+ )
519
+ assert selected is not None
520
+ direction: Literal["both", "upstream", "downstream"] = "both"
521
+ if upstream:
522
+ direction = "upstream"
523
+ elif downstream:
524
+ direction = "downstream"
525
+ query = graph.trace(
526
+ selected.id,
527
+ direction=direction,
528
+ depth=depth,
529
+ relation=relation.value if relation else None,
530
+ evidence=evidence.value,
531
+ )
532
+ _display_query(query, report, detailed=diagnostics, full_history=full_history)
533
+
534
+
535
+ @app.command()
536
+ def impact(
537
+ column: ColumnArgument = None,
538
+ snapshot: SnapshotOption = None,
539
+ project: ProjectOption = None,
540
+ module: ModuleOption = None,
541
+ function: FunctionOption = None,
542
+ dataframe: DataFrameOption = None,
543
+ column_id: ColumnIDOption = None,
544
+ version: VersionOption = None,
545
+ depth: DepthOption = None,
546
+ relation: RelationOption = None,
547
+ evidence: EvidenceOption = EvidenceFilter.all,
548
+ diagnostics: DiagnosticsOption = False,
549
+ ) -> None:
550
+ """Show direct and transitive potential impacts of changing a column."""
551
+ with _errors():
552
+ report = _report(snapshot, project)
553
+ graph = LineageGraph(report)
554
+ selected = _selection(
555
+ graph, column, column_id, module, function, dataframe, version, required=True
556
+ )
557
+ assert selected is not None
558
+ query = graph.impact(
559
+ selected.id,
560
+ depth=depth,
561
+ relation=relation.value if relation else None,
562
+ evidence=evidence.value,
563
+ )
564
+ _display_query(query, report, impact=True, detailed=diagnostics)
565
+
566
+
567
+ @app.command()
568
+ def graph(
569
+ column: ColumnArgument = None,
570
+ snapshot: SnapshotOption = None,
571
+ project: ProjectOption = None,
572
+ module: ModuleOption = None,
573
+ function: FunctionOption = None,
574
+ dataframe: DataFrameOption = None,
575
+ column_id: ColumnIDOption = None,
576
+ version: VersionOption = None,
577
+ depth: DepthOption = None,
578
+ relation: RelationOption = None,
579
+ evidence: EvidenceOption = EvidenceFilter.all,
580
+ output: Annotated[Path, typer.Option(help="Destination HTML file.")] = Path(
581
+ ".coltrace/graph.html"
582
+ ),
583
+ operations: Annotated[bool, typer.Option(help="Include operation nodes.")] = False,
584
+ view: Annotated[
585
+ GraphView, typer.Option(help="Selected column history or column dependency projection.")
586
+ ] = GraphView.history,
587
+ open_browser: Annotated[bool, typer.Option("--open", help="Open the local HTML file.")] = False,
588
+ diagnostics: DiagnosticsOption = False,
589
+ ) -> None:
590
+ """Generate an interactive, offline HTML graph (requires coltrace[viz])."""
591
+ with _errors():
592
+ report = _report(snapshot, project)
593
+ selected = _selection(
594
+ LineageGraph(report),
595
+ column,
596
+ column_id,
597
+ module,
598
+ function,
599
+ dataframe,
600
+ version,
601
+ required=False,
602
+ )
603
+ if depth is not None and selected is None:
604
+ raise ValueError("--depth requires COLUMN or --column-id ID.")
605
+ destination = export_html(
606
+ report,
607
+ output,
608
+ column_id=selected.id if selected else None,
609
+ depth=depth,
610
+ relation=relation.value if relation else None,
611
+ evidence=evidence.value,
612
+ operations=operations,
613
+ view=view.value,
614
+ )
615
+ typer.echo(f"Graph: {destination.resolve()}")
616
+ _diagnostics(report, detailed=diagnostics)
617
+ if open_browser and not webbrowser.open(destination.resolve().as_uri()):
618
+ typer.echo("Could not open a browser automatically. Open the HTML file above.")
619
+
620
+
621
+ @app.command(name="export")
622
+ def export_command(
623
+ column: ColumnArgument = None,
624
+ snapshot: SnapshotOption = None,
625
+ project: ProjectOption = None,
626
+ module: ModuleOption = None,
627
+ function: FunctionOption = None,
628
+ dataframe: DataFrameOption = None,
629
+ column_id: ColumnIDOption = None,
630
+ version: VersionOption = None,
631
+ depth: DepthOption = None,
632
+ relation: RelationOption = None,
633
+ evidence: EvidenceOption = EvidenceFilter.all,
634
+ format: Annotated[ExportFormat, typer.Option(help="Output format.")] = ExportFormat.json,
635
+ output: Annotated[
636
+ Path | None, typer.Option(help="Output file; defaults inside .coltrace.")
637
+ ] = None,
638
+ diagnostics: DiagnosticsOption = False,
639
+ ) -> None:
640
+ """Export a full snapshot or selected column neighborhood as JSON or HTML."""
641
+ with _errors():
642
+ report = _report(snapshot, project)
643
+ selected = _selection(
644
+ LineageGraph(report),
645
+ column,
646
+ column_id,
647
+ module,
648
+ function,
649
+ dataframe,
650
+ version,
651
+ required=False,
652
+ )
653
+ if depth is not None and selected is None:
654
+ raise ValueError("--depth requires COLUMN or --column-id ID.")
655
+ destination = output or Path(f".coltrace/export.{format.value}")
656
+ exporter = export_json if format is ExportFormat.json else export_html
657
+ destination = exporter(
658
+ report,
659
+ destination,
660
+ column_id=selected.id if selected else None,
661
+ depth=depth,
662
+ relation=relation.value if relation else None,
663
+ evidence=evidence.value,
664
+ )
665
+ typer.echo(f"Exported: {destination.resolve()}")
666
+ if format is ExportFormat.json and selected is not None:
667
+ typer.echo("This is a query export; use 'coltrace scan' to create a reusable snapshot.")
668
+ _diagnostics(report, detailed=diagnostics)