From 224e44794e7474ff65708d39852cb65e96f47002 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 05:55:26 +0000 Subject: [PATCH 1/6] Initial plan From 8e2f05ec4c2b2e0d8853476c46dc87114cfe8c2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 06:00:44 +0000 Subject: [PATCH 2/6] Add auto-serialization for NodeSpec and EdgeSpec objects Co-authored-by: MarcSkovMadsen <42288570+MarcSkovMadsen@users.noreply.github.com> --- src/panel_reactflow/base.py | 19 ++++++++ tests/test_api.py | 90 +++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index a683be1..a88bbdc 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -1169,8 +1169,15 @@ def __init__(self, **params: Any): params["node_types"] = _coerce_spec_map(params["node_types"]) if "edge_types" in params: params["edge_types"] = _coerce_spec_map(params["edge_types"], edge=True) + # Normalize nodes and edges to ensure NodeSpec/EdgeSpec are converted to dicts + if "nodes" in params: + params["nodes"] = [self._coerce_node(node) for node in params["nodes"]] + if "edges" in params: + params["edges"] = [self._coerce_edge(edge) for edge in params["edges"]] super().__init__(**params) self._event_handlers: dict[str, list[Callable]] = {"*": []} + self.param.watch(self._normalize_nodes, ["nodes"]) + self.param.watch(self._normalize_edges, ["edges"]) self.param.watch(self._update_selection_from_graph, ["nodes", "edges"]) self.param.watch(self._normalize_specs, ["node_types", "edge_types"]) self.param.watch( @@ -2146,6 +2153,18 @@ def _normalize_specs(self, event: param.parameterized.Event) -> None: if normalized != event.new: setattr(self, event.name, normalized) + def _normalize_nodes(self, event: param.parameterized.Event) -> None: + """Normalize nodes list by converting NodeSpec objects to dicts.""" + normalized = [self._coerce_node(node) for node in event.new] + if normalized != event.new: + self.nodes = normalized + + def _normalize_edges(self, event: param.parameterized.Event) -> None: + """Normalize edges list by converting EdgeSpec objects to dicts.""" + normalized = [self._coerce_edge(edge) for edge in event.new] + if normalized != event.new: + self.edges = normalized + @staticmethod def _generate_edge_id(source: str, target: str) -> str: existing = f"{source}->{target}" diff --git a/tests/test_api.py b/tests/test_api.py index 86ea185..88ca1b1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -321,3 +321,93 @@ def test_node_types_dict_passthrough() -> None: spec = {"type": "task", "label": "Task", "schema": None, "inputs": None, "outputs": None} flow = ReactFlow(node_types={"task": spec}) assert flow.node_types["task"] == spec + + +def test_nodespec_autoserialize_on_init() -> None: + """NodeSpec objects should be automatically converted to dicts on init.""" + node1 = NodeSpec(id="n1", position={"x": 0, "y": 0}, label="Node 1") + node2 = NodeSpec(id="n2", position={"x": 100, "y": 50}, label="Node 2") + flow = ReactFlow(nodes=[node1, node2]) + + # Verify nodes are now dictionaries + assert isinstance(flow.nodes[0], dict) + assert isinstance(flow.nodes[1], dict) + assert flow.nodes[0]["id"] == "n1" + assert flow.nodes[0]["label"] == "Node 1" + assert flow.nodes[1]["id"] == "n2" + assert flow.nodes[1]["label"] == "Node 2" + + +def test_edgespec_autoserialize_on_init() -> None: + """EdgeSpec objects should be automatically converted to dicts on init.""" + edge1 = EdgeSpec(id="e1", source="n1", target="n2", label="Edge 1") + edge2 = EdgeSpec(id="e2", source="n2", target="n3") + flow = ReactFlow(edges=[edge1, edge2]) + + # Verify edges are now dictionaries + assert isinstance(flow.edges[0], dict) + assert isinstance(flow.edges[1], dict) + assert flow.edges[0]["id"] == "e1" + assert flow.edges[0]["source"] == "n1" + assert flow.edges[0]["target"] == "n2" + assert flow.edges[0]["label"] == "Edge 1" + assert flow.edges[1]["id"] == "e2" + + +def test_nodespec_autoserialize_on_assignment() -> None: + """NodeSpec objects should be automatically converted when assigned to nodes param.""" + flow = ReactFlow() + node1 = NodeSpec(id="n1", position={"x": 0, "y": 0}, label="Node 1") + node2 = NodeSpec(id="n2", position={"x": 100, "y": 50}, label="Node 2") + + # Assign NodeSpec objects directly + flow.nodes = [node1, node2] + + # Verify they were converted to dicts + assert isinstance(flow.nodes[0], dict) + assert isinstance(flow.nodes[1], dict) + assert flow.nodes[0]["id"] == "n1" + assert flow.nodes[1]["id"] == "n2" + + +def test_edgespec_autoserialize_on_assignment() -> None: + """EdgeSpec objects should be automatically converted when assigned to edges param.""" + flow = ReactFlow() + edge1 = EdgeSpec(id="e1", source="n1", target="n2", label="Edge 1") + edge2 = EdgeSpec(id="e2", source="n2", target="n3") + + # Assign EdgeSpec objects directly + flow.edges = [edge1, edge2] + + # Verify they were converted to dicts + assert isinstance(flow.edges[0], dict) + assert isinstance(flow.edges[1], dict) + assert flow.edges[0]["id"] == "e1" + assert flow.edges[1]["id"] == "e2" + + +def test_mixed_nodespec_and_dict() -> None: + """Should handle a mix of NodeSpec and dict objects.""" + node1 = NodeSpec(id="n1", position={"x": 0, "y": 0}) + node2 = {"id": "n2", "position": {"x": 100, "y": 50}, "data": {}} + flow = ReactFlow(nodes=[node1, node2]) + + # Both should be dicts + assert isinstance(flow.nodes[0], dict) + assert isinstance(flow.nodes[1], dict) + assert flow.nodes[0]["id"] == "n1" + assert flow.nodes[1]["id"] == "n2" + + +def test_mixed_edgespec_and_dict() -> None: + """Should handle a mix of EdgeSpec and dict objects.""" + edge1 = EdgeSpec(id="e1", source="n1", target="n2") + edge2 = {"id": "e2", "source": "n2", "target": "n3", "data": {}} + flow = ReactFlow(edges=[edge1, edge2]) + + # Both should be dicts + assert isinstance(flow.edges[0], dict) + assert isinstance(flow.edges[1], dict) + assert flow.edges[0]["id"] == "e1" + assert flow.edges[1]["id"] == "e2" + From bf025dedd1bca7e845d2797ded08bdf07de9ad07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 06:04:56 +0000 Subject: [PATCH 3/6] Fix code review issues: avoid infinite recursion and use static methods correctly Co-authored-by: MarcSkovMadsen <42288570+MarcSkovMadsen@users.noreply.github.com> --- src/panel_reactflow/base.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index a88bbdc..9c4671d 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -1171,9 +1171,9 @@ def __init__(self, **params: Any): params["edge_types"] = _coerce_spec_map(params["edge_types"], edge=True) # Normalize nodes and edges to ensure NodeSpec/EdgeSpec are converted to dicts if "nodes" in params: - params["nodes"] = [self._coerce_node(node) for node in params["nodes"]] + params["nodes"] = [ReactFlow._coerce_node(node) for node in params["nodes"]] if "edges" in params: - params["edges"] = [self._coerce_edge(edge) for edge in params["edges"]] + params["edges"] = [ReactFlow._coerce_edge(edge) for edge in params["edges"]] super().__init__(**params) self._event_handlers: dict[str, list[Callable]] = {"*": []} self.param.watch(self._normalize_nodes, ["nodes"]) @@ -2156,13 +2156,15 @@ def _normalize_specs(self, event: param.parameterized.Event) -> None: def _normalize_nodes(self, event: param.parameterized.Event) -> None: """Normalize nodes list by converting NodeSpec objects to dicts.""" normalized = [self._coerce_node(node) for node in event.new] - if normalized != event.new: + # Only update if there were actual changes to avoid infinite recursion + if any(n1 is not n2 for n1, n2 in zip(normalized, event.new)): self.nodes = normalized def _normalize_edges(self, event: param.parameterized.Event) -> None: """Normalize edges list by converting EdgeSpec objects to dicts.""" normalized = [self._coerce_edge(edge) for edge in event.new] - if normalized != event.new: + # Only update if there were actual changes to avoid infinite recursion + if any(e1 is not e2 for e1, e2 in zip(normalized, event.new)): self.edges = normalized @staticmethod From 7a73edbb606191de02c75cf2ee97f317685702db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 06:07:06 +0000 Subject: [PATCH 4/6] Update documentation to reflect auto-serialization feature Co-authored-by: MarcSkovMadsen <42288570+MarcSkovMadsen@users.noreply.github.com> --- docs/how-to/define-nodes-edges.md | 69 ++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/docs/how-to/define-nodes-edges.md b/docs/how-to/define-nodes-edges.md index dc4f6ed..dbca451 100644 --- a/docs/how-to/define-nodes-edges.md +++ b/docs/how-to/define-nodes-edges.md @@ -77,27 +77,54 @@ edges = [ ## Use the NodeSpec / EdgeSpec helpers If you prefer a typed API, use the dataclass helpers. They validate fields -at construction time and convert to plain dicts via `.to_dict()`. +at construction time and are **automatically converted to dictionaries** when +passed to `ReactFlow`. ```python -from panel_reactflow import NodeSpec, EdgeSpec +from panel_reactflow import NodeSpec, EdgeSpec, ReactFlow + +# Create nodes and edges using NodeSpec/EdgeSpec +nodes = [ + NodeSpec( + id="n1", + type="panel", + label="Start", + position={"x": 0, "y": 0}, + data={"status": "idle"}, + ), + NodeSpec( + id="n2", + type="panel", + label="End", + position={"x": 260, "y": 60}, + data={"status": "done"}, + ), +] + +edges = [ + EdgeSpec( + id="e1", + source="n1", + target="n2", + label="next", + ), +] -n1 = NodeSpec( - id="n1", - type="panel", - label="Start", - position={"x": 0, "y": 0}, - data={"status": "idle"}, -).to_dict() - -e1 = EdgeSpec( - id="e1", - source="n1", - target="n2", - label="next", -).to_dict() +# No need to call .to_dict() - automatic serialization! +flow = ReactFlow(nodes=nodes, edges=edges) ``` +!!! note "Automatic Serialization" + `NodeSpec` and `EdgeSpec` objects are automatically converted to dictionaries + when passed to `ReactFlow`. You no longer need to call `.to_dict()` manually. + + However, `.to_dict()` is still available if you need to convert them explicitly + for other use cases: + + ```python + node_dict = NodeSpec(id="n1", position={"x": 0, "y": 0}).to_dict() + ``` + --- ## Update data vs. label @@ -124,10 +151,20 @@ flow.nodes = [ ## Add and remove at runtime +You can use either plain dictionaries or `NodeSpec`/`EdgeSpec` objects with the +`add_node()` and `add_edge()` methods: + ```python +# Using plain dictionaries flow.add_node({"id": "n3", "position": {"x": 520, "y": 0}, "label": "New", "data": {}}) flow.add_edge({"source": "n2", "target": "n3", "data": {}}) +# Or using NodeSpec/EdgeSpec (no .to_dict() needed) +from panel_reactflow import NodeSpec, EdgeSpec + +flow.add_node(NodeSpec(id="n4", position={"x": 780, "y": 0}, label="Another")) +flow.add_edge(EdgeSpec(id="e2", source="n3", target="n4")) + flow.remove_node("n3") # also removes connected edges flow.remove_edge("e1") ``` From 04c108268f3c118c8469d719f647a20b847242e4 Mon Sep 17 00:00:00 2001 From: MarcSkovMadsen Date: Mon, 9 Feb 2026 08:36:07 +0000 Subject: [PATCH 5/6] review feedback --- src/panel_reactflow/base.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index 9c4671d..1e10ce2 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -2157,14 +2157,14 @@ def _normalize_nodes(self, event: param.parameterized.Event) -> None: """Normalize nodes list by converting NodeSpec objects to dicts.""" normalized = [self._coerce_node(node) for node in event.new] # Only update if there were actual changes to avoid infinite recursion - if any(n1 is not n2 for n1, n2 in zip(normalized, event.new)): + if any(n1 is not n2 for n1, n2 in zip(normalized, event.new, strict=False)): self.nodes = normalized def _normalize_edges(self, event: param.parameterized.Event) -> None: """Normalize edges list by converting EdgeSpec objects to dicts.""" normalized = [self._coerce_edge(edge) for edge in event.new] # Only update if there were actual changes to avoid infinite recursion - if any(e1 is not e2 for e1, e2 in zip(normalized, event.new)): + if any(e1 is not e2 for e1, e2 in zip(normalized, event.new, strict=False)): self.edges = normalized @staticmethod @@ -2174,11 +2174,11 @@ def _generate_edge_id(source: str, target: str) -> str: @staticmethod def _coerce_node(node: dict[str, Any] | NodeSpec) -> dict[str, Any]: - return node.to_dict() if hasattr(node, "to_dict") else dict(node) + return node.to_dict() if hasattr(node, "to_dict") else node @staticmethod def _coerce_edge(edge: dict[str, Any] | EdgeSpec) -> dict[str, Any]: - return edge.to_dict() if hasattr(edge, "to_dict") else dict(edge) + return edge.to_dict() if hasattr(edge, "to_dict") else edge def _validate_graph_payload(self, payload: dict[str, Any], *, kind: str) -> None: required = {"node": ["id", "position", "data"], "edge": ["id", "source", "target"]}[kind] From f343d26f38d18aa10c40a6147c0200adeabe3bc2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 08:41:34 +0000 Subject: [PATCH 6/6] Remove 'no longer' from documentation as suggested in review Co-authored-by: MarcSkovMadsen <42288570+MarcSkovMadsen@users.noreply.github.com> --- docs/how-to/define-nodes-edges.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/how-to/define-nodes-edges.md b/docs/how-to/define-nodes-edges.md index dbca451..c98c66a 100644 --- a/docs/how-to/define-nodes-edges.md +++ b/docs/how-to/define-nodes-edges.md @@ -116,7 +116,7 @@ flow = ReactFlow(nodes=nodes, edges=edges) !!! note "Automatic Serialization" `NodeSpec` and `EdgeSpec` objects are automatically converted to dictionaries - when passed to `ReactFlow`. You no longer need to call `.to_dict()` manually. + when passed to `ReactFlow`. You don't need to call `.to_dict()` manually. However, `.to_dict()` is still available if you need to convert them explicitly for other use cases: