Skip to content

Commit 90fd96f

Browse files
authored
Merge pull request #5 from panel-extensions/networkx_tests
Fixes and tests for from/to_networkx
2 parents a0a2b20 + 5ac3799 commit 90fd96f

3 files changed

Lines changed: 109 additions & 69 deletions

File tree

pixi.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ pytest-asyncio = "*"
5252
pytest-cov = "*"
5353
pytest-rerunfailures = "<16"
5454
pytest-xdist = "*"
55+
networkx = "*"
5556
mypy = "*"
5657

5758
[feature.test.tasks]

src/panel_reactflow/base.py

Lines changed: 76 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -415,65 +415,61 @@ def _handle_msg(self, msg: dict[str, Any]) -> None:
415415
"""Handle sync messages from the frontend."""
416416
if not isinstance(msg, dict):
417417
return
418-
msg_type = msg.get("type")
419-
if msg_type == "sync":
420-
nodes = msg.get("nodes")
421-
edges = msg.get("edges")
422-
if nodes is not None:
423-
self.nodes = nodes
424-
if edges is not None:
425-
self.edges = edges
426-
self._emit(msg_type, msg)
427-
elif msg_type == "node_moved":
428-
node_id = msg.get("node_id")
429-
position = msg.get("position")
430-
if node_id is None or position is None:
418+
match msg.get("type"):
419+
case "sync":
420+
nodes = msg.get("nodes")
421+
edges = msg.get("edges")
422+
if nodes is not None:
423+
self.nodes = nodes
424+
if edges is not None:
425+
self.edges = edges
426+
self._emit("sync", msg)
427+
case "node_moved":
428+
node_id = msg.get("node_id")
429+
position = msg.get("position")
430+
if node_id is None or position is None:
431+
return
432+
for node in self.nodes:
433+
if node.get("id") == node_id:
434+
node["position"] = position
435+
self._emit("node_moved", msg)
436+
case "selection_changed":
437+
node_ids = msg.get("nodes") or []
438+
edge_ids = msg.get("edges") or []
439+
for node in self.nodes:
440+
node["selected"] = node.get("id") in node_ids
441+
for edge in self.edges:
442+
edge["selected"] = edge.get("id") in edge_ids
443+
self.selection = {"nodes": list(node_ids), "edges": list(edge_ids)}
444+
self._emit("selection_changed", msg)
445+
case "edge_added":
446+
edge = msg.get("edge")
447+
if edge is None:
448+
return
449+
self.add_edge(edge)
450+
self._emit("edge_added", msg)
451+
case "node_deleted":
452+
node_ids = msg.get("node_ids") or []
453+
if msg.get("node_id"):
454+
node_ids = list(set(node_ids) | {msg.get("node_id")})
455+
for node_id in node_ids:
456+
self.remove_node(node_id)
457+
self._emit("node_deleted", msg)
458+
case "edge_deleted":
459+
edge_ids = msg.get("edge_ids") or []
460+
if msg.get("edge_id"):
461+
edge_ids = list(set(edge_ids) | {msg.get("edge_id")})
462+
for edge_id in edge_ids:
463+
self.remove_edge(edge_id)
464+
self._emit("edge_deleted", msg)
465+
case "node_clicked":
466+
node_id = msg.get("node_id")
467+
if node_id is None:
468+
return
469+
self._build_toolbar_for_node(node_id)
470+
self._emit("node_clicked", msg)
471+
case _:
431472
return
432-
for node in self.nodes:
433-
if node.get("id") == node_id:
434-
node["position"] = position
435-
self._emit(msg_type, msg)
436-
elif msg_type == "selection_changed":
437-
node_ids = msg.get("nodes") or []
438-
edge_ids = msg.get("edges") or []
439-
for node in self.nodes:
440-
node["selected"] = node.get("id") in node_ids
441-
for edge in self.edges:
442-
edge["selected"] = edge.get("id") in edge_ids
443-
self.selection = {"nodes": list(node_ids), "edges": list(edge_ids)}
444-
self._emit(msg_type, msg)
445-
elif msg_type == "edge_added":
446-
edge = msg.get("edge")
447-
if edge is None:
448-
return
449-
self.add_edge(edge)
450-
self._emit(msg_type, msg)
451-
elif msg_type == "node_deleted":
452-
node_ids = msg.get("node_ids") or []
453-
if msg.get("node_id"):
454-
node_ids = list(set(node_ids) | {msg.get("node_id")})
455-
for node_id in node_ids:
456-
self.remove_node(node_id)
457-
self._emit(msg_type, msg)
458-
elif msg_type == "edge_deleted":
459-
edge_ids = msg.get("edge_ids") or []
460-
if msg.get("edge_id"):
461-
edge_ids = list(set(edge_ids) | {msg.get("edge_id")})
462-
for edge_id in edge_ids:
463-
self.remove_edge(edge_id)
464-
self._emit(msg_type, msg)
465-
elif msg_type == "node_clicked":
466-
node_id = msg.get("node_id")
467-
if node_id is None:
468-
return
469-
self._build_toolbar_for_node(node_id)
470-
self._emit(msg_type, msg)
471-
elif msg_type == "toolbar_opened":
472-
node_id = msg.get("node_id")
473-
if node_id is None:
474-
return
475-
self._build_toolbar_for_node(node_id)
476-
self._emit(msg_type, msg)
477473

478474
def remove_node(self, node_id: str) -> None:
479475
"""Remove a node and any connected edges.
@@ -626,22 +622,33 @@ def from_networkx(
626622
position = {"x": position[0], "y": position[1]}
627623
node_data = dict(attrs)
628624
node_data.pop("type", None)
625+
embedded_data = node_data.pop("data", None)
626+
if isinstance(embedded_data, dict):
627+
node_data = {**embedded_data, **node_data}
629628
nodes.append({"id": str(node_id), "position": position, "type": node_type, "data": node_data})
630-
for source, target, key, attrs in graph.edges(keys=True, data=True):
629+
if graph.is_multigraph():
630+
edge_iter = graph.edges(keys=True, data=True)
631+
else:
632+
edge_iter = ((source, target, None, attrs) for source, target, attrs in graph.edges(data=True))
633+
for source, target, key, attrs in edge_iter:
631634
edge_data = dict(attrs)
635+
embedded_edge_data = edge_data.pop("data", None)
636+
if isinstance(embedded_edge_data, dict):
637+
edge_data = {**embedded_edge_data, **edge_data}
632638
label = edge_data.pop("label", None)
633639
edge_type = edge_data.pop("type", None)
634640
edge_id = key if key is not None else f"{source}->{target}"
635-
edges.append(
636-
{
637-
"id": str(edge_id),
638-
"source": str(source),
639-
"target": str(target),
640-
"label": label,
641-
"type": edge_type,
642-
"data": edge_data,
643-
}
644-
)
641+
edge = {
642+
"id": str(edge_id),
643+
"source": str(source),
644+
"target": str(target),
645+
"data": edge_data,
646+
}
647+
if label is not None:
648+
edge["label"] = label
649+
if edge_type is not None:
650+
edge["type"] = edge_type
651+
edges.append(edge)
645652
return cls(nodes=nodes, edges=edges)
646653

647654
def on(self, event_type: str, callback) -> None:

tests/test_api.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
"""Tests for the public API helpers."""
22

3+
try:
4+
import networkx as nx
5+
except ImportError:
6+
nx = None
37
import panel as pn
8+
import pytest
49

510
from panel_reactflow import EdgeSpec, NodeSpec, ReactFlow
611

12+
nx_available = pytest.mark.skipif(nx is None, reason="networkx is not installed")
13+
714

815
def test_node_spec_roundtrip() -> None:
916
node = NodeSpec(
@@ -60,3 +67,28 @@ def test_reactflow_events_and_selection() -> None:
6067
edge_id = flow.edges[0]["id"]
6168
flow.patch_edge_data(edge_id, {"weight": 0.25})
6269
assert flow.edges[0]["data"]["weight"] == 0.25
70+
71+
72+
@nx_available
73+
def test_reactflow_to_networkx() -> None:
74+
flow = ReactFlow()
75+
flow.add_node({"id": "n1", "position": {"x": 0, "y": 0}, "data": {}, "selected": True})
76+
flow.add_node({"id": "n2", "position": {"x": 1, "y": 1}, "data": {}, "selected": False})
77+
flow.add_edge({"source": "n1", "target": "n2", "data": {}})
78+
graph = flow.to_networkx()
79+
assert list(graph.nodes) == ["n1", "n2"]
80+
assert list(graph.edges) == [("n1", "n2")]
81+
82+
83+
@nx_available
84+
def test_reactflow_from_networkx() -> None:
85+
graph = nx.DiGraph()
86+
graph.add_node("n1", position={"x": 0, "y": 0}, data={})
87+
graph.add_node("n2", position={"x": 1, "y": 1}, data={})
88+
graph.add_edge("n1", "n2", data={})
89+
flow = ReactFlow.from_networkx(graph)
90+
assert flow.nodes == [
91+
{"id": "n1", "position": {"x": 0, "y": 0}, "data": {}, "type": "panel"},
92+
{"id": "n2", "position": {"x": 1, "y": 1}, "data": {}, "type": "panel"},
93+
]
94+
assert flow.edges == [{"id": "n1->n2", "source": "n1", "target": "n2", "data": {}}]

0 commit comments

Comments
 (0)