From 121a839d74010f350875ad1cb67b466a9b17a109 Mon Sep 17 00:00:00 2001 From: Florian Huber Date: Wed, 29 Apr 2026 17:00:35 +0200 Subject: [PATCH 1/3] add cytoscape exporter --- graphconstructor/graph.py | 123 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/graphconstructor/graph.py b/graphconstructor/graph.py index a6a4d5e..94a0b6b 100644 --- a/graphconstructor/graph.py +++ b/graphconstructor/graph.py @@ -1,3 +1,4 @@ +import json from dataclasses import dataclass from typing import Iterable, Literal, Optional, Sequence import numpy as np @@ -512,6 +513,128 @@ def to_graphml(self, path, *, include_graph_attrs: bool = True) -> None: nx.write_graphml(G_nx, path) + def to_cytoscape( + self, + path=None, + *, + include_graph_attrs: bool = True, + node_id_col: str | None = None, + node_label_col: str | None = "name", + edge_weight_attr: str = "weight", + ) -> dict: + """ + Export the graph in Cytoscape.js JSON format. + + Parameters + ---------- + path + Optional output path. If provided, the Cytoscape JSON is written to + this file. If None, the JSON-compatible dictionary is returned only. + include_graph_attrs + If True, include graph-level metadata under ``data``. + node_id_col + Optional metadata column to use as Cytoscape node IDs. + If None, integer node indices are used. + node_label_col + Optional metadata column to use as node labels. By default, uses + ``"name"`` if present. Set to None to omit labels. + edge_weight_attr + Name of the edge weight attribute. Defaults to ``"weight"``. + + Returns + ------- + dict + Cytoscape.js-compatible JSON dictionary with ``elements.nodes`` and + ``elements.edges``. + + Notes + ----- + The output format is suitable for Cytoscape.js and can also be imported + by Cytoscape Desktop via JSON import workflows. + """ + + if node_id_col is not None: + if self.meta is None or node_id_col not in self.meta.columns: + raise KeyError(f"Column '{node_id_col}' not found in metadata.") + node_ids = self.meta[node_id_col].astype(str).tolist() + else: + node_ids = [str(i) for i in range(self.n_nodes)] + + if len(set(node_ids)) != len(node_ids): + raise ValueError("Cytoscape node IDs must be unique.") + + nodes = [] + for i, node_id in enumerate(node_ids): + data = { + "id": node_id, + "index": int(i), + } + + if ( + node_label_col is not None + and self.meta is not None + and node_label_col in self.meta.columns + ): + data["label"] = self.meta.iloc[i][node_label_col] + + if self.meta is not None: + for col in self.meta.columns: + value = self.meta.iloc[i][col] + if pd.isna(value): + value = None + data[col] = value + + nodes.append({"data": data}) + + coo = self.adj.tocoo() + + if self.directed: + edge_mask = coo.row != coo.col + else: + # Store each undirected edge only once. + edge_mask = coo.row < coo.col + + rows = coo.row[edge_mask] + cols = coo.col[edge_mask] + values = coo.data[edge_mask] + + edges = [] + for edge_idx, (src, dst, value) in enumerate(zip(rows, cols, values)): + data = { + "id": f"e{edge_idx}", + "source": node_ids[int(src)], + "target": node_ids[int(dst)], + } + + if self.weighted: + data[edge_weight_attr] = float(value) + else: + data[edge_weight_attr] = 1.0 + + edges.append({"data": data}) + + cytoscape = { + "elements": { + "nodes": nodes, + "edges": edges, + } + } + + if include_graph_attrs: + cytoscape["data"] = { + "directed": bool(self.directed), + "weighted": bool(self.weighted), + "mode": self.mode, + "ignore_selfloops": self.ignore_selfloops, + "keep_explicit_zeros": self.keep_explicit_zeros, + } + + if path is not None: + with open(path, "w", encoding="utf-8") as f: + json.dump(cytoscape, f, indent=2) + + return cytoscape + # -------- Utilities -------- def copy(self) -> "Graph": return Graph( From 27e046baec1796af81a7c67a6b972251b7cc5b97 Mon Sep 17 00:00:00 2001 From: Florian Huber Date: Wed, 29 Apr 2026 17:00:44 +0200 Subject: [PATCH 2/3] add tests for cytoscape export --- tests/test_graph.py | 87 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/test_graph.py b/tests/test_graph.py index da3f498..bb6b983 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -495,6 +495,93 @@ def test_to_igraph_types_and_attributes(): assert "weight" in igG.es.attributes() +def test_to_cytoscape_returns_expected_nodes_edges_and_metadata(): + A = _csr([0.5, 0.7], [0, 1], [1, 2], 3) + meta = pd.DataFrame({ + "name": ["n0", "n1", "n2"], + "group": ["a", "b", "a"], + }) + G = Graph.from_csr( + A, + directed=False, + weighted=True, + mode="similarity", + meta=meta, + ) + + cy = G.to_cytoscape() + + assert set(cy.keys()) == {"data", "elements"} + assert cy["data"]["directed"] is False + assert cy["data"]["weighted"] is True + assert cy["data"]["mode"] == "similarity" + + nodes = cy["elements"]["nodes"] + edges = cy["elements"]["edges"] + + assert len(nodes) == 3 + assert len(edges) == 2 + + assert nodes[0]["data"]["id"] == "0" + assert nodes[0]["data"]["index"] == 0 + assert nodes[0]["data"]["label"] == "n0" + assert nodes[0]["data"]["name"] == "n0" + assert nodes[0]["data"]["group"] == "a" + + edge_data = {tuple(sorted((e["data"]["source"], e["data"]["target"]))): e["data"] for e in edges} + + assert set(edge_data) == {("0", "1"), ("1", "2")} + assert edge_data[("0", "1")]["weight"] == pytest.approx(0.5) + assert edge_data[("1", "2")]["weight"] == pytest.approx(0.7) + + +def test_to_cytoscape_writes_json_and_uses_custom_node_ids(tmp_path): + import json + + A = _csr([1.0, 2.0], [0, 1], [1, 2], 3) + meta = pd.DataFrame({ + "id": ["a", "b", "c"], + "name": ["node-a", "node-b", "node-c"], + }) + G = Graph.from_csr( + A, + directed=True, + weighted=True, + mode="distance", + meta=meta, + ) + + path = tmp_path / "graph.cyjs" + returned = G.to_cytoscape(path, node_id_col="id") + + with open(path, encoding="utf-8") as f: + loaded = json.load(f) + + assert loaded == returned + + nodes = loaded["elements"]["nodes"] + edges = loaded["elements"]["edges"] + + assert [node["data"]["id"] for node in nodes] == ["a", "b", "c"] + assert [node["data"]["label"] for node in nodes] == ["node-a", "node-b", "node-c"] + + assert len(edges) == 2 + assert edges[0]["data"]["source"] == "a" + assert edges[0]["data"]["target"] == "b" + assert edges[0]["data"]["weight"] == pytest.approx(1.0) + assert edges[1]["data"]["source"] == "b" + assert edges[1]["data"]["target"] == "c" + assert edges[1]["data"]["weight"] == pytest.approx(2.0) + + +def test_to_cytoscape_rejects_duplicate_custom_node_ids(): + A = _csr([1.0], [0], [1], 2) + meta = pd.DataFrame({"id": ["same", "same"]}) + G = Graph.from_csr(A, directed=False, weighted=True, mode="distance", meta=meta) + + with pytest.raises(ValueError, match="node IDs must be unique"): + G.to_cytoscape(node_id_col="id") + # ----------------- Distance/similarity conversion ----------------- def test_convert_mode_distance_to_similarity_and_back_dense(S_dense, meta_df): From 2166424cb12f2cf1896d745aa9a95979c8ca4b52 Mon Sep 17 00:00:00 2001 From: Florian Huber Date: Wed, 29 Apr 2026 17:10:02 +0200 Subject: [PATCH 3/3] make networkx default import (not lazy) --- graphconstructor/graph.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/graphconstructor/graph.py b/graphconstructor/graph.py index 94a0b6b..087c165 100644 --- a/graphconstructor/graph.py +++ b/graphconstructor/graph.py @@ -1,6 +1,7 @@ import json from dataclasses import dataclass from typing import Iterable, Literal, Optional, Sequence +import networkx as nx import numpy as np import pandas as pd import scipy.sparse as sp @@ -239,10 +240,6 @@ def from_graphml( - Graph-level attributes 'mode', 'directed', 'weighted', 'ignore_selfloops', and 'keep_explicit_zeros' are honored if present. """ - try: - import networkx as nx # lazy import - except Exception as e: - raise ImportError("networkx is required for from_graphml().") from e if default_mode not in {"distance", "similarity"}: raise ValueError( @@ -496,10 +493,6 @@ def to_graphml(self, path, *, include_graph_attrs: bool = True) -> None: 'mode', 'directed', 'weighted', 'ignore_selfloops', and 'keep_explicit_zeros' in the GraphML file. """ - try: - import networkx as nx # lazy import - except Exception as e: - raise ImportError("networkx is required for to_graphml().") from e G_nx = self.to_networkx() @@ -540,17 +533,6 @@ def to_cytoscape( ``"name"`` if present. Set to None to omit labels. edge_weight_attr Name of the edge weight attribute. Defaults to ``"weight"``. - - Returns - ------- - dict - Cytoscape.js-compatible JSON dictionary with ``elements.nodes`` and - ``elements.edges``. - - Notes - ----- - The output format is suitable for Cytoscape.js and can also be imported - by Cytoscape Desktop via JSON import workflows. """ if node_id_col is not None: