rsdeps

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

commit 60d33fb3aa06056d0378370fccb9edd5bb87ef8f
parent 2a2d177536b4a808ab505235784e7321dcf41fc3
Author: Andy Khramtsov <>
Date:   Sat, 30 May 2026 13:46:27 +0300

chore: rename to rsdeps

Diffstat:
MContainerfile | 2+-
Mjustfile | 2+-
Mpyproject.toml | 2+-
Dsrc/deps/app_state.py | 10----------
Dsrc/deps/dash_app.py | 82-------------------------------------------------------------------------------
Dsrc/deps/pages/home.py | 788-------------------------------------------------------------------------------
Rsrc/deps/__init__.py -> src/rsdeps/__init__.py | 0
Rsrc/deps/aio_components/__init__.py -> src/rsdeps/aio_components/__init__.py | 0
Rsrc/deps/aio_components/collapse_aio.py -> src/rsdeps/aio_components/collapse_aio.py | 0
Rsrc/deps/aio_components/table_aio.py -> src/rsdeps/aio_components/table_aio.py | 0
Asrc/rsdeps/app_state.py | 10++++++++++
Rsrc/deps/assets/styles.css -> src/rsdeps/assets/styles.css | 0
Rsrc/deps/config.py -> src/rsdeps/config.py | 0
Asrc/rsdeps/dash_app.py | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/deps/pages/__init__.py -> src/rsdeps/pages/__init__.py | 0
Rsrc/deps/pages/about.py -> src/rsdeps/pages/about.py | 0
Asrc/rsdeps/pages/home.py | 788+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/deps/py.typed -> src/rsdeps/py.typed | 0
Muv.lock | 2+-
19 files changed, 884 insertions(+), 884 deletions(-)

diff --git a/Containerfile b/Containerfile @@ -25,5 +25,5 @@ ENV PYTHONUNBUFFERED=1 \ EXPOSE 8000 -CMD ["python", "src/deps/dash_app.py"] +CMD ["python", "src/rsdeps/dash_app.py"] diff --git a/justfile b/justfile @@ -24,7 +24,7 @@ jupyter: # Starts the app start: - uv run src/deps/dash_app.py --debug + uv run src/rsdeps/dash_app.py --debug # Alias to start run: start diff --git a/pyproject.toml b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "deps" +name = "rsdeps" version = "0.1.0" description = "Add your description here" readme = "README.md" diff --git a/src/deps/app_state.py b/src/deps/app_state.py @@ -1,10 +0,0 @@ -from deps.config import Config - - -class AppState: - """Immutable application state""" - - config: Config - - def __init__(self, config: Config) -> None: - self.config = config diff --git a/src/deps/dash_app.py b/src/deps/dash_app.py @@ -1,82 +0,0 @@ -import argparse - -import dash -from dash import Dash, dcc, html - -from deps.app_state import AppState -from deps.config import Config - - -def new_app_state() -> AppState: - config = Config() - return AppState(config=config) - - -def main(): - state = new_app_state() - - app = Dash(use_pages=True) - app.server.config["APP_STATE"] = state - - app.layout = html.Div( - className="vertical-content vertical-content_medium-gap", - children=[ - html.Div( - className=( - "padded-box horizontal-content horizontal-content_large-gap " - "horizontal-content_spread horizontal-content_center shadow" - ), - children=[ - html.Div( - className="horizontal-content horizontal-content_large-gap", - children=[ - html.Div(children="Cargo.lock visualizer"), - html.Div( - className="horizontal-content", - children=[ - html.Div( - dcc.Link( - f"{page['name']}", - className="link", - href=page["relative_path"], - ) - ) - for page in dash.page_registry.values() - ], - ), - ], - ), - ], - ), - dash.page_container, - ], - ) - - parser = argparse.ArgumentParser(description="Start Dash App") - parser.add_argument( - "--debug", - action="store_true", - help="Start Dash in debug mode", - ) - args = parser.parse_args() - - if args.debug: - app.run(debug=True) - else: - import uvicorn - from uvicorn.middleware.wsgi import WSGIMiddleware - - print("Starting production server via Uvicorn...", flush=True) - - wsgi_app = WSGIMiddleware(app.server) # ty: ignore - - uvicorn.run( - wsgi_app, - host="0.0.0.0", - port=8000, - log_level="info", - ) - - -if __name__ == "__main__": - main() diff --git a/src/deps/pages/home.py b/src/deps/pages/home.py @@ -1,788 +0,0 @@ -import base64 -import io -import tomllib -from dataclasses import dataclass - -import dash -import polars as pl -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.collapse_aio import CollapseAIO -from deps.aio_components.table_aio import TableAIO - -dash.register_page(__name__, path="/", order=1) - - -class ids: - cargo_toml_textarea: str = "cargo-toml-textarea" - cargo_lock_textarea: str = "cargo-lock-textarea" - cargo_toml_clear: str = "cargo-toml-clear" - cargo_lock_clear: str = "cargo-lock-clear" - - upload_files: str = "upload-files" - - collapse_tables: str = "collapse-tables" - 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" - selected_node_details: str = "selected-node-details" - - cache_store: str = "cache-store" - selected_node_store: str = "selected-node-store" - - -class colors: - background: str = "#FAFAFA" - outline: str = "#FFFFFF" - outline_highlight: str = "#808080" - node: str = "#B0D0F0" - node_main: str = "#F080A0" - node_faint: str = "#E0E0E0" - line: str = "#B0B0B0" - line_highlight: str = "#808080" - line_faint: str = "#E0E0E0" - text: str = "#202020" - text_faint: str = "#B0B0B0" - - -@dataclass -class DfGraph: - nodes: pl.DataFrame - arcs: pl.DataFrame - - -@dataclass -class EdgeFigData: - x: list[int | float | None] - y: list[int | float | None] - - -@dataclass -class NodeFigData: - x: list[int | float | None] - y: list[int | float | None] - color: list[str] - label: list[str] - label_color: list[str] - hover_text: list[str] - outline_color: list[str] - custom_data: list[str] - - -def default_figure_layout() -> go.Layout: - return go.Layout( - margin=go.layout.Margin(b=0, l=0, r=0, t=30), - xaxis=go.layout.XAxis(showgrid=False, zeroline=False, showticklabels=False), - yaxis=go.layout.YAxis(showgrid=False, zeroline=False, showticklabels=False), - plot_bgcolor=colors.background, - uirevision=0, - ) - - -def layout(): - return html.Div( - className="padded-box vertical-content vertical-content_large-gap", - children=[ - dcc.Store(id=ids.cache_store, storage_type="memory"), - dcc.Store(id=ids.selected_node_store, storage_type="memory"), - html.Div( - className="padded-box vertical-content shadow", - children=[ - html.Div( - className="horizontal-content horizontal-content_center", - children=[ - html.H3("Insert your lock file"), - html.Abbr( - "?", - title="Select Cargo.lock and optionally Cargo.toml to see the full dependency graph. " - "Multiple select avaliable for file upload. Or just paste the contents into the " - "fields.\n\n" - "It is possible to render the dependency graph of many projects because there is " - "only one Cargo.lock.\n\n" - "Note: Cargo.toml is only used for coloring [dependencies]. " - "The implementation of Cargo.toml parsing is primitive, it will not work on many " - "projects. But it is only the coloring feature, the graph is still fun to see.\n\n" - "Text fieldsare used as storage and the source of truth, they persist data in local " - "storage.", - className="help-icon", - ), - ], - ), - dcc.Upload( - className="button", - id=ids.upload_files, - children=[ - "Select Cargo.lock and optionally Cargo.toml", - ], - multiple=True, - ), - html.Div("Cargo.lock (required):"), - dcc.Textarea( - id=ids.cargo_lock_textarea, - placeholder="Cargo.lock contents", - persistence=True, - ), - html.Div( - html.Button( - className="button", - id=ids.cargo_lock_clear, - children="Clear Cargo.lock", - ), - ), - html.Div("Cargo.toml (optional):"), - dcc.Textarea( - id=ids.cargo_toml_textarea, - placeholder="Cargo.toml contents", - persistence=True, - ), - html.Div( - html.Button( - className="button", - id=ids.cargo_toml_clear, - children="Clear Cargo.toml", - ), - ), - ], - ), - CollapseAIO( - aio_id=ids.collapse_tables, - label="Table view", - default_hidden=True, - button_wrapper=lambda button: html.Div( - className="horizontal-content horizontal-content_center", - children=[ - button, - html.Abbr( - "?", - title="Cargo.toml and Cargo.lock dataframes in table form.\n" - "This is what gets rendered into the graph below.", - className="help-icon", - ), - ], - ), - content=[ - html.Div( - className="padded-box vertical-content shadow", - children=[ - html.Button( - className="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=[ - {"field": i, "colId": i} - for i in [ - "name", - "version", - "source", - "checksum", - "dependencies", - ] - ], - ), - ], - ) - ], - ), - html.Div( - className="padded-box vertical-content shadow", - children=[ - html.Div( - className="horizontal-content horizontal-content_center", - children=[ - html.H3("Dependency graph"), - html.Abbr( - "?", - title="Click on 'Generate' to generate Cargo.lock graph (it is normal for it to take " - "some time).\n\n" - "Graph is Left-to-Right, meaning nodes (packages) to the right are dependencies of " - "nodes (packages) to the left, if they are connected with an arc (edge).\n" - "Or you can think of the graph as if all the edges are directed only to the right or " - "only to the left, it doesn't matter.\n\n" - "Click on any node to see its neighbors. Click it again to deselect, or use the " - "'Deselect' button.\n\n" - "Packages from [dependencies] in Cargo.toml are colored red.", - className="help-icon", - ), - ], - ), - html.Div( - className="horizontal-content horizontal-content_small-gap", - children=[ - html.Button( - className="button", - id=ids.generate_button, - children="Generate", - ), - html.Button( - className="button", - id=ids.reset_highlight_button, - children="Deselect", - ), - ], - ), - html.Div( - [ - dcc.Loading( - dcc.Graph( - className="graph", - id=ids.dependency_graph, - figure=go.Figure( - layout=default_figure_layout(), - ), - ), - show_initially=False, - delay_show=500, - delay_hide=300, - fullscreen=True, - ), - html.Div(id=ids.selected_node_details), - ] - ), - ], - ), - ], - ) - - -def parse_node_details(details: dict) -> str: - details = {key: val for key, val in details.items()} - details["dependencies"] = "[" + ",".join([f"<br> {dep}" for dep in details["dependencies"] or []]) + "<br>]" - return "<br>".join([f"{key}: {val}" for key, val in details.items()]) - - -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)).match_to_schema( - pl.Schema( - dict( - name=pl.String, - ) - ), - missing_columns="insert", - extra_columns="ignore", - ) - - -def parse_cargo_lock(contents: str) -> pl.DataFrame: - toml = tomllib.loads(contents) - return pl.DataFrame(toml.get("package", [])).match_to_schema( - pl.Schema( - dict( - name=pl.String, - version=pl.String, - source=pl.String, - checksum=pl.String, - dependencies=pl.List(pl.String), - ) - ), - missing_columns=dict( - name=pl.lit(""), - version=pl.lit(""), - source=pl.lit(""), - checksum=pl.lit(""), - dependencies=pl.lit([], dtype=pl.List(pl.String)), - ), - extra_columns="ignore", - ) - - -def files_into_graph(cargo_toml: pl.DataFrame | None, cargo_lock: pl.DataFrame) -> DfGraph: - 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"), - ) - ) - - nodes = cargo_lock.join( - cargo_toml.select( - pl.col("name"), - pl.lit(True).alias("explicit"), - ) - if cargo_toml is not None - else pl.DataFrame(dict(name=[])), - 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 DfGraph( - nodes=nodes, - arcs=arcs, - ) - - -def compute_node_positions(df_graph: DfGraph) -> dict[str, tuple[int | float, int | float]]: - def compute_graph(df_graph: DfGraph) -> 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): - if row["target"] is None: - continue - 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 compute_adjacency_list(df_graph: DfGraph) -> dict[str, set[str]]: - adjacency_list = {str(node): set() for node in df_graph.nodes["node_id"]} - - for row in df_graph.arcs.iter_rows(named=True): - if not row["target"]: - continue - source, target = str(row["source"]), str(row["target"]) - adjacency_list[source].add(target) - adjacency_list[target].add(source) - - return adjacency_list - - -def wrap_adjacency_list(adjacency_list: dict[str, set[str]]) -> dict[str, list[str]]: - return {node: list(neighbors) for node, neighbors in adjacency_list.items()} - - -def unwrap_adjacency_list(adjacency_list: dict[str, list[str]]) -> dict[str, set[str]]: - return {node: set(neighbors) for node, neighbors in adjacency_list.items()} - - -def draw_graph_figure( - df_graph: DfGraph, - positions: dict[str, tuple[float, float]], - adjacency_list: dict[str, set[str]], - selected_node=None, -) -> go.Figure: - highlight_nodes = set() - if selected_node and selected_node in adjacency_list: - highlight_nodes.add(selected_node) - highlight_nodes.update(adjacency_list[selected_node]) - - edges = EdgeFigData( - x=[], - y=[], - ) - - edges_highlight = EdgeFigData( - x=[], - y=[], - ) - - for row in df_graph.arcs.iter_rows(named=True): - if not row["target"]: - continue - source, target = str(row["source"]), str(row["target"]) - if source in positions and target in positions: - source_x, source_y = positions[source] - target_x, target_y = positions[target] - if ( - source in highlight_nodes - and target in highlight_nodes - and (target == selected_node or source == selected_node) - ): - edges_highlight.x.extend([source_x, target_x, None]) - edges_highlight.y.extend([source_y, target_y, None]) - else: - edges.x.extend([source_x, target_x, None]) - edges.y.extend([source_y, target_y, None]) - - def build_line_scatter(edge: EdgeFigData, width: float, color: str) -> go.Scatter: - return go.Scatter(x=edge.x, y=edge.y, line=dict(width=width, color=color), hoverinfo="skip", mode="lines") - - edge_scatter = build_line_scatter(edges, width=1.0, color=colors.line_faint if selected_node else colors.line) - edge_scatter_highlight = build_line_scatter(edges_highlight, width=1.5, color=colors.line_highlight) - - nodes = NodeFigData( - x=[], - y=[], - color=[], - label=[], - label_color=[], - hover_text=[], - outline_color=[], - custom_data=[], - ) - - nodes_highlight = NodeFigData( - x=[], - y=[], - color=[], - label=[], - label_color=[], - hover_text=[], - outline_color=[], - custom_data=[], - ) - - for row in df_graph.nodes.iter_rows(named=True): - node_id = row["node_id"] - - if node_id not in positions: - continue - - nodes_appending = nodes if (not selected_node) or (node_id not in highlight_nodes) else nodes_highlight - - x, y = positions[node_id] - nodes_appending.x.append(x) - nodes_appending.y.append(y) - nodes_appending.label.append(row["short_label"]) - nodes_appending.hover_text.append(parse_node_details(row["full_details"])) - nodes_appending.custom_data.append(node_id) - - if (not selected_node) or (node_id in highlight_nodes): - nodes_appending.color.append(colors.node_main if row["full_details"]["explicit"] else colors.node) - nodes_appending.label_color.append(colors.text) - nodes_appending.outline_color.append( - colors.outline_highlight if node_id == selected_node else colors.outline - ) - else: - nodes_appending.color.append(colors.node_faint) - nodes_appending.label_color.append(colors.text_faint) - nodes_appending.outline_color.append(colors.outline) - - def build_node_scatter(node: NodeFigData) -> go.Scatter: - return go.Scatter( - x=node.x, - y=node.y, - text=node.label, - hovertext=node.hover_text, - customdata=node.custom_data, - marker=go.scatter.Marker( - showscale=False, - color=node.color, - size=16, - line=go.scatter.marker.Line(width=1, color=node.outline_color), - ), - textfont=go.scatter.Textfont( - size=10, - color=node.label_color, - shadow=( - f"-1px -1px {colors.outline}," - f"1px -1px {colors.outline}," - f"-1px 1px {colors.outline}," - f"1px 1px {colors.outline}" - ), - ), - mode="markers+text", - textposition="middle left", - hoverinfo="text", - ) - - node_scatter = build_node_scatter(nodes) - node_scatter_highlight = build_node_scatter(nodes_highlight) - - fig = go.Figure( - data=[ - edge_scatter, - node_scatter, - edge_scatter_highlight, - node_scatter_highlight, - ], - layout=go.Layout( - **default_figure_layout().to_plotly_json(), - showlegend=False, - hovermode="closest", - hoverlabel=go.layout.Hoverlabel( - bgcolor=colors.background, - ), - ), - ) - - return fig - - -@callback( - dict( - cargo_toml_textarea=Output(ids.cargo_toml_textarea, "value", allow_duplicate=True), - cargo_lock_textarea=Output(ids.cargo_lock_textarea, "value", allow_duplicate=True), - ), - dict( - contents=Input(ids.upload_files, "contents"), - filename=Input(ids.upload_files, "filename"), - ), - dict(), - prevent_initial_call=True, -) -def upload_files(inputs, state): - contents: list[str] = inputs["contents"] - filename: list[str] = inputs["filename"] - - if not contents or not filename: - raise PreventUpdate - - try: - cargo_toml_index = filename.index("Cargo.toml") - _, cargo_toml_content_string = contents[cargo_toml_index].split(",") - except ValueError: - cargo_toml_content_string = None - - try: - cargo_lock_index = filename.index("Cargo.lock") - _, cargo_lock_content_string = contents[cargo_lock_index].split(",") - except ValueError: - cargo_lock_content_string = None - - cargo_toml_decoded = ( - base64.b64decode(cargo_toml_content_string).decode("utf-8") if cargo_toml_content_string else dash.no_update - ) - cargo_lock_decoded = ( - base64.b64decode(cargo_lock_content_string).decode("utf-8") if cargo_lock_content_string else dash.no_update - ) - - return dict( - cargo_toml_textarea=cargo_toml_decoded, - cargo_lock_textarea=cargo_lock_decoded, - ) - - -@callback( - dict( - cargo_toml_textarea=Output(ids.cargo_toml_textarea, "value", allow_duplicate=True), - ), - dict( - clean=Input(ids.cargo_toml_clear, "n_clicks"), - ), - dict(), - prevent_initial_call=True, -) -def clean_cargo_toml(inputs, state): - return dict(cargo_toml_textarea="") - - -@callback( - dict( - cargo_lock_textarea=Output(ids.cargo_lock_textarea, "value", allow_duplicate=True), - ), - dict( - clean=Input(ids.cargo_lock_clear, "n_clicks"), - ), - dict(), - prevent_initial_call=True, -) -def clean_cargo_lock(inputs, state): - return dict(cargo_lock_textarea="") - - -@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(), -) -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(), -) -def display_cargo_lock_packages(inputs, state): - try: - df = parse_cargo_lock(inputs["textarea"]) - except tomllib.TOMLDecodeError as error: - raise PreventUpdate from error - return dict( - table_rows=df.to_dicts(), - ) - - -@callback( - dict( - graph=Output(ids.dependency_graph, "figure"), - cache=Output(ids.cache_store, "data"), - selected_node_store=Output(ids.selected_node_store, "data"), - details=Output(ids.selected_node_details, "children"), - ), - 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"), - cache=State(ids.cache_store, "data"), - selected_node_store=State(ids.selected_node_store, "data"), - figure=State(ids.dependency_graph, "figure"), - ), - prevent_initial_call=True, -) -def visualize(inputs, state): - pressed_generate = bool(ctx.triggered_id and ctx.triggered_id == ids.generate_button) - invalid_cache = bool( - not state["cache"] - or state["cache"].get("positions") is None - or state["cache"].get("df_graph_nodes") is None - or state["cache"].get("df_graph_arcs") is None - or state["cache"].get("adjacency_list") is None - ) - - clean = pressed_generate or invalid_cache - - if ctx.triggered_id and ctx.triggered_id == ids.reset_highlight_button: - selected = None - elif inputs["click_data"] and "points" in inputs["click_data"] and not clean: - point = inputs["click_data"]["points"][0] - selected = point.get("customdata", None) - else: - selected = None - if state["selected_node_store"] and state["selected_node_store"].get("selected") == selected: - selected = None - - if clean: - if not state["cargo_lock"]: - raise PreventUpdate - try: - cargo_toml = parse_cargo_toml(state["cargo_toml"]) - except tomllib.TOMLDecodeError: - cargo_toml = None - try: - cargo_lock = parse_cargo_lock(state["cargo_lock"]) - except tomllib.TOMLDecodeError as error: - raise PreventUpdate from error - df_graph = files_into_graph(cargo_toml, cargo_lock) - positions = compute_node_positions(df_graph) - adjacency_list = compute_adjacency_list(df_graph) - cache = { - "positions": positions, - "adjacency_list": wrap_adjacency_list(adjacency_list), - "df_graph_nodes": df_graph.nodes.serialize(format="json"), - "df_graph_arcs": df_graph.arcs.serialize(format="json"), - } - else: - positions = state["cache"]["positions"] - adjacency_list = unwrap_adjacency_list(state["cache"]["adjacency_list"]) - df_graph = DfGraph( - nodes=pl.DataFrame.deserialize(io.StringIO(state["cache"]["df_graph_nodes"]), format="json"), - arcs=pl.DataFrame.deserialize(io.StringIO(state["cache"]["df_graph_arcs"]), format="json"), - ) - cache = dash.no_update - - details = [ - item - for part in ( - parse_node_details(df_graph.nodes.filter(pl.col("node_id") == selected).to_dicts()[0]["full_details"]) - if selected - else "" - ).split("<br>") - for item in (part, html.Br()) - ][:-1] - - old_fig = state["figure"] - old_uirevision = old_fig.get("layout", {}).get("uirevision", 0) - - fig = draw_graph_figure(df_graph, positions, adjacency_list, selected) - fig.update_layout( - uirevision=old_uirevision + 1 if clean else old_uirevision, - ) - - return dict( - graph=fig, - cache=cache, - details=details, - selected_node_store=dict(selected=selected), - ) diff --git a/src/deps/__init__.py b/src/rsdeps/__init__.py diff --git a/src/deps/aio_components/__init__.py b/src/rsdeps/aio_components/__init__.py diff --git a/src/deps/aio_components/collapse_aio.py b/src/rsdeps/aio_components/collapse_aio.py diff --git a/src/deps/aio_components/table_aio.py b/src/rsdeps/aio_components/table_aio.py diff --git a/src/rsdeps/app_state.py b/src/rsdeps/app_state.py @@ -0,0 +1,10 @@ +from rsdeps.config import Config + + +class AppState: + """Immutable application state""" + + config: Config + + def __init__(self, config: Config) -> None: + self.config = config diff --git a/src/deps/assets/styles.css b/src/rsdeps/assets/styles.css diff --git a/src/deps/config.py b/src/rsdeps/config.py diff --git a/src/rsdeps/dash_app.py b/src/rsdeps/dash_app.py @@ -0,0 +1,82 @@ +import argparse + +import dash +from dash import Dash, dcc, html + +from rsdeps.app_state import AppState +from rsdeps.config import Config + + +def new_app_state() -> AppState: + config = Config() + return AppState(config=config) + + +def main(): + state = new_app_state() + + app = Dash(use_pages=True) + app.server.config["APP_STATE"] = state + + app.layout = html.Div( + className="vertical-content vertical-content_medium-gap", + children=[ + html.Div( + className=( + "padded-box horizontal-content horizontal-content_large-gap " + "horizontal-content_spread horizontal-content_center shadow" + ), + children=[ + html.Div( + className="horizontal-content horizontal-content_large-gap", + children=[ + html.Div(children="Cargo.lock visualizer"), + html.Div( + className="horizontal-content", + children=[ + html.Div( + dcc.Link( + f"{page['name']}", + className="link", + href=page["relative_path"], + ) + ) + for page in dash.page_registry.values() + ], + ), + ], + ), + ], + ), + dash.page_container, + ], + ) + + parser = argparse.ArgumentParser(description="Start Dash App") + parser.add_argument( + "--debug", + action="store_true", + help="Start Dash in debug mode", + ) + args = parser.parse_args() + + if args.debug: + app.run(debug=True) + else: + import uvicorn + from uvicorn.middleware.wsgi import WSGIMiddleware + + print("Starting production server via Uvicorn...", flush=True) + + wsgi_app = WSGIMiddleware(app.server) # ty: ignore + + uvicorn.run( + wsgi_app, + host="0.0.0.0", + port=8000, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/src/deps/pages/__init__.py b/src/rsdeps/pages/__init__.py diff --git a/src/deps/pages/about.py b/src/rsdeps/pages/about.py diff --git a/src/rsdeps/pages/home.py b/src/rsdeps/pages/home.py @@ -0,0 +1,788 @@ +import base64 +import io +import tomllib +from dataclasses import dataclass + +import dash +import polars as pl +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 rsdeps.aio_components.collapse_aio import CollapseAIO +from rsdeps.aio_components.table_aio import TableAIO + +dash.register_page(__name__, path="/", order=1) + + +class ids: + cargo_toml_textarea: str = "cargo-toml-textarea" + cargo_lock_textarea: str = "cargo-lock-textarea" + cargo_toml_clear: str = "cargo-toml-clear" + cargo_lock_clear: str = "cargo-lock-clear" + + upload_files: str = "upload-files" + + collapse_tables: str = "collapse-tables" + 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" + selected_node_details: str = "selected-node-details" + + cache_store: str = "cache-store" + selected_node_store: str = "selected-node-store" + + +class colors: + background: str = "#FAFAFA" + outline: str = "#FFFFFF" + outline_highlight: str = "#808080" + node: str = "#B0D0F0" + node_main: str = "#F080A0" + node_faint: str = "#E0E0E0" + line: str = "#B0B0B0" + line_highlight: str = "#808080" + line_faint: str = "#E0E0E0" + text: str = "#202020" + text_faint: str = "#B0B0B0" + + +@dataclass +class DfGraph: + nodes: pl.DataFrame + arcs: pl.DataFrame + + +@dataclass +class EdgeFigData: + x: list[int | float | None] + y: list[int | float | None] + + +@dataclass +class NodeFigData: + x: list[int | float | None] + y: list[int | float | None] + color: list[str] + label: list[str] + label_color: list[str] + hover_text: list[str] + outline_color: list[str] + custom_data: list[str] + + +def default_figure_layout() -> go.Layout: + return go.Layout( + margin=go.layout.Margin(b=0, l=0, r=0, t=30), + xaxis=go.layout.XAxis(showgrid=False, zeroline=False, showticklabels=False), + yaxis=go.layout.YAxis(showgrid=False, zeroline=False, showticklabels=False), + plot_bgcolor=colors.background, + uirevision=0, + ) + + +def layout(): + return html.Div( + className="padded-box vertical-content vertical-content_large-gap", + children=[ + dcc.Store(id=ids.cache_store, storage_type="memory"), + dcc.Store(id=ids.selected_node_store, storage_type="memory"), + html.Div( + className="padded-box vertical-content shadow", + children=[ + html.Div( + className="horizontal-content horizontal-content_center", + children=[ + html.H3("Insert your lock file"), + html.Abbr( + "?", + title="Select Cargo.lock and optionally Cargo.toml to see the full dependency graph. " + "Multiple select avaliable for file upload. Or just paste the contents into the " + "fields.\n\n" + "It is possible to render the dependency graph of many projects because there is " + "only one Cargo.lock.\n\n" + "Note: Cargo.toml is only used for coloring [dependencies]. " + "The implementation of Cargo.toml parsing is primitive, it will not work on many " + "projects. But it is only the coloring feature, the graph is still fun to see.\n\n" + "Text fieldsare used as storage and the source of truth, they persist data in local " + "storage.", + className="help-icon", + ), + ], + ), + dcc.Upload( + className="button", + id=ids.upload_files, + children=[ + "Select Cargo.lock and optionally Cargo.toml", + ], + multiple=True, + ), + html.Div("Cargo.lock (required):"), + dcc.Textarea( + id=ids.cargo_lock_textarea, + placeholder="Cargo.lock contents", + persistence=True, + ), + html.Div( + html.Button( + className="button", + id=ids.cargo_lock_clear, + children="Clear Cargo.lock", + ), + ), + html.Div("Cargo.toml (optional):"), + dcc.Textarea( + id=ids.cargo_toml_textarea, + placeholder="Cargo.toml contents", + persistence=True, + ), + html.Div( + html.Button( + className="button", + id=ids.cargo_toml_clear, + children="Clear Cargo.toml", + ), + ), + ], + ), + CollapseAIO( + aio_id=ids.collapse_tables, + label="Table view", + default_hidden=True, + button_wrapper=lambda button: html.Div( + className="horizontal-content horizontal-content_center", + children=[ + button, + html.Abbr( + "?", + title="Cargo.toml and Cargo.lock dataframes in table form.\n" + "This is what gets rendered into the graph below.", + className="help-icon", + ), + ], + ), + content=[ + html.Div( + className="padded-box vertical-content shadow", + children=[ + html.Button( + className="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=[ + {"field": i, "colId": i} + for i in [ + "name", + "version", + "source", + "checksum", + "dependencies", + ] + ], + ), + ], + ) + ], + ), + html.Div( + className="padded-box vertical-content shadow", + children=[ + html.Div( + className="horizontal-content horizontal-content_center", + children=[ + html.H3("Dependency graph"), + html.Abbr( + "?", + title="Click on 'Generate' to generate Cargo.lock graph (it is normal for it to take " + "some time).\n\n" + "Graph is Left-to-Right, meaning nodes (packages) to the right are dependencies of " + "nodes (packages) to the left, if they are connected with an arc (edge).\n" + "Or you can think of the graph as if all the edges are directed only to the right or " + "only to the left, it doesn't matter.\n\n" + "Click on any node to see its neighbors. Click it again to deselect, or use the " + "'Deselect' button.\n\n" + "Packages from [dependencies] in Cargo.toml are colored red.", + className="help-icon", + ), + ], + ), + html.Div( + className="horizontal-content horizontal-content_small-gap", + children=[ + html.Button( + className="button", + id=ids.generate_button, + children="Generate", + ), + html.Button( + className="button", + id=ids.reset_highlight_button, + children="Deselect", + ), + ], + ), + html.Div( + [ + dcc.Loading( + dcc.Graph( + className="graph", + id=ids.dependency_graph, + figure=go.Figure( + layout=default_figure_layout(), + ), + ), + show_initially=False, + delay_show=500, + delay_hide=300, + fullscreen=True, + ), + html.Div(id=ids.selected_node_details), + ] + ), + ], + ), + ], + ) + + +def parse_node_details(details: dict) -> str: + details = {key: val for key, val in details.items()} + details["dependencies"] = "[" + ",".join([f"<br> {dep}" for dep in details["dependencies"] or []]) + "<br>]" + return "<br>".join([f"{key}: {val}" for key, val in details.items()]) + + +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)).match_to_schema( + pl.Schema( + dict( + name=pl.String, + ) + ), + missing_columns="insert", + extra_columns="ignore", + ) + + +def parse_cargo_lock(contents: str) -> pl.DataFrame: + toml = tomllib.loads(contents) + return pl.DataFrame(toml.get("package", [])).match_to_schema( + pl.Schema( + dict( + name=pl.String, + version=pl.String, + source=pl.String, + checksum=pl.String, + dependencies=pl.List(pl.String), + ) + ), + missing_columns=dict( + name=pl.lit(""), + version=pl.lit(""), + source=pl.lit(""), + checksum=pl.lit(""), + dependencies=pl.lit([], dtype=pl.List(pl.String)), + ), + extra_columns="ignore", + ) + + +def files_into_graph(cargo_toml: pl.DataFrame | None, cargo_lock: pl.DataFrame) -> DfGraph: + 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"), + ) + ) + + nodes = cargo_lock.join( + cargo_toml.select( + pl.col("name"), + pl.lit(True).alias("explicit"), + ) + if cargo_toml is not None + else pl.DataFrame(dict(name=[])), + 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 DfGraph( + nodes=nodes, + arcs=arcs, + ) + + +def compute_node_positions(df_graph: DfGraph) -> dict[str, tuple[int | float, int | float]]: + def compute_graph(df_graph: DfGraph) -> 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): + if row["target"] is None: + continue + 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 compute_adjacency_list(df_graph: DfGraph) -> dict[str, set[str]]: + adjacency_list = {str(node): set() for node in df_graph.nodes["node_id"]} + + for row in df_graph.arcs.iter_rows(named=True): + if not row["target"]: + continue + source, target = str(row["source"]), str(row["target"]) + adjacency_list[source].add(target) + adjacency_list[target].add(source) + + return adjacency_list + + +def wrap_adjacency_list(adjacency_list: dict[str, set[str]]) -> dict[str, list[str]]: + return {node: list(neighbors) for node, neighbors in adjacency_list.items()} + + +def unwrap_adjacency_list(adjacency_list: dict[str, list[str]]) -> dict[str, set[str]]: + return {node: set(neighbors) for node, neighbors in adjacency_list.items()} + + +def draw_graph_figure( + df_graph: DfGraph, + positions: dict[str, tuple[float, float]], + adjacency_list: dict[str, set[str]], + selected_node=None, +) -> go.Figure: + highlight_nodes = set() + if selected_node and selected_node in adjacency_list: + highlight_nodes.add(selected_node) + highlight_nodes.update(adjacency_list[selected_node]) + + edges = EdgeFigData( + x=[], + y=[], + ) + + edges_highlight = EdgeFigData( + x=[], + y=[], + ) + + for row in df_graph.arcs.iter_rows(named=True): + if not row["target"]: + continue + source, target = str(row["source"]), str(row["target"]) + if source in positions and target in positions: + source_x, source_y = positions[source] + target_x, target_y = positions[target] + if ( + source in highlight_nodes + and target in highlight_nodes + and (target == selected_node or source == selected_node) + ): + edges_highlight.x.extend([source_x, target_x, None]) + edges_highlight.y.extend([source_y, target_y, None]) + else: + edges.x.extend([source_x, target_x, None]) + edges.y.extend([source_y, target_y, None]) + + def build_line_scatter(edge: EdgeFigData, width: float, color: str) -> go.Scatter: + return go.Scatter(x=edge.x, y=edge.y, line=dict(width=width, color=color), hoverinfo="skip", mode="lines") + + edge_scatter = build_line_scatter(edges, width=1.0, color=colors.line_faint if selected_node else colors.line) + edge_scatter_highlight = build_line_scatter(edges_highlight, width=1.5, color=colors.line_highlight) + + nodes = NodeFigData( + x=[], + y=[], + color=[], + label=[], + label_color=[], + hover_text=[], + outline_color=[], + custom_data=[], + ) + + nodes_highlight = NodeFigData( + x=[], + y=[], + color=[], + label=[], + label_color=[], + hover_text=[], + outline_color=[], + custom_data=[], + ) + + for row in df_graph.nodes.iter_rows(named=True): + node_id = row["node_id"] + + if node_id not in positions: + continue + + nodes_appending = nodes if (not selected_node) or (node_id not in highlight_nodes) else nodes_highlight + + x, y = positions[node_id] + nodes_appending.x.append(x) + nodes_appending.y.append(y) + nodes_appending.label.append(row["short_label"]) + nodes_appending.hover_text.append(parse_node_details(row["full_details"])) + nodes_appending.custom_data.append(node_id) + + if (not selected_node) or (node_id in highlight_nodes): + nodes_appending.color.append(colors.node_main if row["full_details"]["explicit"] else colors.node) + nodes_appending.label_color.append(colors.text) + nodes_appending.outline_color.append( + colors.outline_highlight if node_id == selected_node else colors.outline + ) + else: + nodes_appending.color.append(colors.node_faint) + nodes_appending.label_color.append(colors.text_faint) + nodes_appending.outline_color.append(colors.outline) + + def build_node_scatter(node: NodeFigData) -> go.Scatter: + return go.Scatter( + x=node.x, + y=node.y, + text=node.label, + hovertext=node.hover_text, + customdata=node.custom_data, + marker=go.scatter.Marker( + showscale=False, + color=node.color, + size=16, + line=go.scatter.marker.Line(width=1, color=node.outline_color), + ), + textfont=go.scatter.Textfont( + size=10, + color=node.label_color, + shadow=( + f"-1px -1px {colors.outline}," + f"1px -1px {colors.outline}," + f"-1px 1px {colors.outline}," + f"1px 1px {colors.outline}" + ), + ), + mode="markers+text", + textposition="middle left", + hoverinfo="text", + ) + + node_scatter = build_node_scatter(nodes) + node_scatter_highlight = build_node_scatter(nodes_highlight) + + fig = go.Figure( + data=[ + edge_scatter, + node_scatter, + edge_scatter_highlight, + node_scatter_highlight, + ], + layout=go.Layout( + **default_figure_layout().to_plotly_json(), + showlegend=False, + hovermode="closest", + hoverlabel=go.layout.Hoverlabel( + bgcolor=colors.background, + ), + ), + ) + + return fig + + +@callback( + dict( + cargo_toml_textarea=Output(ids.cargo_toml_textarea, "value", allow_duplicate=True), + cargo_lock_textarea=Output(ids.cargo_lock_textarea, "value", allow_duplicate=True), + ), + dict( + contents=Input(ids.upload_files, "contents"), + filename=Input(ids.upload_files, "filename"), + ), + dict(), + prevent_initial_call=True, +) +def upload_files(inputs, state): + contents: list[str] = inputs["contents"] + filename: list[str] = inputs["filename"] + + if not contents or not filename: + raise PreventUpdate + + try: + cargo_toml_index = filename.index("Cargo.toml") + _, cargo_toml_content_string = contents[cargo_toml_index].split(",") + except ValueError: + cargo_toml_content_string = None + + try: + cargo_lock_index = filename.index("Cargo.lock") + _, cargo_lock_content_string = contents[cargo_lock_index].split(",") + except ValueError: + cargo_lock_content_string = None + + cargo_toml_decoded = ( + base64.b64decode(cargo_toml_content_string).decode("utf-8") if cargo_toml_content_string else dash.no_update + ) + cargo_lock_decoded = ( + base64.b64decode(cargo_lock_content_string).decode("utf-8") if cargo_lock_content_string else dash.no_update + ) + + return dict( + cargo_toml_textarea=cargo_toml_decoded, + cargo_lock_textarea=cargo_lock_decoded, + ) + + +@callback( + dict( + cargo_toml_textarea=Output(ids.cargo_toml_textarea, "value", allow_duplicate=True), + ), + dict( + clean=Input(ids.cargo_toml_clear, "n_clicks"), + ), + dict(), + prevent_initial_call=True, +) +def clean_cargo_toml(inputs, state): + return dict(cargo_toml_textarea="") + + +@callback( + dict( + cargo_lock_textarea=Output(ids.cargo_lock_textarea, "value", allow_duplicate=True), + ), + dict( + clean=Input(ids.cargo_lock_clear, "n_clicks"), + ), + dict(), + prevent_initial_call=True, +) +def clean_cargo_lock(inputs, state): + return dict(cargo_lock_textarea="") + + +@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(), +) +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(), +) +def display_cargo_lock_packages(inputs, state): + try: + df = parse_cargo_lock(inputs["textarea"]) + except tomllib.TOMLDecodeError as error: + raise PreventUpdate from error + return dict( + table_rows=df.to_dicts(), + ) + + +@callback( + dict( + graph=Output(ids.dependency_graph, "figure"), + cache=Output(ids.cache_store, "data"), + selected_node_store=Output(ids.selected_node_store, "data"), + details=Output(ids.selected_node_details, "children"), + ), + 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"), + cache=State(ids.cache_store, "data"), + selected_node_store=State(ids.selected_node_store, "data"), + figure=State(ids.dependency_graph, "figure"), + ), + prevent_initial_call=True, +) +def visualize(inputs, state): + pressed_generate = bool(ctx.triggered_id and ctx.triggered_id == ids.generate_button) + invalid_cache = bool( + not state["cache"] + or state["cache"].get("positions") is None + or state["cache"].get("df_graph_nodes") is None + or state["cache"].get("df_graph_arcs") is None + or state["cache"].get("adjacency_list") is None + ) + + clean = pressed_generate or invalid_cache + + if ctx.triggered_id and ctx.triggered_id == ids.reset_highlight_button: + selected = None + elif inputs["click_data"] and "points" in inputs["click_data"] and not clean: + point = inputs["click_data"]["points"][0] + selected = point.get("customdata", None) + else: + selected = None + if state["selected_node_store"] and state["selected_node_store"].get("selected") == selected: + selected = None + + if clean: + if not state["cargo_lock"]: + raise PreventUpdate + try: + cargo_toml = parse_cargo_toml(state["cargo_toml"]) + except tomllib.TOMLDecodeError: + cargo_toml = None + try: + cargo_lock = parse_cargo_lock(state["cargo_lock"]) + except tomllib.TOMLDecodeError as error: + raise PreventUpdate from error + df_graph = files_into_graph(cargo_toml, cargo_lock) + positions = compute_node_positions(df_graph) + adjacency_list = compute_adjacency_list(df_graph) + cache = { + "positions": positions, + "adjacency_list": wrap_adjacency_list(adjacency_list), + "df_graph_nodes": df_graph.nodes.serialize(format="json"), + "df_graph_arcs": df_graph.arcs.serialize(format="json"), + } + else: + positions = state["cache"]["positions"] + adjacency_list = unwrap_adjacency_list(state["cache"]["adjacency_list"]) + df_graph = DfGraph( + nodes=pl.DataFrame.deserialize(io.StringIO(state["cache"]["df_graph_nodes"]), format="json"), + arcs=pl.DataFrame.deserialize(io.StringIO(state["cache"]["df_graph_arcs"]), format="json"), + ) + cache = dash.no_update + + details = [ + item + for part in ( + parse_node_details(df_graph.nodes.filter(pl.col("node_id") == selected).to_dicts()[0]["full_details"]) + if selected + else "" + ).split("<br>") + for item in (part, html.Br()) + ][:-1] + + old_fig = state["figure"] + old_uirevision = old_fig.get("layout", {}).get("uirevision", 0) + + fig = draw_graph_figure(df_graph, positions, adjacency_list, selected) + fig.update_layout( + uirevision=old_uirevision + 1 if clean else old_uirevision, + ) + + return dict( + graph=fig, + cache=cache, + details=details, + selected_node_store=dict(selected=selected), + ) diff --git a/src/deps/py.typed b/src/rsdeps/py.typed diff --git a/uv.lock b/uv.lock @@ -331,7 +331,7 @@ wheels = [ ] [[package]] -name = "deps" +name = "rsdeps" version = "0.1.0" source = { editable = "." } dependencies = [