sql-glider 0.1.23__py3-none-any.whl → 0.1.25__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-glider
3
- Version: 0.1.23
3
+ Version: 0.1.25
4
4
  Summary: SQL Utility Toolkit for better understanding, use, and governance of your queries in a native environment.
5
5
  Project-URL: Homepage, https://github.com/rycowhi/sql-glider/
6
6
  Project-URL: Repository, https://github.com/rycowhi/sql-glider/
@@ -28,6 +28,8 @@ Requires-Dist: sqlglot[rs]>=25.0.0
28
28
  Requires-Dist: typer>=0.9.0
29
29
  Provides-Extra: databricks
30
30
  Requires-Dist: databricks-sdk>=0.20.0; extra == 'databricks'
31
+ Provides-Extra: plotly
32
+ Requires-Dist: plotly>=5.0.0; extra == 'plotly'
31
33
  Description-Content-Type: text/markdown
32
34
 
33
35
  # SQL Glider
@@ -420,12 +422,33 @@ uv run sqlglider graph query graph.json --upstream orders.customer_id -f mermaid
420
422
  # Query with DOT (Graphviz) diagram output
421
423
  uv run sqlglider graph query graph.json --downstream customers.id -f dot
422
424
 
425
+ # Query with Plotly JSON output (for interactive visualization)
426
+ uv run sqlglider graph query graph.json --upstream orders.customer_id -f plotly
427
+
423
428
  # Visualize entire graph as a diagram
424
429
  uv run sqlglider graph visualize graph.json # Mermaid (default)
425
430
  uv run sqlglider graph visualize graph.json -f dot # DOT/Graphviz
431
+ uv run sqlglider graph visualize graph.json -f plotly # Plotly JSON
426
432
  uv run sqlglider graph visualize graph.json -o lineage.mmd # Save to file
427
433
  ```
428
434
 
435
+ > **Note:** Plotly output requires an optional dependency. Install with: `pip install sql-glider[plotly]`
436
+
437
+ The Plotly JSON output can be loaded into Plotly/Dash applications for interactive visualization:
438
+
439
+ ```python
440
+ import plotly.io as pio
441
+ from dash import Dash, dcc, html
442
+
443
+ # Load the JSON output
444
+ with open("lineage.json") as f:
445
+ fig = pio.from_json(f.read())
446
+
447
+ # Use in a Dash app
448
+ app = Dash(__name__)
449
+ app.layout = html.Div([dcc.Graph(figure=fig)])
450
+ ```
451
+
429
452
  **Example Upstream Query Output:**
430
453
  ```
431
454
  Sources for 'order_totals.total'
@@ -711,12 +734,24 @@ Arguments:
711
734
  Options:
712
735
  --upstream, -u Find source columns for this column [optional]
713
736
  --downstream, -d Find affected columns for this source [optional]
714
- --output-format, -f Output format: 'text', 'json', or 'csv' [default: text]
737
+ --output-format, -f Output format: 'text', 'json', 'csv', 'mermaid', 'mermaid-markdown', 'dot', or 'plotly' [default: text]
738
+ ```
739
+
740
+ ```
741
+ sqlglider graph visualize <graph_file> [OPTIONS]
742
+
743
+ Arguments:
744
+ graph_file Path to graph JSON file [required]
745
+
746
+ Options:
747
+ --output-format, -f Diagram format: 'mermaid', 'mermaid-markdown', 'dot', or 'plotly' [default: mermaid]
748
+ --output-file, -o Write diagram to file instead of stdout [optional]
715
749
  ```
716
750
 
717
751
  **Notes:**
718
752
  - `--upstream` and `--downstream` are mutually exclusive. Use one or the other.
719
753
  - Graph queries are case-insensitive for column matching.
754
+ - Plotly output requires optional dependency: `pip install sql-glider[plotly]`
720
755
 
721
756
  ## Output Formats
722
757
 
@@ -921,6 +956,21 @@ UV_PUBLISH_TOKEN=pypi-...
921
956
  - **pydantic:** Data validation and serialization
922
957
  - **rustworkx:** High-performance graph library for cross-file lineage analysis
923
958
 
959
+ ### Optional Dependencies
960
+
961
+ Install optional features with extras:
962
+
963
+ ```bash
964
+ # Databricks catalog integration
965
+ pip install sql-glider[databricks]
966
+
967
+ # Plotly interactive visualization
968
+ pip install sql-glider[plotly]
969
+
970
+ # Install multiple extras
971
+ pip install sql-glider[databricks,plotly]
972
+ ```
973
+
924
974
  ## References
925
975
 
926
976
  - [SQLGlot Documentation](https://sqlglot.com/)
@@ -1,6 +1,6 @@
1
1
  sqlglider/__init__.py,sha256=gDf7s52dMcX7JuCZ1SLawcB1vb3U0yJCohu9RQAATBY,125
2
- sqlglider/_version.py,sha256=eZj6tqY-zTtH5r_8y6a4Vovz6LQ_hDHSesTIiwuyahQ,706
3
- sqlglider/cli.py,sha256=yvCkwU75aCSwExXcu0xHpMMI2G5X6L-DViP44nUgs5A,68548
2
+ sqlglider/_version.py,sha256=VIORluFSyo8DggJNI3m2ltXngK-bmCHX8hSwlGrwopY,706
3
+ sqlglider/cli.py,sha256=h0dAx-Dz_LkSfrWlxD8wU_G2WzWqzn14EjHAwajWVyQ,69591
4
4
  sqlglider/global_models.py,sha256=2vyJXAuXOsXQpE-D3F0ejj7eR9z0nDWFjTkielhzM8k,356
5
5
  sqlglider/catalog/__init__.py,sha256=2PqFPyzFXJ14FpSUcBmVK2L-a_ypWQHAbHFHxLDk_LE,814
6
6
  sqlglider/catalog/base.py,sha256=R7htHC43InpH4uRjYk33dMYYji6oylHns7Ye_mgfjJE,3116
@@ -12,7 +12,7 @@ sqlglider/dissection/formatters.py,sha256=M7gsmTNljRIeLIRv4D0vHvqJVrTqWSpsg7vem8
12
12
  sqlglider/dissection/models.py,sha256=RRD3RIteqbUBY6e-74skKDvMH3qeAUaqA2sFcrjP5GQ,3618
13
13
  sqlglider/graph/__init__.py,sha256=4DDdrPM75CmeQWt7wHdBsjCm1s70BHGLYdijIbaUEKY,871
14
14
  sqlglider/graph/builder.py,sha256=VNBdsDlkiaId3JGvr2G4h6OIFek_9zPsGMIYL9GpJlk,15796
15
- sqlglider/graph/diagram_formatters.py,sha256=loCpT343Awuj1mue8pv2fNTfwrLfK8n9adlfpPI_OQw,10825
15
+ sqlglider/graph/diagram_formatters.py,sha256=-7BFG3z4EZrmiwnOAco-R-Axs5OL57ytMXGHuTh0z68,25321
16
16
  sqlglider/graph/formatters.py,sha256=p85-WN9oPmEETsAtWSo1sIQELF36w85QoFEJyfBZGoM,4800
17
17
  sqlglider/graph/merge.py,sha256=uUZlm4BN3S9gRL66Cc2mzhbtuh4SVAv2n4cN4eUEQBU,4077
18
18
  sqlglider/graph/models.py,sha256=EYmjv_WzDSNp_WfhJ6H-qBIOkAcoNKS7GRUryfKrHuY,9330
@@ -32,8 +32,8 @@ sqlglider/utils/__init__.py,sha256=KGp9-UzKz_OFBOTFoSy-g-NXDZsvyWXG_9-1zcC6ePE,2
32
32
  sqlglider/utils/config.py,sha256=qx5zE9pjLCCzHQDFVPLVd7LgJ-lghxUa2x-aZOAHByY,4962
33
33
  sqlglider/utils/file_utils.py,sha256=5_ff28E0r1R7emZzsOnRuHd-7zIX6873eyr1SuPEr4E,1093
34
34
  sqlglider/utils/schema.py,sha256=LiWrYDunXKJdoSlpKmIaIQ2hLSaIN1iQHqkXjMpGzRE,1883
35
- sql_glider-0.1.23.dist-info/METADATA,sha256=kus6plflZU9p594ooQRsVkeYgEPyW8V_hbvCyMUGid8,29695
36
- sql_glider-0.1.23.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
37
- sql_glider-0.1.23.dist-info/entry_points.txt,sha256=HDuakHqHS5C0HFKsMIxMYmDU7-BLBGrnIJcYaVRu-s0,251
38
- sql_glider-0.1.23.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
39
- sql_glider-0.1.23.dist-info/RECORD,,
35
+ sql_glider-0.1.25.dist-info/METADATA,sha256=qCGtZ5ROBNQbi-9NDmWUNdf9WtIG9NQoEVrplFzXsxs,31197
36
+ sql_glider-0.1.25.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
37
+ sql_glider-0.1.25.dist-info/entry_points.txt,sha256=HDuakHqHS5C0HFKsMIxMYmDU7-BLBGrnIJcYaVRu-s0,251
38
+ sql_glider-0.1.25.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
39
+ sql_glider-0.1.25.dist-info/RECORD,,
sqlglider/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.23'
32
- __version_tuple__ = version_tuple = (0, 1, 23)
31
+ __version__ = version = '0.1.25'
32
+ __version_tuple__ = version_tuple = (0, 1, 25)
33
33
 
34
34
  __commit_id__ = commit_id = None
sqlglider/cli.py CHANGED
@@ -1642,7 +1642,7 @@ def graph_query(
1642
1642
  "text",
1643
1643
  "--output-format",
1644
1644
  "-f",
1645
- help="Output format: 'text', 'json', 'csv', 'mermaid', 'mermaid-markdown', or 'dot'",
1645
+ help="Output format: 'text', 'json', 'csv', 'mermaid', 'mermaid-markdown', 'dot', or 'plotly'",
1646
1646
  ),
1647
1647
  ) -> None:
1648
1648
  """
@@ -1661,6 +1661,9 @@ def graph_query(
1661
1661
 
1662
1662
  # CSV output
1663
1663
  sqlglider graph query graph.json --downstream orders.order_id -f csv
1664
+
1665
+ # Plotly JSON output (for Dash/Plotly visualization)
1666
+ sqlglider graph query graph.json --upstream orders.total -f plotly
1664
1667
  """
1665
1668
  from sqlglider.graph.query import GraphQuerier
1666
1669
 
@@ -1685,10 +1688,11 @@ def graph_query(
1685
1688
  "mermaid",
1686
1689
  "mermaid-markdown",
1687
1690
  "dot",
1691
+ "plotly",
1688
1692
  ]:
1689
1693
  err_console.print(
1690
1694
  f"[red]Error:[/red] Invalid output format '{output_format}'. "
1691
- "Use 'text', 'json', 'csv', 'mermaid', 'mermaid-markdown', or 'dot'."
1695
+ "Use 'text', 'json', 'csv', 'mermaid', 'mermaid-markdown', 'dot', or 'plotly'."
1692
1696
  )
1693
1697
  raise typer.Exit(1)
1694
1698
 
@@ -1720,6 +1724,14 @@ def graph_query(
1720
1724
  from sqlglider.graph.diagram_formatters import DotFormatter
1721
1725
 
1722
1726
  print(DotFormatter.format_query_result(result))
1727
+ elif output_format == "plotly":
1728
+ from sqlglider.graph.diagram_formatters import PlotlyFormatter
1729
+
1730
+ print(PlotlyFormatter.format_query_result(result, graph=querier.graph))
1731
+
1732
+ except ImportError as e:
1733
+ err_console.print(f"[red]Error:[/red] {e}")
1734
+ raise typer.Exit(1)
1723
1735
 
1724
1736
  except FileNotFoundError as e:
1725
1737
  err_console.print(f"[red]Error:[/red] {e}")
@@ -1825,7 +1837,7 @@ def graph_visualize(
1825
1837
  "mermaid",
1826
1838
  "--output-format",
1827
1839
  "-f",
1828
- help="Diagram format: 'mermaid', 'mermaid-markdown', or 'dot'",
1840
+ help="Diagram format: 'mermaid', 'mermaid-markdown', 'dot', or 'plotly'",
1829
1841
  ),
1830
1842
  output_file: Optional[Path] = typer.Option(
1831
1843
  None,
@@ -1837,8 +1849,8 @@ def graph_visualize(
1837
1849
  """
1838
1850
  Visualize the entire lineage graph as a diagram.
1839
1851
 
1840
- Generates Mermaid or DOT (Graphviz) diagrams showing all nodes and edges
1841
- in the graph for visualization tools.
1852
+ Generates Mermaid, DOT (Graphviz), or Plotly JSON diagrams showing all
1853
+ nodes and edges in the graph for visualization tools.
1842
1854
 
1843
1855
  Examples:
1844
1856
 
@@ -1853,18 +1865,16 @@ def graph_visualize(
1853
1865
 
1854
1866
  # Render DOT to PNG with Graphviz
1855
1867
  sqlglider graph visualize graph.json -f dot -o lineage.dot
1868
+
1869
+ # Generate Plotly JSON for Dash/Plotly apps
1870
+ sqlglider graph visualize graph.json -f plotly -o lineage.json
1856
1871
  """
1857
- from sqlglider.graph.diagram_formatters import (
1858
- DotFormatter,
1859
- MermaidFormatter,
1860
- MermaidMarkdownFormatter,
1861
- )
1862
1872
  from sqlglider.graph.serialization import load_graph
1863
1873
 
1864
- if output_format not in ["mermaid", "mermaid-markdown", "dot"]:
1874
+ if output_format not in ["mermaid", "mermaid-markdown", "dot", "plotly"]:
1865
1875
  err_console.print(
1866
1876
  f"[red]Error:[/red] Invalid output format '{output_format}'. "
1867
- "Use 'mermaid', 'mermaid-markdown', or 'dot'."
1877
+ "Use 'mermaid', 'mermaid-markdown', 'dot', or 'plotly'."
1868
1878
  )
1869
1879
  raise typer.Exit(1)
1870
1880
 
@@ -1872,11 +1882,21 @@ def graph_visualize(
1872
1882
  graph = load_graph(graph_file)
1873
1883
 
1874
1884
  if output_format == "mermaid":
1885
+ from sqlglider.graph.diagram_formatters import MermaidFormatter
1886
+
1875
1887
  diagram = MermaidFormatter.format_full_graph(graph)
1876
1888
  elif output_format == "mermaid-markdown":
1889
+ from sqlglider.graph.diagram_formatters import MermaidMarkdownFormatter
1890
+
1877
1891
  diagram = MermaidMarkdownFormatter.format_full_graph(graph)
1878
- else:
1892
+ elif output_format == "dot":
1893
+ from sqlglider.graph.diagram_formatters import DotFormatter
1894
+
1879
1895
  diagram = DotFormatter.format_full_graph(graph)
1896
+ else: # plotly
1897
+ from sqlglider.graph.diagram_formatters import PlotlyFormatter
1898
+
1899
+ diagram = PlotlyFormatter.format_full_graph(graph)
1880
1900
 
1881
1901
  if output_file:
1882
1902
  output_file.write_text(diagram, encoding="utf-8")
@@ -1884,6 +1904,10 @@ def graph_visualize(
1884
1904
  else:
1885
1905
  print(diagram)
1886
1906
 
1907
+ except ImportError as e:
1908
+ err_console.print(f"[red]Error:[/red] {e}")
1909
+ raise typer.Exit(1)
1910
+
1887
1911
  except FileNotFoundError as e:
1888
1912
  err_console.print(f"[red]Error:[/red] {e}")
1889
1913
  raise typer.Exit(1)
@@ -1,7 +1,9 @@
1
- """Diagram formatters for lineage graphs (Mermaid and DOT/Graphviz)."""
1
+ """Diagram formatters for lineage graphs (Mermaid, DOT/Graphviz, and Plotly)."""
2
2
 
3
+ import json
3
4
  import re
4
- from typing import Set
5
+ from collections import defaultdict
6
+ from typing import Any, Optional, Set
5
7
 
6
8
  from sqlglider.graph.models import LineageGraph
7
9
  from sqlglider.graph.query import LineageQueryResult
@@ -328,3 +330,409 @@ class DotFormatter:
328
330
 
329
331
  lines.append("}")
330
332
  return "\n".join(lines)
333
+
334
+
335
+ def _compute_layered_layout(
336
+ nodes: list[str],
337
+ edges: list[tuple[str, str]],
338
+ x_spacing: float = 250.0,
339
+ y_spacing: float = 100.0,
340
+ ) -> dict[str, tuple[float, float]]:
341
+ """Compute layered layout positions for nodes using topological ordering.
342
+
343
+ Positions nodes in layers from left to right based on their dependencies.
344
+ Nodes with no incoming edges are placed in layer 0, their dependents in
345
+ layer 1, etc.
346
+
347
+ Args:
348
+ nodes: List of node identifiers
349
+ edges: List of (source, target) edge tuples
350
+ x_spacing: Horizontal spacing between layers (default: 250 pixels)
351
+ y_spacing: Vertical spacing between nodes in the same layer (default: 100 pixels)
352
+
353
+ Returns:
354
+ Dictionary mapping node identifiers to (x, y) positions
355
+ """
356
+ if not nodes:
357
+ return {}
358
+
359
+ # Build adjacency structures
360
+ incoming: dict[str, set[str]] = defaultdict(set)
361
+ outgoing: dict[str, set[str]] = defaultdict(set)
362
+ for src, tgt in edges:
363
+ outgoing[src].add(tgt)
364
+ incoming[tgt].add(src)
365
+
366
+ # Assign layers via modified Kahn's algorithm
367
+ layers: dict[str, int] = {}
368
+ node_set = set(nodes)
369
+
370
+ # Start with nodes that have no incoming edges (roots)
371
+ current_layer = [n for n in nodes if not incoming[n]]
372
+ if not current_layer:
373
+ # Cycle detected or all nodes have incoming edges, use first node
374
+ current_layer = [nodes[0]]
375
+
376
+ layer_num = 0
377
+ while current_layer:
378
+ next_layer = []
379
+ for node in current_layer:
380
+ if node not in layers:
381
+ layers[node] = layer_num
382
+ for child in outgoing[node]:
383
+ if child in node_set and child not in layers:
384
+ # Check if all parents are assigned
385
+ if all(p in layers for p in incoming[child]):
386
+ next_layer.append(child)
387
+ layer_num += 1
388
+ current_layer = next_layer
389
+
390
+ # Assign any remaining unvisited nodes to the last layer
391
+ for node in nodes:
392
+ if node not in layers:
393
+ layers[node] = layer_num
394
+
395
+ # Group nodes by layer for vertical positioning
396
+ layer_groups: dict[int, list[str]] = defaultdict(list)
397
+ for node, layer in layers.items():
398
+ layer_groups[layer].append(node)
399
+
400
+ # Compute positions: x based on layer, y spread vertically within layer
401
+ positions: dict[str, tuple[float, float]] = {}
402
+
403
+ for layer, layer_nodes in layer_groups.items():
404
+ x = layer * x_spacing
405
+ n = len(layer_nodes)
406
+ for i, node in enumerate(sorted(layer_nodes)):
407
+ # Center nodes vertically, spread them out uniformly
408
+ y = (i - (n - 1) / 2) * y_spacing
409
+ positions[node] = (x, y)
410
+
411
+ return positions
412
+
413
+
414
+ class PlotlyFormatter:
415
+ """Format lineage graphs as Plotly JSON figure specifications.
416
+
417
+ Generates JSON that can be loaded into Plotly/Dash applications using
418
+ plotly.io.from_json() or directly into dcc.Graph components.
419
+
420
+ Requires the 'plotly' optional dependency: pip install sql-glider[plotly]
421
+ """
422
+
423
+ @staticmethod
424
+ def _check_plotly_available() -> None:
425
+ """Check if plotly is installed, raise ImportError if not."""
426
+ try:
427
+ import plotly # noqa: F401
428
+ except ImportError:
429
+ raise ImportError(
430
+ "Plotly is required for this output format. "
431
+ "Install it with: pip install sql-glider[plotly]"
432
+ )
433
+
434
+ @staticmethod
435
+ def format_full_graph(graph: LineageGraph) -> str:
436
+ """Format complete lineage graph as a Plotly JSON figure.
437
+
438
+ Args:
439
+ graph: LineageGraph with all nodes and edges
440
+
441
+ Returns:
442
+ JSON string representing a Plotly figure specification
443
+ """
444
+ PlotlyFormatter._check_plotly_available()
445
+
446
+ node_ids = [n.identifier for n in graph.nodes]
447
+ edge_tuples = [(e.source_node, e.target_node) for e in graph.edges]
448
+
449
+ # Build lookup for edge file paths
450
+ edge_file_paths: dict[tuple[str, str], str] = {}
451
+ for e in graph.edges:
452
+ edge_file_paths[(e.source_node, e.target_node)] = e.file_path
453
+
454
+ if not node_ids:
455
+ # Empty graph
456
+ figure: dict[str, Any] = {
457
+ "data": [],
458
+ "layout": {
459
+ "title": {"text": "Lineage Graph"},
460
+ "showlegend": False,
461
+ "xaxis": {"visible": False},
462
+ "yaxis": {"visible": False},
463
+ },
464
+ }
465
+ return json.dumps(figure, indent=2)
466
+
467
+ positions = _compute_layered_layout(node_ids, edge_tuples)
468
+
469
+ # Build edge traces with hover text for file path
470
+ edge_traces: list[dict[str, Any]] = []
471
+ edge_annotations: list[dict[str, Any]] = []
472
+ for src, tgt in edge_tuples:
473
+ if src in positions and tgt in positions:
474
+ x0, y0 = positions[src]
475
+ x1, y1 = positions[tgt]
476
+ file_path = edge_file_paths.get((src, tgt), "")
477
+ # Extract just the filename for display
478
+ file_name = (
479
+ file_path.split("/")[-1].split("\\")[-1] if file_path else ""
480
+ )
481
+
482
+ edge_traces.append(
483
+ {
484
+ "type": "scatter",
485
+ "x": [x0, x1, None],
486
+ "y": [y0, y1, None],
487
+ "mode": "lines",
488
+ "line": {"width": 1.5, "color": "#888"},
489
+ "hoverinfo": "text",
490
+ "hovertext": file_path,
491
+ "showlegend": False,
492
+ }
493
+ )
494
+
495
+ # Add annotation at midpoint of edge
496
+ mid_x = (x0 + x1) / 2
497
+ mid_y = (y0 + y1) / 2
498
+ edge_annotations.append(
499
+ {
500
+ "x": mid_x,
501
+ "y": mid_y,
502
+ "text": file_name,
503
+ "showarrow": False,
504
+ "font": {"size": 9, "color": "#666"},
505
+ "bgcolor": "rgba(255,255,255,0.8)",
506
+ }
507
+ )
508
+
509
+ # Build node trace
510
+ node_x = [positions[n][0] for n in node_ids if n in positions]
511
+ node_y = [positions[n][1] for n in node_ids if n in positions]
512
+ node_text = [n for n in node_ids if n in positions]
513
+
514
+ node_trace: dict[str, Any] = {
515
+ "type": "scatter",
516
+ "x": node_x,
517
+ "y": node_y,
518
+ "mode": "markers+text",
519
+ "text": node_text,
520
+ "textposition": "top center",
521
+ "textfont": {"size": 11},
522
+ "hoverinfo": "text",
523
+ "marker": {
524
+ "size": 15,
525
+ "color": "#6495ED",
526
+ "line": {"width": 2, "color": "#4169E1"},
527
+ },
528
+ "showlegend": False,
529
+ }
530
+
531
+ # Calculate figure dimensions based on graph size
532
+ min_height = 400
533
+ height_per_node = 50
534
+ calculated_height = max(min_height, len(node_ids) * height_per_node)
535
+
536
+ figure = {
537
+ "data": edge_traces + [node_trace],
538
+ "layout": {
539
+ "title": {"text": "Lineage Graph"},
540
+ "showlegend": False,
541
+ "hovermode": "closest",
542
+ "xaxis": {"visible": False},
543
+ "yaxis": {"visible": False},
544
+ "height": calculated_height,
545
+ "margin": {"l": 50, "r": 50, "t": 60, "b": 40},
546
+ "annotations": edge_annotations,
547
+ },
548
+ }
549
+
550
+ return json.dumps(figure, indent=2)
551
+
552
+ @staticmethod
553
+ def format_query_result(
554
+ result: LineageQueryResult,
555
+ graph: Optional[LineageGraph] = None,
556
+ ) -> str:
557
+ """Format query result as a Plotly JSON figure with styling.
558
+
559
+ The queried column is highlighted in amber, root nodes in teal,
560
+ and leaf nodes in violet, matching the Mermaid/DOT color scheme.
561
+
562
+ Args:
563
+ result: LineageQueryResult from upstream/downstream query
564
+ graph: Optional LineageGraph for edge file path labels
565
+
566
+ Returns:
567
+ JSON string representing a Plotly figure specification
568
+ """
569
+ PlotlyFormatter._check_plotly_available()
570
+
571
+ all_nodes = _collect_query_nodes(result)
572
+ edges = _collect_query_edges(result)
573
+
574
+ # Build edge file path lookup if graph is provided
575
+ edge_file_paths: dict[tuple[str, str], str] = {}
576
+ if graph:
577
+ for e in graph.edges:
578
+ edge_file_paths[(e.source_node, e.target_node)] = e.file_path
579
+
580
+ if not all_nodes:
581
+ # Should not happen, but handle gracefully
582
+ figure: dict[str, Any] = {
583
+ "data": [],
584
+ "layout": {
585
+ "title": {"text": f"Lineage: {result.query_column}"},
586
+ "showlegend": False,
587
+ "xaxis": {"visible": False},
588
+ "yaxis": {"visible": False},
589
+ },
590
+ }
591
+ return json.dumps(figure, indent=2)
592
+
593
+ node_list = sorted(all_nodes)
594
+ edge_list = list(edges)
595
+ positions = _compute_layered_layout(node_list, edge_list)
596
+
597
+ # Build styling lookup
598
+ root_ids: set[str] = set()
599
+ leaf_ids: set[str] = set()
600
+ for node in result.related_columns:
601
+ if node.is_root:
602
+ root_ids.add(node.identifier)
603
+ if node.is_leaf:
604
+ leaf_ids.add(node.identifier)
605
+
606
+ # Determine node colors
607
+ node_colors: list[str] = []
608
+ for node in node_list:
609
+ if node == result.query_column:
610
+ node_colors.append(QUERIED_FILL)
611
+ elif node in root_ids:
612
+ node_colors.append(ROOT_FILL)
613
+ elif node in leaf_ids:
614
+ node_colors.append(LEAF_FILL)
615
+ else:
616
+ node_colors.append("#6495ED") # Default blue
617
+
618
+ # Build edge traces with optional file path labels
619
+ edge_traces: list[dict[str, Any]] = []
620
+ edge_annotations: list[dict[str, Any]] = []
621
+ for src, tgt in sorted(edges):
622
+ if src in positions and tgt in positions:
623
+ x0, y0 = positions[src]
624
+ x1, y1 = positions[tgt]
625
+ file_path = edge_file_paths.get((src, tgt), "")
626
+ file_name = (
627
+ file_path.split("/")[-1].split("\\")[-1] if file_path else ""
628
+ )
629
+
630
+ edge_traces.append(
631
+ {
632
+ "type": "scatter",
633
+ "x": [x0, x1, None],
634
+ "y": [y0, y1, None],
635
+ "mode": "lines",
636
+ "line": {"width": 1.5, "color": "#888"},
637
+ "hoverinfo": "text" if file_path else "none",
638
+ "hovertext": file_path if file_path else None,
639
+ "showlegend": False,
640
+ }
641
+ )
642
+
643
+ # Add annotation at midpoint of edge if we have file path info
644
+ if file_name:
645
+ mid_x = (x0 + x1) / 2
646
+ mid_y = (y0 + y1) / 2
647
+ edge_annotations.append(
648
+ {
649
+ "x": mid_x,
650
+ "y": mid_y,
651
+ "text": file_name,
652
+ "showarrow": False,
653
+ "font": {"size": 9, "color": "#666"},
654
+ "bgcolor": "rgba(255,255,255,0.8)",
655
+ }
656
+ )
657
+
658
+ # Build node trace
659
+ node_x = [positions[n][0] for n in node_list if n in positions]
660
+ node_y = [positions[n][1] for n in node_list if n in positions]
661
+
662
+ node_trace: dict[str, Any] = {
663
+ "type": "scatter",
664
+ "x": node_x,
665
+ "y": node_y,
666
+ "mode": "markers+text",
667
+ "text": node_list,
668
+ "textposition": "top center",
669
+ "textfont": {"size": 11},
670
+ "hoverinfo": "text",
671
+ "marker": {
672
+ "size": 15,
673
+ "color": node_colors,
674
+ "line": {"width": 2, "color": "#333"},
675
+ },
676
+ "showlegend": False,
677
+ }
678
+
679
+ # Build legend traces (invisible markers for legend display)
680
+ legend_traces: list[dict[str, Any]] = [
681
+ {
682
+ "type": "scatter",
683
+ "x": [None],
684
+ "y": [None],
685
+ "mode": "markers",
686
+ "marker": {"size": 12, "color": QUERIED_FILL},
687
+ "name": "Queried Column",
688
+ "showlegend": True,
689
+ },
690
+ {
691
+ "type": "scatter",
692
+ "x": [None],
693
+ "y": [None],
694
+ "mode": "markers",
695
+ "marker": {"size": 12, "color": ROOT_FILL},
696
+ "name": "Root (no upstream)",
697
+ "showlegend": True,
698
+ },
699
+ {
700
+ "type": "scatter",
701
+ "x": [None],
702
+ "y": [None],
703
+ "mode": "markers",
704
+ "marker": {"size": 12, "color": LEAF_FILL},
705
+ "name": "Leaf (no downstream)",
706
+ "showlegend": True,
707
+ },
708
+ ]
709
+
710
+ direction_label = "Upstream" if result.direction == "upstream" else "Downstream"
711
+ title = f"{direction_label} Lineage: {result.query_column}"
712
+
713
+ # Calculate figure dimensions based on graph size
714
+ min_height = 400
715
+ height_per_node = 50
716
+ calculated_height = max(min_height, len(node_list) * height_per_node)
717
+
718
+ layout: dict[str, Any] = {
719
+ "title": {"text": title},
720
+ "showlegend": True,
721
+ "legend": {"x": 1, "y": 1, "xanchor": "right"},
722
+ "hovermode": "closest",
723
+ "xaxis": {"visible": False},
724
+ "yaxis": {"visible": False},
725
+ "height": calculated_height,
726
+ "margin": {"l": 50, "r": 50, "t": 60, "b": 40},
727
+ }
728
+
729
+ # Add edge annotations if we have file path info
730
+ if edge_annotations:
731
+ layout["annotations"] = edge_annotations
732
+
733
+ figure = {
734
+ "data": edge_traces + [node_trace] + legend_traces,
735
+ "layout": layout,
736
+ }
737
+
738
+ return json.dumps(figure, indent=2)