diff --git a/Cargo.lock b/Cargo.lock index 076dd2723..c566eb823 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "cc" version = "1.2.18" @@ -61,6 +70,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.4.2" @@ -95,6 +113,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "derive_arbitrary" version = "1.4.1" @@ -106,6 +134,16 @@ dependencies = [ "syn", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "either" version = "1.15.0" @@ -150,6 +188,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -420,6 +468,51 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "pest" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" +dependencies = [ + "memchr", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + [[package]] name = "petgraph" version = "0.8.1" @@ -754,6 +847,8 @@ dependencies = [ "num-complex", "num-traits", "numpy", + "pest", + "pest_derive", "petgraph", "pyo3", "quick-xml", @@ -836,6 +931,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -879,6 +985,38 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -891,6 +1029,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 7ac526c62..f296922ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,8 @@ serde_json = "1.0" smallvec = { version = "1.0", features = ["union"] } rustworkx-core = { path = "rustworkx-core", version = "=0.17.1" } flate2 = "1.0.35" +pest = "=2.7.15" +pest_derive = "=2.7.15" [dependencies.pyo3] version = "0.24" diff --git a/releasenotes/notes/from-dot-b9f092537c7bce94.yaml b/releasenotes/notes/from-dot-b9f092537c7bce94.yaml new file mode 100644 index 000000000..f8f5076c6 --- /dev/null +++ b/releasenotes/notes/from-dot-b9f092537c7bce94.yaml @@ -0,0 +1,21 @@ +features: + - | + Added feature for importing graphs from the GraphViz DOT format via the new + :func:`~rustworkx.from_dot` function. This function takes a DOT string and + constructs either a :class:`~rustworkx.PyGraph` or + :class:`~rustworkx.PyDiGraph` object, automatically detecting the graph type + from the DOT input. Node attributes, edge attributes, and graph-level + attributes are preserved.For example:: + + import rustworkx + + dot_str = ''' + digraph { + 0 [label="a", color=red]; + 1 [label="b", color=blue]; + 0 -> 1 [weight=1]; + } + ''' + g = rustworkx.from_dot(dot_str) + assert len(g.nodes()) == 2 + assert len(g.edges()) == 1 diff --git a/rustworkx-core/src/max_weight_matching.rs b/rustworkx-core/src/max_weight_matching.rs index bc702d62b..88d24edb2 100644 --- a/rustworkx-core/src/max_weight_matching.rs +++ b/rustworkx-core/src/max_weight_matching.rs @@ -99,7 +99,7 @@ fn assign_label( assign_label( endpoints[mate[&base]], 1, - mate.get(&base).map(|p| (p ^ 1)), + mate.get(&base).map(|p| p ^ 1), num_nodes, in_blossoms, labels, diff --git a/rustworkx/__init__.pyi b/rustworkx/__init__.pyi index 13d9e5dd1..d6b396bf8 100644 --- a/rustworkx/__init__.pyi +++ b/rustworkx/__init__.pyi @@ -169,6 +169,7 @@ from .rustworkx import GraphMLKey as GraphMLKey from .rustworkx import digraph_node_link_json as digraph_node_link_json from .rustworkx import graph_node_link_json as graph_node_link_json from .rustworkx import from_node_link_json_file as from_node_link_json_file +from .rustworkx import from_dot as from_dot from .rustworkx import parse_node_link_json as parse_node_link_json from .rustworkx import digraph_bellman_ford_shortest_paths as digraph_bellman_ford_shortest_paths from .rustworkx import graph_bellman_ford_shortest_paths as graph_bellman_ford_shortest_paths diff --git a/rustworkx/rustworkx.pyi b/rustworkx/rustworkx.pyi index 6848cf166..08d8cbc68 100644 --- a/rustworkx/rustworkx.pyi +++ b/rustworkx/rustworkx.pyi @@ -751,6 +751,9 @@ def from_node_link_json_file( node_attrs: Callable[[dict[str, str]], _S] | None = ..., edge_attrs: Callable[[dict[str, str]], _T] | None = ..., ) -> PyDiGraph[_S, _T] | PyGraph[_S, _T]: ... +def from_dot( + dot_str: str, +) -> PyDiGraph[_S, _T] | PyGraph[_S, _T]: ... # Shortest Path diff --git a/src/dot_parser/dot.pest b/src/dot_parser/dot.pest new file mode 100644 index 000000000..dad44a31d --- /dev/null +++ b/src/dot_parser/dot.pest @@ -0,0 +1,43 @@ +WHITESPACE = _{ " " | "\t" | "\r" | "\n" } +COMMENT = _{ "#" ~ (!NEWLINE ~ ANY)* | "//" ~ (!NEWLINE ~ ANY)* | "/*" ~ (!"*/" ~ ANY)* ~ "*/" } +NEWLINE = _{ "\n" | "\r\n" } + +graph_file = { SOI ~ strict? ~ graph_type ~ id? ~ "{" ~ stmt_list? ~ "}" ~ EOI } + +strict = { "strict" } +graph_type = { "graph" | "digraph" } + +id = _{ number | identifier | quoted_id | html_id } + +number = @{ ASCII_DIGIT+ } +identifier = @{ (ASCII_ALPHANUMERIC | "_" | "." | "/" | "\\" | "-")+ } +quoted_id = @{ "\"" ~ (("\\\"" | (!"\"" ~ ANY))*) ~ "\"" } +html_id = @{ "<" ~ (!">" ~ ANY)* ~ ">" } + +stmt_list = { (stmt ~ (";" | NEWLINE)*)* } + +stmt = _{ + edge_stmt + | node_stmt + | attr_stmt + | assignment + | subgraph +} + +node_stmt = { node_id ~ attr_list* } +edge_stmt = { edge_point ~ (edge_op ~ edge_point)+ ~ attr_list* } + +edge_op = { "->" | "--" } + +edge_point = { node_id | subgraph } + +attr_stmt = { ("graph" | "node" | "edge") ~ attr_list+ } + +attr_list = { "[" ~ a_list? ~ "]" } +a_list = { (id ~ ("=" ~ id)? ~ ("," | ";")?)* } + +assignment = { id ~ "=" ~ id } + +subgraph = { "subgraph" ~ id? ~ "{" ~ stmt_list? ~ "}" } + +node_id = { id ~ (":" ~ id)? ~ (":" ~ id)? } \ No newline at end of file diff --git a/src/dot_parser/mod.rs b/src/dot_parser/mod.rs new file mode 100644 index 000000000..6281e08f4 --- /dev/null +++ b/src/dot_parser/mod.rs @@ -0,0 +1,322 @@ +use pest::Parser; +use pest_derive::Parser; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyString}; + +use crate::digraph::PyDiGraph; +use crate::graph::PyGraph; +use crate::StablePyGraph; + +use hashbrown::HashMap; +use rustworkx_core::petgraph::prelude::{Directed, NodeIndex, Undirected}; + +#[derive(Parser)] +#[grammar = "dot_parser/dot.pest"] +pub struct DotParser; + +/// Keep a single graph value that can be either directed or undirected. This avoids generic return-type mismatches. +enum DotGraph { + Directed(StablePyGraph), + Undirected(StablePyGraph), +} + +impl DotGraph { + fn new_directed() -> Self { + DotGraph::Directed(StablePyGraph::::with_capacity(0, 0)) + } + fn new_undirected() -> Self { + DotGraph::Undirected(StablePyGraph::::with_capacity(0, 0)) + } + fn add_node(&mut self, w: PyObject) -> NodeIndex { + match self { + DotGraph::Directed(g) => g.add_node(w), + DotGraph::Undirected(g) => g.add_node(w), + } + } + fn add_edge(&mut self, a: NodeIndex, b: NodeIndex, w: PyObject) { + match self { + DotGraph::Directed(g) => { + g.add_edge(a, b, w); + } + DotGraph::Undirected(g) => { + g.add_edge(a, b, w); + } + } + } + + #[allow(dead_code)] + fn is_directed(&self) -> bool { + matches!(self, DotGraph::Directed(_)) + } + + fn into_inner(self) -> Result, StablePyGraph> { + match self { + DotGraph::Directed(g) => Ok(g), + DotGraph::Undirected(g) => Err(g), + } + } +} + +/// Unquote a quoted string +fn unquote_str(s: &str) -> String { + let t = s.trim(); + if t.starts_with('"') && t.ends_with('"') && t.len() >= 2 { + t[1..t.len() - 1] + .replace("\\\"", "\"") + .replace("\\\\", "\\") + } else { + t.to_string() + } +} + +/// Parse an `attr_list` pair into a Rust HashMap +fn parse_attr_list_to_map(pair: pest::iterators::Pair) -> HashMap { + let mut map = HashMap::new(); + for a_list in pair.into_inner() { + if a_list.as_rule() != Rule::a_list { + continue; + } + let tokens: Vec<_> = a_list.into_inner().collect(); + let mut i = 0usize; + while i < tokens.len() { + let key = tokens[i].as_str().trim().to_string(); + if i + 1 < tokens.len() { + let val = tokens[i + 1].as_str().trim().to_string(); + map.insert(key, unquote_str(&val)); + i += 2; + } else { + map.insert(key, String::new()); + i += 1; + } + } + } + map +} + +/// Extract the first inner token of node_id +fn node_id_to_string(pair: pest::iterators::Pair) -> String { + if let Some(child) = pair.into_inner().next() { + return unquote_str(child.as_str().trim()); + } + String::new() +} + +#[pyfunction] +pub fn from_dot(py: Python<'_>, dot_str: &str) -> PyResult { + let pairs = DotParser::parse(Rule::graph_file, dot_str).map_err(|e| { + PyErr::new::(format!("DOT parse error: {}", e)) + })?; + + // Detect directedness from a clone of the iterator so we don't consume it. + let mut is_directed = false; + for pair in pairs.clone() { + if pair.as_rule() != Rule::graph_file { + continue; + } + let mut inner = pair.into_inner(); + let first = inner.next().unwrap(); + let graph_type_str = if first.as_rule() == Rule::strict { + inner.next().unwrap().as_str() + } else { + first.as_str() + }; + is_directed = graph_type_str == "digraph"; + break; + } + + build_graph_enum(py, pairs, is_directed) +} + +fn build_graph_enum( + py: Python<'_>, + pairs: pest::iterators::Pairs, + is_directed: bool, +) -> PyResult { + let mut node_map: HashMap = HashMap::new(); + let graph_attrs = PyDict::new(py); + + let mut default_node_attrs: HashMap = HashMap::new(); + let mut default_edge_attrs: HashMap = HashMap::new(); + + let mut node_attrs_map: HashMap = HashMap::new(); + + let mut graph = if is_directed { + DotGraph::new_directed() + } else { + DotGraph::new_undirected() + }; + + for pair in pairs { + if pair.as_rule() != Rule::graph_file { + continue; + } + let mut inner = pair.into_inner(); + let first = inner.next().ok_or_else(|| { + PyErr::new::("Missing graph type in DOT") + })?; + if first.as_rule() == Rule::strict { + inner.next(); + } + + for rest in inner { + if rest.as_rule() != Rule::stmt_list { + continue; + } + + for stmt in rest.into_inner() { + match stmt.as_rule() { + Rule::node_stmt => { + let mut it = stmt.into_inner(); + let nid = it.next().ok_or_else(|| { + PyErr::new::( + "Missing node id in DOT", + ) + })?; + let name = node_id_to_string(nid); + let py_node_obj: PyObject = PyString::new(py, &name).into(); + + let idx = graph.add_node(py_node_obj); + node_map.insert(name.clone(), idx); + + // Merge default node attrs + node's attr_list + let merged = PyDict::new(py); + for (k, v) in default_node_attrs.iter() { + merged.set_item(k.as_str(), v.as_str())?; + } + for maybe_attr in it { + if maybe_attr.as_rule() == Rule::attr_list { + let map = parse_attr_list_to_map(maybe_attr); + for (k, v) in map { + merged.set_item(k.as_str(), v.as_str())?; + } + } + } + node_attrs_map.insert(name.clone(), merged.into()); + } + + Rule::edge_stmt => { + let mut endpoints: Vec = Vec::new(); + + // Start collected edge attrs from defaults + let collected = PyDict::new(py); + for (k, v) in default_edge_attrs.iter() { + collected.set_item(k.as_str(), v.as_str())?; + } + + for child in stmt.into_inner() { + match child.as_rule() { + Rule::edge_point => { + for ep_child in child.into_inner() { + if ep_child.as_rule() == Rule::node_id { + let n = node_id_to_string(ep_child); + endpoints.push(n); + } + } + } + Rule::edge_op => { + // we already know directedness + } + Rule::attr_list => { + let map = parse_attr_list_to_map(child); + for (k, v) in map { + collected.set_item(k.as_str(), v.as_str())?; + } + } + _ => {} + } + } + + // Pairwise edges along the chain + for i in 0..endpoints.len().saturating_sub(1) { + let src = endpoints[i].clone(); + let dst = endpoints[i + 1].clone(); + + let src_idx = *node_map.entry(src.clone()).or_insert_with(|| { + let py_node: PyObject = PyString::new(py, &src).into(); + graph.add_node(py_node) + }); + + let dst_idx = *node_map.entry(dst.clone()).or_insert_with(|| { + let py_node: PyObject = PyString::new(py, &dst).into(); + graph.add_node(py_node) + }); + + let edge_attrs_obj: PyObject = collected.clone().into(); + graph.add_edge(src_idx, dst_idx, edge_attrs_obj); + } + } + + Rule::attr_stmt => { + // attr_stmt = ("graph" | "node" | "edge") ~ attr_list+ + let mut it = stmt.into_inner(); + if let Some(target_pair) = it.next() { + let target = target_pair.as_str(); + for rest in it { + if rest.as_rule() == Rule::attr_list { + let map = parse_attr_list_to_map(rest); + match target { + "graph" => { + for (k, v) in map { + graph_attrs.set_item(k.as_str(), v.as_str())?; + } + } + "node" => { + for (k, v) in map { + default_node_attrs.insert(k, v); + } + } + "edge" => { + for (k, v) in map { + default_edge_attrs.insert(k, v); + } + } + _ => {} + } + } + } + } + } + + Rule::assignment => { + let mut parts = stmt.into_inner(); + let key = parts.next().map(|p| p.as_str()).unwrap_or(""); + let val = parts.next().map(|p| p.as_str()).unwrap_or(""); + graph_attrs.set_item(key, val)?; + } + + Rule::subgraph => { + return Err(PyErr::new::( + "subgraph parsing is not supported", + )); + } + + _ => {} + } + } + } + } + + // Wrap into the a Python class + match graph.into_inner() { + Ok(directed_graph) => { + let dg = PyDiGraph { + graph: directed_graph, + cycle_state: rustworkx_core::petgraph::algo::DfsSpace::default(), + check_cycle: false, + node_removed: false, + multigraph: true, + attrs: graph_attrs.clone().into(), + }; + Ok(Py::new(py, dg)?.into()) + } + Err(undirected_graph) => { + let ug = PyGraph { + graph: undirected_graph, + node_removed: false, + multigraph: true, + attrs: graph_attrs.clone().into(), + }; + Ok(Py::new(py, ug)?.into()) + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 1b352d28f..b5aac4fcc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ mod connectivity; mod dag_algo; mod digraph; mod dominance; +mod dot_parser; mod dot_utils; mod generators; mod graph; @@ -56,6 +57,7 @@ use layout::*; use line_graph::*; use link_analysis::*; +use dot_parser::*; use matching::*; use planar::*; use random_graph::*; @@ -679,6 +681,7 @@ fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(parse_node_link_json))?; m.add_wrapped(wrap_pyfunction!(pagerank))?; m.add_wrapped(wrap_pyfunction!(hits))?; + m.add_wrapped(wrap_pyfunction!(from_dot))?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/tests/graph/test_dot.py b/tests/graph/test_dot.py index 63c98804e..e3ab9158a 100644 --- a/tests/graph/test_dot.py +++ b/tests/graph/test_dot.py @@ -128,3 +128,79 @@ def test_graph_no_args(self): graph = rustworkx.undirected_gnp_random_graph(3, 0.95, seed=24) dot_str = graph.to_dot() self.assertEqual("graph {\n0 ;\n1 ;\n2 ;\n2 -- 0 ;\n2 -- 1 ;\n}\n", dot_str) + + def test_from_dot_graph(self): + dot_str = """graph { + 0 [color=black, fillcolor=green, label="a", style=filled]; + 1 [color=black, fillcolor=red, label="a", style=filled]; + 0 -- 1 [label="1", name=1]; + }""" + g = rustworkx.from_dot(dot_str) + self.assertEqual(len(g.nodes()), 2) + self.assertEqual(len(g.edges()), 1) + + def test_from_dot_digraph(self): + dot_str = """digraph { + 0 [color=black, fillcolor=green, label="a", style=filled]; + 1 [color=black, fillcolor=red, label="a", style=filled]; + 0 -> 1 [label="1", name=1]; + }""" + g = rustworkx.from_dot(dot_str) + self.assertEqual(len(g.nodes()), 2) + self.assertEqual(len(g.edges()), 1) + + def test_graph_roundtrip_with_attrs(self): + + graph = rustworkx.PyGraph() + graph.add_node( + { + "color": "black", + "fillcolor": "green", + "label": "a", + "style": "filled", + } + ) + graph.add_node( + { + "color": "black", + "fillcolor": "red", + "label": "a", + "style": "filled", + } + ) + graph.add_edge(0, 1, dict(label="1", name="1")) + + res = graph.to_dot(lambda node: node, lambda edge: edge) + + g2 = rustworkx.from_dot(res) + + self.assertEqual(len(g2.nodes()), 2) + self.assertEqual(len(g2.edges()), 1) + + def test_digraph_roundtrip_with_attrs(self): + graph = rustworkx.PyGraph() + graph.add_node( + { + "color": "black", + "fillcolor": "green", + "label": "a", + "style": "filled", + } + ) + graph.add_node( + { + "color": "black", + "fillcolor": "red", + "label": "a", + "style": "filled", + } + ) + graph.add_edge(0, 1, dict(label="1", name="1")) + graph.add_edge(1, 0, dict(label="2", name="2")) + + res = graph.to_dot(lambda node: node, lambda edge: edge) + + g2 = rustworkx.from_dot(res) + + self.assertEqual(len(g2.nodes()), 2) + self.assertEqual(len(g2.edges()), 2)