rsdeps

Cargo.lock visualizer (mirror)
Log | Files | Refs | README | LICENSE

commit cf3a1dda0282c09daee28f73b5c6e095dcf5eb78
parent 43abaaa91da3b19567afbd38551cefee92980289
Author: Andy Khramtsov <>
Date:   Wed, 27 May 2026 23:00:35 +0300

feat: visualize

Diffstat:
Mpyproject.toml | 1+
Msrc/deps/pages/home.py | 352+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Muv.lock | 23+++++++++++++++++++++++
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" }