commit cf3a1dda0282c09daee28f73b5c6e095dcf5eb78
parent 43abaaa91da3b19567afbd38551cefee92980289
Author: Andy Khramtsov <>
Date: Wed, 27 May 2026 23:00:35 +0300
feat: visualize
Diffstat:
3 files changed, 369 insertions(+), 7 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
@@ -12,6 +12,7 @@ dependencies = [
"dash-ag-grid>=35.2.0",
"plotly>=6.7.0",
"polars>=1.40.1",
+ "pydot>=4.0.1",
]
[build-system]
diff --git a/src/deps/pages/home.py b/src/deps/pages/home.py
@@ -3,8 +3,10 @@ import tomllib
import dash
import polars as pl
-from dash import Input, Output, State, callback, dcc, html
+import pydot
+from dash import Input, Output, State, callback, ctx, dcc, html
from dash.exceptions import PreventUpdate
+from plotly import graph_objects as go
from deps.aio_components.table_aio import TableAIO
@@ -14,16 +16,46 @@ dash.register_page(__name__, path="/", order=1)
class ids:
cargo_lock_textarea: str = "cargo-lock-textarea"
upload_cargo_lock: str = "upload-cargo-lock"
- cargo_lock_table: str = "cargo-lock-table"
cargo_lock_filename_display: str = "cargo-lock-filename-display"
+ cargo_toml_textarea: str = "cargo-toml-textarea"
+ upload_cargo_toml: str = "upload-cargo-toml"
+ cargo_toml_filename_display: str = "cargo-toml-filename-display"
+
+ cargo_lock_table: str = "cargo-lock-table"
+ cargo_toml_table: str = "cargo-toml-table"
+
+ recalculate_button: str = "recalculate-button"
+ generate_button: str = "generate-button"
+ dependency_graph: str = "dependency-graph"
+ reset_highlight_button: str = "reset-highlight-button"
+
def layout():
return html.Div(
className="padded-box vertical-content vertical-content_large-gap",
children=[
html.H2("Cargo dependency visualisation"),
- dcc.Textarea(id=ids.cargo_lock_textarea, placeholder="Cargo.lock contents", style={}),
+ html.H3("Cargo toml"),
+ dcc.Textarea(
+ id=ids.cargo_toml_textarea,
+ placeholder="Cargo.toml contents",
+ persistence=True,
+ ),
+ dcc.Upload(
+ id=ids.upload_cargo_toml,
+ children=[
+ dcc.Button("Upload Cargo.toml"),
+ html.Div("Uploaded file name:"),
+ html.Div(id=ids.cargo_toml_filename_display, children="Empty"),
+ ],
+ ),
+ html.H3("Cargo lock"),
+ dcc.Textarea(
+ id=ids.cargo_lock_textarea,
+ placeholder="Cargo.lock contents",
+ persistence=True,
+ ),
dcc.Upload(
id=ids.upload_cargo_lock,
children=[
@@ -32,6 +64,18 @@ def layout():
html.Div(id=ids.cargo_lock_filename_display, children="Empty"),
],
),
+ dcc.Button(id=ids.recalculate_button, children="Recalculate"),
+ html.H3("Cargo toml dependencies"),
+ TableAIO(
+ aio_id=ids.cargo_toml_table,
+ column_defs=[
+ {"field": i, "colId": i}
+ for i in [
+ "name",
+ ]
+ ],
+ ),
+ html.H3("Cargo lock packages"),
TableAIO(
aio_id=ids.cargo_lock_table,
column_defs=[
@@ -44,10 +88,243 @@ def layout():
]
],
),
+ dcc.Button(id=ids.generate_button, children="Generate"),
+ html.H3("Dependency graph"),
+ dcc.Graph(
+ id=ids.dependency_graph,
+ figure={},
+ style={"height": 800},
+ ),
+ dcc.Button(id=ids.reset_highlight_button, children="Reset highlight"),
],
)
+def parse_cargo_toml(contents: str) -> pl.DataFrame:
+ toml = tomllib.loads(contents)
+ dependencies = toml.get("dependencies", {})
+ return pl.DataFrame(list(dict(name=name) for name in dependencies))
+
+
+def parse_cargo_lock(contents: str) -> pl.DataFrame:
+ toml = tomllib.loads(contents)
+ return pl.DataFrame(toml.get("package", []))
+
+
+def parse_graph(cargo_toml: pl.DataFrame, cargo_lock: pl.DataFrame) -> dict[str, pl.DataFrame]:
+
+ graph_arcs = (
+ (
+ cargo_lock.select(
+ (pl.col("name") + " " + pl.col("version")).alias("source"),
+ pl.col("dependencies").alias("target"),
+ )
+ .explode("target")
+ .select(
+ pl.col("source"),
+ pl.col("target"),
+ pl.col("target").str.split(" ").alias("target_split"),
+ )
+ .select(
+ pl.col("source"),
+ pl.when(pl.col("target_split").list.len() > 1)
+ .then(pl.col("target_split").list.get(0))
+ .otherwise(pl.col("target"))
+ .alias("target_name"),
+ pl.col("target_split").list.get(1, null_on_oob=True).alias("target_version"),
+ )
+ )
+ .join(
+ cargo_lock.select(
+ pl.col("name"),
+ pl.col("version"),
+ ).filter(pl.col("name").is_unique()),
+ left_on=pl.col("target_name"),
+ right_on=pl.col("name"),
+ how="left",
+ )
+ .select(
+ pl.col("source"),
+ pl.when(pl.col("target_name").is_not_null())
+ .then(
+ pl.col("target_name")
+ + " "
+ + pl.coalesce(
+ pl.col("target_version"),
+ pl.col("version"),
+ )
+ )
+ .otherwise(pl.lit(None))
+ .alias("target"),
+ )
+ )
+
+ graph_nodes = cargo_lock.join(
+ cargo_toml.select(
+ pl.col("name"),
+ pl.lit(True).alias("explicit"),
+ ),
+ pl.col("name"),
+ "left",
+ ).select(
+ (pl.col("name") + " " + pl.col("version")).alias("node_id"),
+ pl.col("name").alias("short_label"),
+ pl.struct(
+ name=pl.col("name"),
+ explicit=pl.col("explicit").fill_null(False),
+ version=pl.col("version"),
+ source=pl.col("source"),
+ checksum=pl.col("checksum"),
+ dependencies=pl.col("dependencies"),
+ ).alias("full_details"),
+ )
+
+ return dict(
+ nodes=graph_nodes,
+ arcs=graph_arcs,
+ )
+
+
+def compute_positions(df_graph: dict[str, pl.DataFrame]) -> dict[str, tuple[float, float]]:
+ def compute_graph(df_graph: dict[str, pl.DataFrame]) -> pydot.Dot:
+ graph = pydot.Dot(graph_type="digraph", rankdir="LR")
+ for row in df_graph["nodes"].iter_rows(named=True):
+ graph.add_node(pydot.Node(name=str(row["node_id"])))
+ for row in df_graph["arcs"].iter_rows(named=True):
+ graph.add_edge(pydot.Edge(str(row["source"]), str(row["target"])))
+
+ dot_output = graph.create(prog="dot", format="dot")
+ dot_string = dot_output.decode("utf-8") if isinstance(dot_output, bytes) else dot_output
+ computed_graphs = pydot.graph_from_dot_data(dot_string)
+ if not computed_graphs:
+ raise PreventUpdate
+ return computed_graphs[0]
+
+ def extract_positions(graph: pydot.Dot) -> dict[str, tuple[float, float]]:
+ pos = {}
+ for node in graph.get_nodes():
+ name = node.get_name().strip('"')
+ pos_attr = node.get_attributes().get("pos")
+ if pos_attr:
+ x, y = map(float, pos_attr.strip('"').split(","))
+ pos[name] = (x, y)
+ return pos
+
+ return extract_positions(compute_graph(df_graph))
+
+
+def draw_graph_figure(
+ df_graph: dict[str, pl.DataFrame],
+ positions: dict[str, tuple[float, float]],
+ selected_node=None,
+) -> go.Figure:
+ adjacency_list = {node: set() for node in df_graph["nodes"]["node_id"]}
+ edge_x, edge_y = [], []
+ for row in df_graph["arcs"].iter_rows(named=True):
+ if not row["target"]:
+ continue
+ src, tgt = str(row["source"]), str(row["target"])
+ adjacency_list[src].add(tgt)
+ adjacency_list[tgt].add(src)
+
+ if src in positions and tgt in positions:
+ x0, y0 = positions[src]
+ x1, y1 = positions[tgt]
+ edge_x.extend([x0, x1, None])
+ edge_y.extend([y0, y1, None])
+
+ highlight_nodes = set()
+ if selected_node and selected_node in adjacency_list:
+ highlight_nodes.add(selected_node)
+ highlight_nodes.update(adjacency_list[selected_node])
+
+ edge_trace = go.Scatter(x=edge_x, y=edge_y, line=dict(width=1.0, color="#A0AEC0"), hoverinfo="skip", mode="lines")
+
+ node_x = []
+ node_y = []
+ short_labels = []
+ hover_details = []
+ colors = []
+ sizes = []
+ text_colors = []
+ custom_data = []
+ for row in df_graph["nodes"].iter_rows(named=True):
+ node_id = str(row["node_id"])
+ if node_id in positions:
+ x, y = positions[node_id]
+ node_x.append(x)
+ node_y.append(y)
+ short_labels.append(row["short_label"])
+ hover_details.append("<br>".join([f"<b>{key}:</b> {val}" for key, val in row["full_details"].items()]))
+ custom_data.append(node_id)
+
+ base_color = "#C1829E" if row["full_details"]["explicit"] else "#3182CE"
+ if not selected_node:
+ colors.append(base_color)
+ sizes.append(28)
+ text_colors.append("#2D3748")
+ elif node_id in highlight_nodes:
+ colors.append("#ED8936" if node_id != selected_node else "#3182CE")
+ sizes.append(34)
+ text_colors.append("#000000")
+ else:
+ colors.append("#E2E8F0")
+ sizes.append(20)
+ text_colors.append("#A0AEC0")
+
+ node_trace = go.Scatter(
+ x=node_x,
+ y=node_y,
+ mode="markers+text",
+ text=short_labels,
+ textposition="top center",
+ hovertext=hover_details,
+ hoverinfo="text",
+ customdata=custom_data,
+ marker=dict(showscale=False, color=colors, size=sizes, line=dict(width=2, color="white")),
+ textfont=dict(size=11, color=text_colors),
+ )
+
+ fig = go.Figure(
+ data=[edge_trace, node_trace],
+ layout=go.Layout(
+ title="Hierarchical Dependency Graph (Left-to-Right)",
+ showlegend=False,
+ hovermode="closest",
+ margin=dict(b=40, l=40, r=40, t=60),
+ xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
+ yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
+ plot_bgcolor="#F7FAFC",
+ ),
+ )
+
+ return fig
+
+
+@callback(
+ dict(
+ textarea=Output(ids.cargo_toml_textarea, "value"),
+ filename=Output(ids.cargo_toml_filename_display, "children"),
+ ),
+ dict(
+ upload_contents=Input(ids.upload_cargo_toml, "contents"),
+ ),
+ dict(
+ upload_filename=State(ids.upload_cargo_toml, "filename"),
+ ),
+)
+def upload_cargo_toml(inputs, state):
+ contents = inputs["upload_contents"]
+ if not contents:
+ raise PreventUpdate
+ content_type, content_string = contents.split(",")
+ decoded = base64.b64decode(content_string)
+ return dict(
+ textarea=decoded.decode("utf-8"),
+ filename=state["upload_filename"],
+ )
+
+
@callback(
dict(
textarea=Output(ids.cargo_lock_textarea, "value"),
@@ -74,22 +351,83 @@ def upload_cargo_lock(inputs, state):
@callback(
dict(
+ table_rows=Output(TableAIO.ids.ag_grid(ids.cargo_toml_table), "rowData"),
+ ),
+ dict(
+ textarea=Input(ids.cargo_toml_textarea, "value"),
+ recalculate=Input(ids.recalculate_button, "n_clicks"),
+ ),
+ dict(
+ upload_filename=State(ids.upload_cargo_toml, "filename"),
+ ),
+)
+def display_cargo_toml_packages(inputs, state):
+ try:
+ df = parse_cargo_toml(inputs["textarea"])
+ except tomllib.TOMLDecodeError as error:
+ raise PreventUpdate from error
+ return dict(
+ table_rows=df.to_dicts(),
+ )
+
+
+@callback(
+ dict(
table_rows=Output(TableAIO.ids.ag_grid(ids.cargo_lock_table), "rowData"),
),
dict(
textarea=Input(ids.cargo_lock_textarea, "value"),
+ recalculate=Input(ids.recalculate_button, "n_clicks"),
),
dict(
upload_filename=State(ids.upload_cargo_lock, "filename"),
),
)
-def visualize(inputs, state):
+def display_cargo_lock_packages(inputs, state):
try:
- toml = tomllib.loads(inputs["textarea"])
+ df = parse_cargo_lock(inputs["textarea"])
except tomllib.TOMLDecodeError as error:
raise PreventUpdate from error
- df = pl.DataFrame(toml.get("package", []))
- print(df)
return dict(
table_rows=df.to_dicts(),
)
+
+
+@callback(
+ dict(
+ graph=Output(ids.dependency_graph, "figure"),
+ ),
+ dict(
+ generate=Input(ids.generate_button, "n_clicks"),
+ click_data=Input(ids.dependency_graph, "clickData"),
+ reset_highlight_button=Input(ids.reset_highlight_button, "n_clicks"),
+ ),
+ dict(
+ cargo_toml=State(ids.cargo_toml_textarea, "value"),
+ cargo_lock=State(ids.cargo_lock_textarea, "value"),
+ ),
+)
+def visualize(inputs, state):
+ if not state["cargo_toml"] or not state["cargo_lock"]:
+ raise PreventUpdate
+ try:
+ cargo_toml = parse_cargo_toml(state["cargo_toml"])
+ cargo_lock = parse_cargo_lock(state["cargo_lock"])
+ except tomllib.TOMLDecodeError as error:
+ raise PreventUpdate from error
+
+ selected = None
+ if ctx.triggered_id and ctx.triggered_id == ids.reset_highlight_button:
+ selected = None
+ elif inputs["click_data"] and "points" in inputs["click_data"]:
+ point = inputs["click_data"]["points"][0]
+ if "customdata" in point:
+ selected = point["customdata"]
+
+ df_graph = parse_graph(cargo_toml, cargo_lock)
+ positions = compute_positions(df_graph)
+ fig = draw_graph_figure(df_graph, positions, selected)
+
+ return dict(
+ graph=fig,
+ )
diff --git a/uv.lock b/uv.lock
@@ -339,6 +339,7 @@ dependencies = [
{ name = "dash-ag-grid" },
{ name = "plotly" },
{ name = "polars" },
+ { name = "pydot" },
]
[package.dev-dependencies]
@@ -353,6 +354,7 @@ requires-dist = [
{ name = "dash-ag-grid", specifier = ">=35.2.0" },
{ name = "plotly", specifier = ">=6.7.0" },
{ name = "polars", specifier = ">=1.40.1" },
+ { name = "pydot", specifier = ">=4.0.1" },
]
[package.metadata.requires-dev]
@@ -1171,6 +1173,18 @@ wheels = [
]
[[package]]
+name = "pydot"
+version = "4.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyparsing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" },
+]
+
+[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
@@ -1180,6 +1194,15 @@ wheels = [
]
[[package]]
+name = "pyparsing"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
+]
+
+[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }