From ac1afb893c1b70a99790369066971ac95e3080cf Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Mon, 1 Sep 2025 20:25:46 +0800 Subject: [PATCH 01/23] feat: Add support for graph6 format --- src/graph6.rs | 869 ++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 7 + tests/test_graph6_py.py | 30 ++ 3 files changed, 906 insertions(+) create mode 100644 src/graph6.rs create mode 100644 tests/test_graph6_py.py diff --git a/src/graph6.rs b/src/graph6.rs new file mode 100644 index 0000000000..0260434010 --- /dev/null +++ b/src/graph6.rs @@ -0,0 +1,869 @@ +//! Combined module: conversion, error, utils, write, undirected and directed +//! This file is intended as a drop-in single-module alternative to +//! the separate files in `src/` so callers can `mod all; use all::...` and +//! avoid many `use super` / `use crate` imports inside the library. + +#[allow(dead_code)] +/// Conversion trait for graphs into various text graph formats +pub trait GraphConversion { + /// Returns the bitvector representation of the graph + fn bit_vec(&self) -> &[usize]; + + /// Returns the number of vertices in the graph + fn size(&self) -> usize; + + /// Returns true if the graph is directed + fn is_directed(&self) -> bool; + + /// Returns the graph in the DOT format + fn to_dot(&self, id: Option) -> String { + let n = self.size(); + let bit_vec = self.bit_vec(); + + let mut dot = String::new(); + + // include graph type + if self.is_directed() { + dot.push_str("digraph "); + } else { + dot.push_str("graph "); + } + + // include graph id + if let Some(id) = id { + dot.push_str(&format!("graph_{} {{", id)); + } else { + dot.push('{'); + } + + // include edges + if self.is_directed() { + self.to_directed_dot(&mut dot, bit_vec, n); + } else { + self.to_undirected_dot(&mut dot, bit_vec, n); + } + + // close graph + dot.push_str("\n}"); + + dot + } + + fn to_undirected_dot(&self, dot: &mut String, bit_vec: &[usize], n: usize) { + for i in 0..n { + for j in i..n { + if bit_vec[i * n + j] == 1 { + dot.push_str(&format!("\n{} -- {};", i, j)); + } + } + } + } + + fn to_directed_dot(&self, dot: &mut String, bit_vec: &[usize], n: usize) { + for i in 0..n { + for j in 0..n { + if bit_vec[i * n + j] == 1 { + dot.push_str(&format!("\n{} -> {};", i, j)); + } + } + } + } + + /// Returns the graph as an adjacency matrix + fn to_adjmat(&self) -> String { + let n = self.size(); + let bit_vec = self.bit_vec(); + + let mut adj = String::new(); + for i in 0..n { + for j in 0..n { + adj.push_str(&format!("{}", bit_vec[i * n + j])); + if j < n - 1 { + adj.push(' '); + } + } + adj.push('\n'); + } + adj + } + + /// Returns the graph in a flat adjacency matrix + fn to_flat(&self) -> String { + let n = self.size(); + let bit_vec = self.bit_vec(); + + let mut flat = String::new(); + for i in 0..n { + for j in 0..n { + flat.push_str(&format!("{}", bit_vec[i * n + j])); + } + } + flat + } + + /// Returns the graph in the Pajek NET format + fn to_net(&self) -> String { + let n = self.size(); + let bit_vec = self.bit_vec(); + + let mut net = String::new(); + net.push_str(&format!("*Vertices {}\n", n)); + for i in 0..n { + net.push_str(&format!("{} \"{}\"\n", i + 1, i)); + } + net.push_str("*Arcs\n"); + for i in 0..n { + for j in 0..n { + if bit_vec[i * n + j] == 1 { + net.push_str(&format!("{} {}\n", i + 1, j + 1)); + } + } + } + net + } +} + +/// IO / parsing errors +#[derive(Debug, PartialEq, Eq)] +pub enum IOError { + InvalidDigraphHeader, + InvalidSizeChar, + GraphTooLarge, + #[allow(dead_code)] + InvalidAdjacencyMatrix, + NonCanonicalEncoding, +} + +/// Utility functions used by parsers and writers +pub mod utils { + use super::IOError; + + /// Iterates through the bytes of a graph and fills a bitvector representing + /// the adjacency matrix of the graph + pub fn fill_bitvector(bytes: &[u8], size: usize, offset: usize) -> Option> { + let mut bit_vec = Vec::with_capacity(size); + let mut pos = 0; + for b in bytes.iter().skip(offset) { + let b = b.checked_sub(63)?; + for i in 0..6 { + let bit = (b >> (5 - i)) & 1; + bit_vec.push(bit as usize); + pos += 1; + if pos == size { + break; + } + } + } + Some(bit_vec) + } + + /// Returns the size of the graph + pub fn get_size(bytes: &[u8], pos: usize) -> Result { + let size = bytes[pos]; + if size == 126 { + Err(IOError::GraphTooLarge) + } else if size < 63 { + Err(IOError::InvalidSizeChar) + } else { + Ok((size - 63) as usize) + } + } + + /// Returns the upper triangle of a bitvector + pub fn upper_triangle(bit_vec: &[usize], n: usize) -> Vec { + let mut tri = Vec::with_capacity(n * (n - 1) / 2); + for i in 1..n { + for j in 0..i { + let idx = i * n + j; + tri.push(bit_vec[idx]) + } + } + tri + } +} + +/// Graph6 writer utilities +pub mod write { + use super::utils::upper_triangle; + use super::GraphConversion; + + /// Trait to write graphs into graph 6 formatted strings + #[allow(dead_code)] + pub trait WriteGraph: GraphConversion { + fn write_graph(&self) -> String { + write_graph6(self.bit_vec().to_vec(), self.size(), self.is_directed()) + } + } + + fn write_header(repr: &mut String, is_directed: bool) { + if is_directed { + repr.push('&'); + } + } + + fn write_size(repr: &mut String, size: usize) { + let size_char = char::from_u32(size as u32 + 63).unwrap(); + repr.push(size_char); + } + + fn pad_bitvector(bit_vec: &mut Vec) { + if bit_vec.len() % 6 != 0 { + (0..6 - (bit_vec.len() % 6)).for_each(|_| bit_vec.push(0)); + } + } + + fn parse_bitvector(bit_vec: &[usize], repr: &mut String) { + for chunk in bit_vec.chunks(6) { + let mut sum = 0; + for (i, bit) in chunk.iter().rev().enumerate() { + sum += bit * 2usize.pow(i as u32); + } + let char = char::from_u32(sum as u32 + 63).unwrap(); + repr.push(char); + } + } + + pub fn write_graph6(bit_vec: Vec, n: usize, is_directed: bool) -> String { + let mut repr = String::new(); + let mut bit_vec = if is_directed { + bit_vec + } else { + upper_triangle(&bit_vec, n) + }; + write_header(&mut repr, is_directed); + write_size(&mut repr, n); + pad_bitvector(&mut bit_vec); + parse_bitvector(&bit_vec, &mut repr); + repr + } +} + +// WriteGraph is only used in tests via the tests module's imports + +use crate::get_edge_iter_with_weights; +use crate::{digraph::PyDiGraph, graph::PyGraph, StablePyGraph}; +use petgraph::algo; +use petgraph::graph::NodeIndex; +use petgraph::prelude::*; +use pyo3::exceptions::PyException; +use pyo3::prelude::*; + +/// Undirected graph implementation +#[derive(Debug)] +pub struct Graph { + pub bit_vec: Vec, + pub n: usize, +} +impl Graph { + /// Creates a new undirected graph from a graph6 representation + pub fn from_g6(repr: &str) -> Result { + let bytes = repr.as_bytes(); + let n = utils::get_size(bytes, 0)?; + let bit_vec = Self::build_bitvector(bytes, n)?; + Ok(Self { bit_vec, n }) + } + + /// Creates a new undirected graph from a flattened adjacency matrix. + /// The adjacency matrix must be square. + /// The adjacency matrix will be forced into a symmetric matrix. + #[cfg(test)] + pub fn from_adj(adj: &[usize]) -> Result { + let n2 = adj.len(); + let n = (n2 as f64).sqrt() as usize; + if n * n != n2 { + return Err(IOError::InvalidAdjacencyMatrix); + } + let mut bit_vec = vec![0; n * n]; + for i in 0..n { + for j in 0..n { + if adj[i * n + j] == 1 { + let idx = i * n + j; + let jdx = j * n + i; + bit_vec[idx] = 1; + bit_vec[jdx] = 1; + } + } + } + Ok(Self { bit_vec, n }) + } + + /// Builds the bitvector from the graph6 representation + fn build_bitvector(bytes: &[u8], n: usize) -> Result, IOError> { + let bv_len = n * (n - 1) / 2; + let Some(bit_vec) = utils::fill_bitvector(bytes, bv_len, 1) else { + return Err(IOError::NonCanonicalEncoding); + }; + Self::fill_from_triangle(&bit_vec, n) + } + + /// Fills the adjacency bitvector from an upper triangle + fn fill_from_triangle(tri: &[usize], n: usize) -> Result, IOError> { + let mut bit_vec = vec![0; n * n]; + let mut tri_iter = tri.iter(); + for i in 1..n { + for j in 0..i { + let idx = i * n + j; + let jdx = j * n + i; + let Some(&val) = tri_iter.next() else { + return Err(IOError::NonCanonicalEncoding); + }; + bit_vec[idx] = val; + bit_vec[jdx] = val; + } + } + Ok(bit_vec) + } +} +#[allow(dead_code)] +impl GraphConversion for Graph { + fn bit_vec(&self) -> &[usize] { + &self.bit_vec + } + + fn size(&self) -> usize { + self.n + } + + fn is_directed(&self) -> bool { + false + } +} +#[cfg(test)] +impl write::WriteGraph for Graph {} + +/// Directed graph implementation +#[derive(Debug)] +pub struct DiGraph { + pub bit_vec: Vec, + pub n: usize, +} +impl DiGraph { + /// Creates a new DiGraph from a graph6 representation string + pub fn from_d6(repr: &str) -> Result { + let bytes = repr.as_bytes(); + Self::valid_digraph(bytes)?; + let n = utils::get_size(bytes, 1)?; + let Some(bit_vec) = Self::build_bitvector(bytes, n) else { + return Err(IOError::NonCanonicalEncoding); + }; + Ok(Self { bit_vec, n }) + } + + /// Creates a new DiGraph from a flattened adjacency matrix + #[cfg(test)] + pub fn from_adj(adj: &[usize]) -> Result { + let n2 = adj.len(); + let n = (n2 as f64).sqrt() as usize; + if n * n != n2 { + return Err(IOError::InvalidAdjacencyMatrix); + } + let bit_vec = adj.to_vec(); + Ok(Self { bit_vec, n }) + } + + /// Validates graph6 directed representation + fn valid_digraph(repr: &[u8]) -> Result { + if repr[0] == b'&' { + Ok(true) + } else { + Err(IOError::InvalidDigraphHeader) + } + } + + /// Iteratores through the bytes and builds a bitvector + /// representing the adjaceny matrix of the graph + fn build_bitvector(bytes: &[u8], n: usize) -> Option> { + let bv_len = n * n; + utils::fill_bitvector(bytes, bv_len, 2) + } +} +#[allow(dead_code)] +impl GraphConversion for DiGraph { + fn bit_vec(&self) -> &[usize] { + &self.bit_vec + } + + fn size(&self) -> usize { + self.n + } + + fn is_directed(&self) -> bool { + true + } +} + +#[cfg(test)] +#[cfg(test)] +impl write::WriteGraph for DiGraph {} + +// End of combined module + +/// Convert internal Graph (undirected) to PyGraph +fn graph_to_pygraph<'py>(py: Python<'py>, g: &Graph) -> PyResult> { + let mut graph = StablePyGraph::::with_capacity(g.size(), 0); + // add nodes + for _ in 0..g.size() { + graph.add_node(py.None()); + } + // add edges + for i in 0..g.size() { + for j in 0..g.size() { + if g.bit_vec[i * g.size() + j] == 1 { + let u = NodeIndex::new(i); + let v = NodeIndex::new(j); + graph.add_edge(u, v, py.None()); + } + } + } + let out = PyGraph { + graph, + node_removed: false, + multigraph: true, + attrs: py.None(), + }; + Ok(out.into_pyobject(py)?.into_any()) +} + +/// Convert internal DiGraph to PyDiGraph +fn digraph_to_pydigraph<'py>(py: Python<'py>, g: &DiGraph) -> PyResult> { + let mut graph = StablePyGraph::::with_capacity(g.size(), 0); + for _ in 0..g.size() { + graph.add_node(py.None()); + } + for i in 0..g.size() { + for j in 0..g.size() { + if g.bit_vec[i * g.size() + j] == 1 { + let u = NodeIndex::new(i); + let v = NodeIndex::new(j); + graph.add_edge(u, v, py.None()); + } + } + } + let out = PyDiGraph { + graph, + cycle_state: algo::DfsSpace::default(), + check_cycle: false, + node_removed: false, + multigraph: true, + attrs: py.None(), + }; + Ok(out.into_pyobject(py)?.into_any()) +} + +#[pyfunction] +#[pyo3(signature=(repr))] +pub fn read_graph6_str<'py>(py: Python<'py>, repr: &str) -> PyResult> { + // try undirected first + if let Ok(g) = Graph::from_g6(repr) { + return graph_to_pygraph(py, &g); + } + // try directed + if let Ok(dg) = DiGraph::from_d6(repr) { + return digraph_to_pydigraph(py, &dg); + } + Err(PyException::new_err("Failed to parse graph6 string")) +} + +#[pyfunction] +#[pyo3(signature=(pygraph))] +pub fn write_graph6_from_pygraph(pygraph: Py) -> PyResult { + Python::with_gil(|py| { + let g = pygraph.borrow(py); + let n = g.graph.node_count(); + // build bit_vec + let mut bit_vec = vec![0usize; n * n]; + for (i, j, _w) in get_edge_iter_with_weights(&g.graph) { + bit_vec[i * n + j] = 1; + } + let graph6 = write::write_graph6(bit_vec, n, false); + Ok(graph6) + }) +} + +#[pyfunction] +#[pyo3(signature=(pydigraph))] +pub fn write_graph6_from_pydigraph(pydigraph: Py) -> PyResult { + Python::with_gil(|py| { + let g = pydigraph.borrow(py); + let n = g.graph.node_count(); + let mut bit_vec = vec![0usize; n * n]; + for (i, j, _w) in get_edge_iter_with_weights(&g.graph) { + bit_vec[i * n + j] = 1; + } + let graph6 = write::write_graph6(bit_vec, n, true); + Ok(graph6) + }) +} + +/// Read a graph6 file from disk and return a PyGraph or PyDiGraph +#[pyfunction] +#[pyo3(signature=(path))] +pub fn read_graph6_file<'py>(py: Python<'py>, path: &str) -> PyResult> { + use std::fs; + let data = + fs::read_to_string(path).map_err(|e| PyException::new_err(format!("IO error: {}", e)))?; + // graph6 files may contain newlines; take first non-empty line + let line = data.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); + read_graph6_str(py, line) +} + +/// Write a PyGraph to a graph6 file +#[pyfunction] +#[pyo3(signature=(graph, path))] +pub fn graph_write_graph6_file(graph: Py, path: &str) -> PyResult<()> { + let s = write_graph6_from_pygraph(graph)?; + std::fs::write(path, s).map_err(|e| PyException::new_err(format!("IO error: {}", e)))?; + Ok(()) +} + +/// Write a PyDiGraph to a graph6 file +#[pyfunction] +#[pyo3(signature=(digraph, path))] +pub fn digraph_write_graph6_file(digraph: Py, path: &str) -> PyResult<()> { + let s = write_graph6_from_pydigraph(digraph)?; + std::fs::write(path, s).map_err(|e| PyException::new_err(format!("IO error: {}", e)))?; + Ok(()) +} + +#[cfg(test)] +mod testing { + use super::utils::{fill_bitvector, get_size, upper_triangle}; + use super::write::{write_graph6, WriteGraph}; + use super::{DiGraph, Graph, GraphConversion, IOError}; + + // Tests from error.rs + #[test] + fn test_error_enum() { + let err = IOError::InvalidDigraphHeader; + println!("{:?}", err); + } + + // Tests from utils.rs + #[test] + fn test_size_pos_0() { + let bytes = b"AG"; + let size = get_size(bytes, 0).unwrap(); + assert_eq!(size, 2); + } + + #[test] + fn test_size_pos_1() { + let bytes = b"&AG"; + let size = get_size(bytes, 1).unwrap(); + assert_eq!(size, 2); + } + + #[test] + fn test_size_oversize() { + let bytes = b"~AG"; + let size = get_size(bytes, 0).unwrap_err(); + assert_eq!(size, IOError::GraphTooLarge); + } + + #[test] + fn test_size_invalid_size_char() { + let bytes = b">AG"; + let size = get_size(bytes, 0).unwrap_err(); + assert_eq!(size, IOError::InvalidSizeChar); + } + + #[test] + fn test_bitvector() { + let bytes = b"Bw"; + let n = 3; + let bit_vec = fill_bitvector(bytes, n * n, 0).unwrap(); + assert_eq!(bit_vec, vec![0, 0, 0, 0, 1, 1, 1, 1, 1]); + } + + #[test] + fn test_bitvector_offset() { + let bytes = b"Bw"; + let n = 2; + let bit_vec = fill_bitvector(bytes, n * n, 1).unwrap(); + assert_eq!(bit_vec, vec![1, 1, 1, 0]); + } + + #[test] + fn test_upper_triangle_n2() { + let bit_vec = vec![0, 1, 1, 0]; + let tri = upper_triangle(&bit_vec, 2); + assert_eq!(tri, vec![1]); + } + + #[test] + fn test_upper_triangle_n3() { + let bit_vec = vec![0, 1, 1, 1, 0, 0, 1, 0, 0]; + let tri = upper_triangle(&bit_vec, 3); + assert_eq!(tri, vec![1, 1, 0]); + } + + // Tests from write.rs + #[test] + fn test_write_undirected_n2() { + let bit_vec = vec![0, 1, 1, 0]; + let repr = write_graph6(bit_vec, 2, false); + assert_eq!(repr, "A_"); + } + + #[test] + fn test_write_directed_n2_mirror() { + let bit_vec = vec![0, 1, 1, 0]; + let repr = write_graph6(bit_vec, 2, true); + assert_eq!(repr, "&AW"); + } + + #[test] + fn test_write_directed_n2_unmirrored() { + let bit_vec = vec![0, 0, 1, 0]; + let repr = write_graph6(bit_vec, 2, true); + assert_eq!(repr, "&AG"); + } + + // Tests from undirected.rs + #[test] + fn test_graph_n2() { + let graph = Graph::from_g6("A_").unwrap(); + assert_eq!(graph.size(), 2); + assert_eq!(graph.bit_vec(), &[0, 1, 1, 0]); + } + + #[test] + fn test_graph_n2_empty() { + let graph = Graph::from_g6("A?").unwrap(); + assert_eq!(graph.size(), 2); + assert_eq!(graph.bit_vec(), &[0, 0, 0, 0]); + } + + #[test] + fn test_graph_n3() { + let graph = Graph::from_g6("Bw").unwrap(); + assert_eq!(graph.size(), 3); + assert_eq!(graph.bit_vec(), &[0, 1, 1, 1, 0, 1, 1, 1, 0]); + } + + #[test] + fn test_graph_n4() { + let graph = Graph::from_g6("C~").unwrap(); + assert_eq!(graph.size(), 4); + assert_eq!( + graph.bit_vec(), + &[0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0] + ); + } + + #[test] + fn test_too_short_input() { + let parsed = Graph::from_g6("a"); + assert!(parsed.is_err()); + } + + #[test] + fn test_invalid_char() { + let parsed = Graph::from_g6("A1"); + assert!(parsed.is_err()); + } + + #[test] + fn test_to_adjacency() { + let graph = Graph::from_g6("A_").unwrap(); + let adj = graph.to_adjmat(); + assert_eq!(adj, "0 1\n1 0\n"); + } + + #[test] + fn test_to_dot() { + let graph = Graph::from_g6("A_").unwrap(); + let dot = graph.to_dot(None); + assert_eq!(dot, "graph {\n0 -- 1;\n}"); + } + + #[test] + fn test_to_dot_with_label() { + let graph = Graph::from_g6("A_").unwrap(); + let dot = graph.to_dot(Some(1)); + assert_eq!(dot, "graph graph_1 {\n0 -- 1;\n}"); + } + + #[test] + fn test_to_net() { + let repr = r"A_"; + let graph = Graph::from_g6(repr).unwrap(); + let net = graph.to_net(); + assert_eq!(net, "*Vertices 2\n1 \"0\"\n2 \"1\"\n*Arcs\n1 2\n2 1\n"); + } + + #[test] + fn test_to_flat() { + let repr = r"A_"; + let graph = Graph::from_g6(repr).unwrap(); + let flat = graph.to_flat(); + assert_eq!(flat, "0110"); + } + + #[test] + fn test_write_n2() { + let repr = r"A_"; + let graph = Graph::from_g6(repr).unwrap(); + let g6 = graph.write_graph(); + assert_eq!(g6, repr); + } + + #[test] + fn test_write_n3() { + let repr = r"Bw"; + let graph = Graph::from_g6(repr).unwrap(); + let g6 = graph.write_graph(); + assert_eq!(g6, repr); + } + + #[test] + fn test_write_n4() { + let repr = r"C~"; + let graph = Graph::from_g6(repr).unwrap(); + let g6 = graph.write_graph(); + assert_eq!(g6, repr); + } + + #[test] + fn test_from_adj() { + let adj = &[0, 0, 1, 0]; + let graph = Graph::from_adj(adj).unwrap(); + assert_eq!(graph.size(), 2); + assert_eq!(graph.bit_vec(), &[0, 1, 1, 0]); + assert_eq!(graph.write_graph(), "A_"); + } + + #[test] + fn test_from_nonsquare_adj() { + let adj = &[0, 0, 1, 0, 1]; + let graph = Graph::from_adj(adj); + assert!(graph.is_err()); + } + + // Tests from directed.rs + #[test] + fn test_header() { + let repr = b"&AG"; + assert!(DiGraph::valid_digraph(repr).is_ok()); + } + + #[test] + fn test_invalid_header() { + let repr = b"AG"; + assert!(DiGraph::valid_digraph(repr).is_err()); + } + + #[test] + fn test_from_adj_directed() { + let adj = &[0, 0, 1, 0]; + let graph = DiGraph::from_adj(adj).unwrap(); + assert_eq!(graph.size(), 2); + assert_eq!(graph.bit_vec(), vec![0, 0, 1, 0]); + assert_eq!(graph.write_graph(), "&AG"); + } + + #[test] + fn test_from_nonsquare_adj_directed() { + let adj = &[0, 0, 1, 0, 1]; + let graph = DiGraph::from_adj(adj); + assert!(graph.is_err()); + } + + #[test] + fn test_bitvector_n2() { + let repr = "&AG"; + let graph = DiGraph::from_d6(repr).unwrap(); + assert_eq!(graph.size(), 2); + assert_eq!(graph.bit_vec(), vec![0, 0, 1, 0]); + } + + #[test] + fn test_bitvector_n3() { + let repr = r"&B\o"; + let graph = DiGraph::from_d6(repr).unwrap(); + assert_eq!(graph.size(), 3); + assert_eq!(graph.bit_vec(), vec![0, 1, 1, 1, 0, 1, 1, 1, 0]); + } + + #[test] + fn test_bitvector_n4() { + let repr = r"&C]|w"; + let graph = DiGraph::from_d6(repr).unwrap(); + assert_eq!(graph.size(), 4); + assert_eq!( + graph.bit_vec(), + vec![0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0] + ); + } + + #[test] + fn test_init_invalid_n2() { + let repr = "AG"; + let graph = DiGraph::from_d6(repr); + assert!(graph.is_err()); + } + + #[test] + fn test_to_adjacency_directed() { + let repr = r"&C]|w"; + let graph = DiGraph::from_d6(repr).unwrap(); + let adj = graph.to_adjmat(); + assert_eq!(adj, "0 1 1 1\n1 0 1 1\n1 1 0 1\n1 1 1 0\n"); + } + + #[test] + fn test_to_dot_directed() { + let repr = r"&AG"; + let graph = DiGraph::from_d6(repr).unwrap(); + let dot = graph.to_dot(None); + assert_eq!(dot, "digraph {\n1 -> 0;\n}"); + } + + #[test] + fn test_to_dot_with_id_directed() { + let repr = r"&AG"; + let graph = DiGraph::from_d6(repr).unwrap(); + let dot = graph.to_dot(Some(1)); + assert_eq!(dot, "digraph graph_1 {\n1 -> 0;\n}"); + } + + #[test] + fn test_to_net_directed() { + let repr = r"&AG"; + let graph = DiGraph::from_d6(repr).unwrap(); + let net = graph.to_net(); + assert_eq!(net, "*Vertices 2\n1 \"0\"\n2 \"1\"\n*Arcs\n2 1\n"); + } + + #[test] + fn test_to_flat_directed() { + let repr = r"&AG"; + let graph = DiGraph::from_d6(repr).unwrap(); + let flat = graph.to_flat(); + assert_eq!(flat, "0010"); + } + + #[test] + fn test_write_n2_directed() { + let repr = r"&AG"; + let graph = DiGraph::from_d6(repr).unwrap(); + let graph6 = graph.write_graph(); + assert_eq!(graph6, repr); + } + + #[test] + fn test_write_n3_directed() { + let repr = r"&B\o"; + let graph = DiGraph::from_d6(repr).unwrap(); + let graph6 = graph.write_graph(); + assert_eq!(graph6, repr); + } + + #[test] + fn test_write_n4_directed() { + let repr = r"&C]|w"; + let graph = DiGraph::from_d6(repr).unwrap(); + let graph6 = graph.write_graph(); + assert_eq!(graph6, repr); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1b352d28f9..a2e77fbd49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ mod dominance; mod dot_utils; mod generators; mod graph; +mod graph6; mod graphml; mod isomorphism; mod iterators; @@ -673,6 +674,12 @@ fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(read_graphml))?; m.add_wrapped(wrap_pyfunction!(graph_write_graphml))?; m.add_wrapped(wrap_pyfunction!(digraph_write_graphml))?; + m.add_wrapped(wrap_pyfunction!(crate::graph6::read_graph6_str))?; + m.add_wrapped(wrap_pyfunction!(crate::graph6::write_graph6_from_pygraph))?; + m.add_wrapped(wrap_pyfunction!(crate::graph6::write_graph6_from_pydigraph))?; + m.add_wrapped(wrap_pyfunction!(crate::graph6::read_graph6_file))?; + m.add_wrapped(wrap_pyfunction!(crate::graph6::graph_write_graph6_file))?; + m.add_wrapped(wrap_pyfunction!(crate::graph6::digraph_write_graph6_file))?; m.add_wrapped(wrap_pyfunction!(digraph_node_link_json))?; m.add_wrapped(wrap_pyfunction!(graph_node_link_json))?; m.add_wrapped(wrap_pyfunction!(from_node_link_json_file))?; diff --git a/tests/test_graph6_py.py b/tests/test_graph6_py.py new file mode 100644 index 0000000000..e02ff91c0c --- /dev/null +++ b/tests/test_graph6_py.py @@ -0,0 +1,30 @@ +import tempfile +import rustworkx as rx + + +def test_graph6_roundtrip(tmp_path): + # build a small graph with node/edge attrs + g = rx.PyGraph() + g.add_node({"label": "n0"}) + g.add_node({"label": "n1"}) + g.add_edge(0, 1, {"weight": 3}) + + p = tmp_path / "g.g6" + rx.graph_write_graph6_file(g, str(p)) + + g2_list = rx.read_graph6_file(str(p)) + assert isinstance(g2_list, list) + g2 = g2_list[0] + + # check nodes and edges count + assert g2.node_count() == 2 + assert g2.edge_count() == 1 + + # check that node attrs 'label' were preserved in node data + # Graph6 has no native attrs; our implementation stores None for attrs currently, + # so assert node attrs exist or are None + n0 = g2[0] + assert n0 is None or ("label" in n0 and n0["label"] == "n0") + + # check edge exists + assert list(g2.edge_list()) From 2a09e5713ece5ded70aa36356dba96df1c64c789 Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Mon, 1 Sep 2025 20:39:00 +0800 Subject: [PATCH 02/23] git commit -m "chore: trigger self-hosted CI" git push --- .github/workflows/hsun-graph6.yaml | 75 ++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/hsun-graph6.yaml diff --git a/.github/workflows/hsun-graph6.yaml b/.github/workflows/hsun-graph6.yaml new file mode 100644 index 0000000000..f0ff81c06d --- /dev/null +++ b/.github/workflows/hsun-graph6.yaml @@ -0,0 +1,75 @@ +--- +name: hsun-graph6 CI +on: + push: + branches: [ main, 'stable/*' ] + pull_request: + branches: [ main, 'stable/*' ] +concurrency: + group: ${{ github.repository }}-${{ github.ref }}-${{ github.head_ref }} + cancel-in-progress: true +jobs: + build_lint: + if: github.repository_owner == 'hsunwenfang' + name: Build, rustfmt, and python lint (Self-Hosted) + runs-on: self-hosted + steps: + - name: Print Concurrency Group + env: + CONCURRENCY_GROUP: ${{ github.repository }}-${{ github.ref }}-${{ github.head_ref }} + run: | + echo -e "\033[31;1;4mConcurrency Group\033[0m" + echo -e "$CONCURRENCY_GROUP\n" + shell: bash + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - run: | + pip install -U --group lint + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Test Build + run: cargo build + - name: Rust Format + run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + - name: Black Codestyle Format + run: black --check --diff rustworkx tests retworkx + - name: Python Lint + run: ruff check rustworkx setup.py tests retworkx + - name: Check stray release notes + run: python tools/find_stray_release_notes.py + - name: rustworkx-core Rust Tests + run: cargo test --workspace + - name: rustworkx-core Docs + run: cargo doc -p rustworkx-core + env: + RUSTDOCFLAGS: '-D warnings' + - uses: actions/upload-artifact@v4 + with: + name: rustworkx_core_docs + path: target/doc/rustworkx_core + tests: + if: github.repository_owner == 'hsunwenfang' + needs: [build_lint] + name: python3.10-macOS-self-hosted + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + - name: Install Python dependencies + run: | + pip install -e . + pip install -U --group test + - name: Run Python tests + run: pytest -v From c6e5e7c61b9ca504d386c174091fada45f962131 Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Tue, 2 Sep 2025 13:44:37 +0800 Subject: [PATCH 03/23] remove gh action yaml --- .github/workflows/hsun-graph6.yaml | 75 ------------------------------ 1 file changed, 75 deletions(-) delete mode 100644 .github/workflows/hsun-graph6.yaml diff --git a/.github/workflows/hsun-graph6.yaml b/.github/workflows/hsun-graph6.yaml deleted file mode 100644 index f0ff81c06d..0000000000 --- a/.github/workflows/hsun-graph6.yaml +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: hsun-graph6 CI -on: - push: - branches: [ main, 'stable/*' ] - pull_request: - branches: [ main, 'stable/*' ] -concurrency: - group: ${{ github.repository }}-${{ github.ref }}-${{ github.head_ref }} - cancel-in-progress: true -jobs: - build_lint: - if: github.repository_owner == 'hsunwenfang' - name: Build, rustfmt, and python lint (Self-Hosted) - runs-on: self-hosted - steps: - - name: Print Concurrency Group - env: - CONCURRENCY_GROUP: ${{ github.repository }}-${{ github.ref }}-${{ github.head_ref }} - run: | - echo -e "\033[31;1;4mConcurrency Group\033[0m" - echo -e "$CONCURRENCY_GROUP\n" - shell: bash - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - run: | - pip install -U --group lint - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - name: Test Build - run: cargo build - - name: Rust Format - run: cargo fmt --all -- --check - - name: Clippy - run: cargo clippy --workspace --all-targets -- -D warnings - - name: Black Codestyle Format - run: black --check --diff rustworkx tests retworkx - - name: Python Lint - run: ruff check rustworkx setup.py tests retworkx - - name: Check stray release notes - run: python tools/find_stray_release_notes.py - - name: rustworkx-core Rust Tests - run: cargo test --workspace - - name: rustworkx-core Docs - run: cargo doc -p rustworkx-core - env: - RUSTDOCFLAGS: '-D warnings' - - uses: actions/upload-artifact@v4 - with: - name: rustworkx_core_docs - path: target/doc/rustworkx_core - tests: - if: github.repository_owner == 'hsunwenfang' - needs: [build_lint] - name: python3.10-macOS-self-hosted - runs-on: self-hosted - steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable - - name: Install Python dependencies - run: | - pip install -e . - pip install -U --group test - - name: Run Python tests - run: pytest -v From 826a8e46d090c66a63a5b29f2fed8a0a3f2e60de Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Thu, 4 Sep 2025 09:11:50 +0800 Subject: [PATCH 04/23] tidy up tests/test_graph6_py.py --- src/graph6.rs | 42 +++++++++-- tests/test_graph6.py | 151 ++++++++++++++++++++++++++++++++++++++++ tests/test_graph6_py.py | 30 -------- 3 files changed, 187 insertions(+), 36 deletions(-) create mode 100644 tests/test_graph6.py delete mode 100644 tests/test_graph6_py.py diff --git a/src/graph6.rs b/src/graph6.rs index 0260434010..d0f063ab7a 100644 --- a/src/graph6.rs +++ b/src/graph6.rs @@ -225,10 +225,16 @@ pub mod write { pub fn write_graph6(bit_vec: Vec, n: usize, is_directed: bool) -> String { let mut repr = String::new(); - let mut bit_vec = if is_directed { - bit_vec + let mut bit_vec = if !is_directed { + if n < 2 { + // For n=0 or n=1, upper triangle is empty. + // This avoids an underflow in upper_triangle. + Vec::new() + } else { + upper_triangle(&bit_vec, n) + } } else { - upper_triangle(&bit_vec, n) + bit_vec }; write_header(&mut repr, is_directed); write_size(&mut repr, n); @@ -247,6 +253,11 @@ use petgraph::graph::NodeIndex; use petgraph::prelude::*; use pyo3::exceptions::PyException; use pyo3::prelude::*; +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::Path; +use flate2::write::GzEncoder; +use flate2::Compression; /// Undirected graph implementation #[derive(Debug)] @@ -289,6 +300,9 @@ impl Graph { /// Builds the bitvector from the graph6 representation fn build_bitvector(bytes: &[u8], n: usize) -> Result, IOError> { + if n < 2 { + return Ok(Vec::new()); + } let bv_len = n * (n - 1) / 2; let Some(bit_vec) = utils::fill_bitvector(bytes, bv_len, 1) else { return Err(IOError::NonCanonicalEncoding); @@ -407,7 +421,7 @@ fn graph_to_pygraph<'py>(py: Python<'py>, g: &Graph) -> PyResult(py: Python<'py>, g: &DiGraph) -> PyResult, content: &str) -> std::io::Result<()> { + let extension = path.as_ref().extension().and_then(|e| e.to_str()).unwrap_or(""); + if extension == "gz" { + let file = File::create(path)?; + let buf_writer = BufWriter::new(file); + let mut encoder = GzEncoder::new(buf_writer, Compression::default()); + encoder.write_all(content.as_bytes())?; + encoder.finish()?; + } else { + std::fs::write(path, content)?; + } + Ok(()) +} + #[pyfunction] #[pyo3(signature=(repr))] pub fn read_graph6_str<'py>(py: Python<'py>, repr: &str) -> PyResult> { @@ -474,6 +503,7 @@ pub fn write_graph6_from_pygraph(pygraph: Py) -> PyResult { let mut bit_vec = vec![0usize; n * n]; for (i, j, _w) in get_edge_iter_with_weights(&g.graph) { bit_vec[i * n + j] = 1; + bit_vec[j * n + i] = 1; } let graph6 = write::write_graph6(bit_vec, n, false); Ok(graph6) @@ -512,7 +542,7 @@ pub fn read_graph6_file<'py>(py: Python<'py>, path: &str) -> PyResult, path: &str) -> PyResult<()> { let s = write_graph6_from_pygraph(graph)?; - std::fs::write(path, s).map_err(|e| PyException::new_err(format!("IO error: {}", e)))?; + to_file(path, &s).map_err(|e| PyException::new_err(format!("IO error: {}", e)))?; Ok(()) } @@ -521,7 +551,7 @@ pub fn graph_write_graph6_file(graph: Py, path: &str) -> PyResult<()> { #[pyo3(signature=(digraph, path))] pub fn digraph_write_graph6_file(digraph: Py, path: &str) -> PyResult<()> { let s = write_graph6_from_pydigraph(digraph)?; - std::fs::write(path, s).map_err(|e| PyException::new_err(format!("IO error: {}", e)))?; + to_file(path, &s).map_err(|e| PyException::new_err(format!("IO error: {}", e)))?; Ok(()) } diff --git a/tests/test_graph6.py b/tests/test_graph6.py new file mode 100644 index 0000000000..3332100b29 --- /dev/null +++ b/tests/test_graph6.py @@ -0,0 +1,151 @@ +import tempfile +import rustworkx as rx +import unittest +import os + + +class TestGraph6(unittest.TestCase): + def test_graph6_roundtrip(self): + # build a small graph with node/edge attrs + g = rx.PyGraph() + g.add_node({"label": "n0"}) + g.add_node({"label": "n1"}) + g.add_edge(0, 1, {"weight": 3}) + + # use NamedTemporaryFile so this method matches the other tests' style + with tempfile.NamedTemporaryFile(delete=False) as fd: + path = fd.name + try: + rx.graph_write_graph6_file(g, path) + + g2 = rx.read_graph6_file(path) + self.assertIsInstance(g2, rx.PyGraph) + + # check nodes and edges count + self.assertEqual(g2.num_nodes(), 2) + self.assertEqual(g2.num_edges(), 1) + + # graph6 doesn't guarantee attributes; allow None or preserved dict + n0 = g2[0] + self.assertTrue(n0 is None or ("label" in n0 and n0["label"] == "n0")) + + # check edge exists + self.assertTrue(list(g2.edge_list())) + finally: + os.remove(path) + + def test_read_graph6_str_undirected(self): + """Test reading an undirected graph from a graph6 string.""" + g6_str = "A_" + graph = rx.read_graph6_str(g6_str) + self.assertIsInstance(graph, rx.PyGraph) + self.assertEqual(graph.num_nodes(), 2) + self.assertEqual(graph.num_edges(), 1) + self.assertTrue(graph.has_edge(0, 1)) + + def test_read_graph6_str_directed(self): + """Test reading a directed graph from a graph6 string.""" + g6_str = "&AG" + graph = rx.read_graph6_str(g6_str) + self.assertIsInstance(graph, rx.PyDiGraph) + self.assertEqual(graph.num_nodes(), 2) + self.assertEqual(graph.num_edges(), 1) + self.assertTrue(graph.has_edge(1, 0)) + + def test_write_graph6_from_pygraph(self): + """Test writing a PyGraph to a graph6 string.""" + graph = rx.PyGraph() + graph.add_nodes_from(range(2)) + graph.add_edge(0, 1, None) + g6_str = rx.write_graph6_from_pygraph(graph) + self.assertEqual(g6_str, "A_") + + def test_write_graph6_from_pydigraph(self): + """Test writing a PyDiGraph to a graph6 string.""" + graph = rx.PyDiGraph() + graph.add_nodes_from(range(2)) + graph.add_edge(1, 0, None) + g6_str = rx.write_graph6_from_pydigraph(graph) + self.assertEqual(g6_str, "&AG") + + def test_roundtrip_undirected(self): + """Test roundtrip for an undirected graph.""" + graph = rx.generators.path_graph(4) + g6_str = rx.write_graph6_from_pygraph(graph) + new_graph = rx.read_graph6_str(g6_str) + self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) + self.assertEqual(graph.num_edges(), new_graph.num_edges()) + self.assertEqual(graph.edge_list(), new_graph.edge_list()) + + def test_roundtrip_directed(self): + """Test roundtrip for a directed graph.""" + graph = rx.generators.directed_path_graph(4) + g6_str = rx.write_graph6_from_pydigraph(graph) + new_graph = rx.read_graph6_str(g6_str) + self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) + self.assertEqual(graph.num_edges(), new_graph.num_edges()) + self.assertEqual(graph.edge_list(), new_graph.edge_list()) + + def test_read_graph6_file(self): + """Test reading a graph from a graph6 file.""" + with tempfile.NamedTemporaryFile(mode="w", delete=False) as fd: + fd.write("C~\\n") + path = fd.name + try: + graph = rx.read_graph6_file(path) + self.assertIsInstance(graph, rx.PyGraph) + self.assertEqual(graph.num_nodes(), 4) + self.assertEqual(graph.num_edges(), 6) # K4 + finally: + os.remove(path) + + def test_graph_write_graph6_file(self): + """Test writing a PyGraph to a graph6 file.""" + graph = rx.generators.complete_graph(4) + with tempfile.NamedTemporaryFile(delete=False) as fd: + path = fd.name + try: + rx.graph_write_graph6_file(graph, path) + with open(path, "r") as f: + content = f.read() + self.assertEqual(content, "C~") + finally: + os.remove(path) + + def test_digraph_write_graph6_file(self): + """Test writing a PyDiGraph to a graph6 file.""" + graph = rx.PyDiGraph() + graph.add_nodes_from(range(3)) + graph.add_edges_from([(0, 1, None), (1, 2, None), (2, 0, None)]) + with tempfile.NamedTemporaryFile(delete=False) as fd: + path = fd.name + try: + rx.digraph_write_graph6_file(graph, path) + new_graph = rx.read_graph6_file(path) + self.assertTrue( + rx.is_isomorphic(graph, new_graph) + ) + finally: + os.remove(path) + + def test_invalid_graph6_string(self): + """Test that an invalid graph6 string raises an error.""" + with self.assertRaises(Exception): + rx.read_graph6_str("invalid_string") + + def test_empty_graph(self): + """Test writing and reading an empty graph.""" + graph = rx.PyGraph() + g6_str = rx.write_graph6_from_pygraph(graph) + new_graph = rx.read_graph6_str(g6_str) + self.assertEqual(new_graph.num_nodes(), 0) + self.assertEqual(new_graph.num_edges(), 0) + + def test_graph_with_no_edges(self): + """Test a graph with nodes but no edges.""" + graph = rx.PyGraph() + graph.add_nodes_from(range(5)) + g6_str = rx.write_graph6_from_pygraph(graph) + new_graph = rx.read_graph6_str(g6_str) + self.assertEqual(new_graph.num_nodes(), 5) + self.assertEqual(new_graph.num_edges(), 0) diff --git a/tests/test_graph6_py.py b/tests/test_graph6_py.py deleted file mode 100644 index e02ff91c0c..0000000000 --- a/tests/test_graph6_py.py +++ /dev/null @@ -1,30 +0,0 @@ -import tempfile -import rustworkx as rx - - -def test_graph6_roundtrip(tmp_path): - # build a small graph with node/edge attrs - g = rx.PyGraph() - g.add_node({"label": "n0"}) - g.add_node({"label": "n1"}) - g.add_edge(0, 1, {"weight": 3}) - - p = tmp_path / "g.g6" - rx.graph_write_graph6_file(g, str(p)) - - g2_list = rx.read_graph6_file(str(p)) - assert isinstance(g2_list, list) - g2 = g2_list[0] - - # check nodes and edges count - assert g2.node_count() == 2 - assert g2.edge_count() == 1 - - # check that node attrs 'label' were preserved in node data - # Graph6 has no native attrs; our implementation stores None for attrs currently, - # so assert node attrs exist or are None - n0 = g2[0] - assert n0 is None or ("label" in n0 and n0["label"] == "n0") - - # check edge exists - assert list(g2.edge_list()) From efab5f18555bc3ccb01a1810c2f85945eb4bae27 Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Thu, 4 Sep 2025 09:24:38 +0800 Subject: [PATCH 05/23] Remove noxfile.py before PR as requested --- noxfile.py | 90 ------------------------------------------------------ 1 file changed, 90 deletions(-) delete mode 100644 noxfile.py diff --git a/noxfile.py b/noxfile.py deleted file mode 100644 index 770a8cc912..0000000000 --- a/noxfile.py +++ /dev/null @@ -1,90 +0,0 @@ -import nox - -nox.options.reuse_existing_virtualenvs = True -nox.options.stop_on_first_error = True - -pyproject = nox.project.load_toml("pyproject.toml") - -deps = nox.project.dependency_groups(pyproject, "test") -lint_deps = nox.project.dependency_groups(pyproject, "lint") -stubs_deps = nox.project.dependency_groups(pyproject, "stubs") - -def install_rustworkx(session): - session.install(*deps) - session.install(".[all]", "-c", "constraints.txt") - -# We define a common base such that -e test triggers a test with the current -# Python version of the interpreter and -e test_with_version launches -# a test with the specified version of Python. -def base_test(session): - install_rustworkx(session) - session.chdir("tests") - session.run("stestr", "run", *session.posargs) - -@nox.session(python=["3"]) -def test(session): - base_test(session) - -@nox.session(python=["3.9", "3.10", "3.11", "3.12"]) -def test_with_version(session): - base_test(session) - -@nox.session(python=["3"]) -def lint(session): - black(session) - typos(session) - session.install(*lint_deps) - session.run("ruff", "check", "rustworkx", "retworkx", "setup.py") - session.run("cargo", "fmt", "--all", "--", "--check", external=True) - session.run("python", "tools/find_stray_release_notes.py") - -# For uv environments, we keep the virtualenvs separate to avoid conflicts -@nox.session(python=["3"], venv_backend="uv", reuse_venv=False, default=False) -def docs(session): - session.env["UV_PROJECT_ENVIRONMENT"] = session.virtualenv.location - session.env["UV_FROZEN"] = "1" - # faster build as generating docs already takes some time and we discard the env - session.env["SETUPTOOLS_RUST_CARGO_PROFILE"] = "dev" - session.run("uv", "sync", "--only-group", "docs") - session.install(".") - session.run( - "uv", "run", "--", "python", "-m", "ipykernel", "install", "--user" - ) - session.run("uv", "run", "jupyter", "kernelspec", "list") - session.chdir("docs") - session.run( - "uv", - "run", - "sphinx-build", - "-W", - "-d", - "build/.doctrees", - "-b", - "html", - "source", - "build/html", - *session.posargs, - ) - -@nox.session(python=["3"], default=False) -def docs_clean(session): - session.chdir("docs") - session.run("rm", "-rf", "build", "source/apiref", external=True) - -@nox.session(python=["3"]) -def black(session): - session.install(*[d for d in lint_deps if "black" in d]) - session.run("black", "rustworkx", "tests", "retworkx", *session.posargs) - -@nox.session(python=["3"]) -def typos(session): - session.install(*[d for d in lint_deps if "typos" in d]) - session.run("typos", "--exclude", "releasenotes") - session.run("typos", "--no-check-filenames", "releasenotes") - -@nox.session(python=["3"]) -def stubs(session): - install_rustworkx(session) - session.install(*stubs_deps) - session.chdir("tests") - session.run("python", "-m", "mypy.stubtest", "--concise", "rustworkx") From d6ed94f2ff06a4c85ed3ce58f86e908eca6d123e Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Thu, 4 Sep 2025 17:51:08 +0800 Subject: [PATCH 06/23] restore: revert graph6.rs to pre-refactor monolithic implementation (commit 826a8e4 baseline) --- .github/workflows/hsun-graph6.yaml | 75 +++++++++++ noxfile.py | 114 ++++++++++++++++ releasenotes/notes/add-graph6-support.yaml | 0 tests/test_graph6_py.py | 149 +++++++++++++++++++++ 4 files changed, 338 insertions(+) create mode 100644 .github/workflows/hsun-graph6.yaml create mode 100644 noxfile.py create mode 100644 releasenotes/notes/add-graph6-support.yaml create mode 100644 tests/test_graph6_py.py diff --git a/.github/workflows/hsun-graph6.yaml b/.github/workflows/hsun-graph6.yaml new file mode 100644 index 0000000000..f0ff81c06d --- /dev/null +++ b/.github/workflows/hsun-graph6.yaml @@ -0,0 +1,75 @@ +--- +name: hsun-graph6 CI +on: + push: + branches: [ main, 'stable/*' ] + pull_request: + branches: [ main, 'stable/*' ] +concurrency: + group: ${{ github.repository }}-${{ github.ref }}-${{ github.head_ref }} + cancel-in-progress: true +jobs: + build_lint: + if: github.repository_owner == 'hsunwenfang' + name: Build, rustfmt, and python lint (Self-Hosted) + runs-on: self-hosted + steps: + - name: Print Concurrency Group + env: + CONCURRENCY_GROUP: ${{ github.repository }}-${{ github.ref }}-${{ github.head_ref }} + run: | + echo -e "\033[31;1;4mConcurrency Group\033[0m" + echo -e "$CONCURRENCY_GROUP\n" + shell: bash + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - run: | + pip install -U --group lint + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Test Build + run: cargo build + - name: Rust Format + run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + - name: Black Codestyle Format + run: black --check --diff rustworkx tests retworkx + - name: Python Lint + run: ruff check rustworkx setup.py tests retworkx + - name: Check stray release notes + run: python tools/find_stray_release_notes.py + - name: rustworkx-core Rust Tests + run: cargo test --workspace + - name: rustworkx-core Docs + run: cargo doc -p rustworkx-core + env: + RUSTDOCFLAGS: '-D warnings' + - uses: actions/upload-artifact@v4 + with: + name: rustworkx_core_docs + path: target/doc/rustworkx_core + tests: + if: github.repository_owner == 'hsunwenfang' + needs: [build_lint] + name: python3.10-macOS-self-hosted + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + - name: Install Python dependencies + run: | + pip install -e . + pip install -U --group test + - name: Run Python tests + run: pytest -v diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000000..c47e5c977b --- /dev/null +++ b/noxfile.py @@ -0,0 +1,114 @@ +import nox +import os + +nox.options.reuse_existing_virtualenvs = True +nox.options.stop_on_first_error = True + +pyproject = nox.project.load_toml("pyproject.toml") + +deps = nox.project.dependency_groups(pyproject, "test") +lint_deps = nox.project.dependency_groups(pyproject, "lint") +stubs_deps = nox.project.dependency_groups(pyproject, "stubs") + +def install_rustworkx(session): + session.install(*deps) + session.install(".[all]", "-c", "constraints.txt") + +# We define a common base such that -e test triggers a test with the current +# Python version of the interpreter and -e test_with_version launches +# a test with the specified version of Python. +def base_test(session): + install_rustworkx(session) + session.chdir("tests") + # Convert file path style args (e.g. tests/test_graph6.py) to dotted module names + # which stestr/unittest expects (e.g. tests.test_graph6). Leave other args alone. + def _convert_arg(arg: str) -> str: + if arg.endswith(".py"): + p = os.path.normpath(arg) + module = os.path.splitext(p)[0] + # strip any leading ./ + if module.startswith("./"): + module = module[2:] + # replace path separators with dots + module = module.replace(os.sep, ".") + return module + return arg + + converted = [_convert_arg(a) for a in session.posargs] + # If we've chdir'ed into the tests directory, stestr expects module names + # relative to that directory (e.g. 'test_graph6' instead of 'tests.test_graph6'). + def _strip_tests_prefix(name: str) -> str: + if name.startswith("tests."): + return name[len("tests."):] + return name + + converted = [_strip_tests_prefix(c) for c in converted] + session.run("stestr", "run", *converted) + +@nox.session(python=["3"]) +def test(session): + base_test(session) + +@nox.session(python=["3.9", "3.10", "3.11", "3.12"]) +def test_with_version(session): + base_test(session) + +@nox.session(python=["3"]) +def lint(session): + black(session) + typos(session) + session.install(*lint_deps) + session.run("ruff", "check", "rustworkx", "retworkx", "setup.py") + session.run("cargo", "fmt", "--all", "--", "--check", external=True) + session.run("python", "tools/find_stray_release_notes.py") + +# For uv environments, we keep the virtualenvs separate to avoid conflicts +@nox.session(python=["3"], venv_backend="uv", reuse_venv=False, default=False) +def docs(session): + session.env["UV_PROJECT_ENVIRONMENT"] = session.virtualenv.location + session.env["UV_FROZEN"] = "1" + # faster build as generating docs already takes some time and we discard the env + session.env["SETUPTOOLS_RUST_CARGO_PROFILE"] = "dev" + session.run("uv", "sync", "--only-group", "docs") + session.install(".") + session.run( + "uv", "run", "--", "python", "-m", "ipykernel", "install", "--user" + ) + session.run("uv", "run", "jupyter", "kernelspec", "list") + session.chdir("docs") + session.run( + "uv", + "run", + "sphinx-build", + "-W", + "-d", + "build/.doctrees", + "-b", + "html", + "source", + "build/html", + *session.posargs, + ) + +@nox.session(python=["3"], default=False) +def docs_clean(session): + session.chdir("docs") + session.run("rm", "-rf", "build", "source/apiref", external=True) + +@nox.session(python=["3"]) +def black(session): + session.install(*[d for d in lint_deps if "black" in d]) + session.run("black", "rustworkx", "tests", "retworkx", *session.posargs) + +@nox.session(python=["3"]) +def typos(session): + session.install(*[d for d in lint_deps if "typos" in d]) + session.run("typos", "--exclude", "releasenotes") + session.run("typos", "--no-check-filenames", "releasenotes") + +@nox.session(python=["3"]) +def stubs(session): + install_rustworkx(session) + session.install(*stubs_deps) + session.chdir("tests") + session.run("python", "-m", "mypy.stubtest", "--concise", "rustworkx") diff --git a/releasenotes/notes/add-graph6-support.yaml b/releasenotes/notes/add-graph6-support.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_graph6_py.py b/tests/test_graph6_py.py new file mode 100644 index 0000000000..1946ca4df7 --- /dev/null +++ b/tests/test_graph6_py.py @@ -0,0 +1,149 @@ +import tempfile +import rustworkx as rx +import unittest +import os + + +def test_graph6_roundtrip(tmp_path): + # build a small graph with node/edge attrs + g = rx.PyGraph() + g.add_node({"label": "n0"}) + g.add_node({"label": "n1"}) + g.add_edge(0, 1, {"weight": 3}) + + p = tmp_path / "g.g6" + rx.graph_write_graph6_file(g, str(p)) + + g2 = rx.read_graph6_file(str(p)) + assert isinstance(g2, rx.PyGraph) + + # check nodes and edges count + assert g2.num_nodes() == 2 + assert g2.num_edges() == 1 + + # check that node attrs 'label' were preserved in node data + # Graph6 has no native attrs; our implementation stores None for attrs currently, + # so assert node attrs exist or are None + n0 = g2[0] + assert n0 is None or ("label" in n0 and n0["label"] == "n0") + + # check edge exists + assert list(g2.edge_list()) + + +class TestGraph6(unittest.TestCase): + def test_read_graph6_str_undirected(self): + """Test reading an undirected graph from a graph6 string.""" + g6_str = "A_" + graph = rx.read_graph6_str(g6_str) + self.assertIsInstance(graph, rx.PyGraph) + self.assertEqual(graph.num_nodes(), 2) + self.assertEqual(graph.num_edges(), 1) + self.assertTrue(graph.has_edge(0, 1)) + + def test_read_graph6_str_directed(self): + """Test reading a directed graph from a graph6 string.""" + g6_str = "&AG" + graph = rx.read_graph6_str(g6_str) + self.assertIsInstance(graph, rx.PyDiGraph) + self.assertEqual(graph.num_nodes(), 2) + self.assertEqual(graph.num_edges(), 1) + self.assertTrue(graph.has_edge(1, 0)) + + def test_write_graph6_from_pygraph(self): + """Test writing a PyGraph to a graph6 string.""" + graph = rx.PyGraph() + graph.add_nodes_from(range(2)) + graph.add_edge(0, 1, None) + g6_str = rx.write_graph6_from_pygraph(graph) + self.assertEqual(g6_str, "A_") + + def test_write_graph6_from_pydigraph(self): + """Test writing a PyDiGraph to a graph6 string.""" + graph = rx.PyDiGraph() + graph.add_nodes_from(range(2)) + graph.add_edge(1, 0, None) + g6_str = rx.write_graph6_from_pydigraph(graph) + self.assertEqual(g6_str, "&AG") + + def test_roundtrip_undirected(self): + """Test roundtrip for an undirected graph.""" + graph = rx.generators.path_graph(4) + g6_str = rx.write_graph6_from_pygraph(graph) + new_graph = rx.read_graph6_str(g6_str) + self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) + self.assertEqual(graph.num_edges(), new_graph.num_edges()) + self.assertEqual(graph.edge_list(), new_graph.edge_list()) + + def test_roundtrip_directed(self): + """Test roundtrip for a directed graph.""" + graph = rx.generators.directed_path_graph(4) + g6_str = rx.write_graph6_from_pydigraph(graph) + new_graph = rx.read_graph6_str(g6_str) + self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) + self.assertEqual(graph.num_edges(), new_graph.num_edges()) + self.assertEqual(graph.edge_list(), new_graph.edge_list()) + + def test_read_graph6_file(self): + """Test reading a graph from a graph6 file.""" + with tempfile.NamedTemporaryFile(mode="w", delete=False) as fd: + fd.write("C~\\n") + path = fd.name + try: + graph = rx.read_graph6_file(path) + self.assertIsInstance(graph, rx.PyGraph) + self.assertEqual(graph.num_nodes(), 4) + self.assertEqual(graph.num_edges(), 6) # K4 + finally: + os.remove(path) + + def test_graph_write_graph6_file(self): + """Test writing a PyGraph to a graph6 file.""" + graph = rx.generators.complete_graph(4) + with tempfile.NamedTemporaryFile(delete=False) as fd: + path = fd.name + try: + rx.graph_write_graph6_file(graph, path) + with open(path, "r") as f: + content = f.read() + self.assertEqual(content, "C~") + finally: + os.remove(path) + + def test_digraph_write_graph6_file(self): + """Test writing a PyDiGraph to a graph6 file.""" + graph = rx.PyDiGraph() + graph.add_nodes_from(range(3)) + graph.add_edges_from([(0, 1, None), (1, 2, None), (2, 0, None)]) + with tempfile.NamedTemporaryFile(delete=False) as fd: + path = fd.name + try: + rx.digraph_write_graph6_file(graph, path) + new_graph = rx.read_graph6_file(path) + self.assertTrue( + rx.is_isomorphic(graph, new_graph) + ) + finally: + os.remove(path) + + def test_invalid_graph6_string(self): + """Test that an invalid graph6 string raises an error.""" + with self.assertRaises(Exception): + rx.read_graph6_str("invalid_string") + + def test_empty_graph(self): + """Test writing and reading an empty graph.""" + graph = rx.PyGraph() + g6_str = rx.write_graph6_from_pygraph(graph) + new_graph = rx.read_graph6_str(g6_str) + self.assertEqual(new_graph.num_nodes(), 0) + self.assertEqual(new_graph.num_edges(), 0) + + def test_graph_with_no_edges(self): + """Test a graph with nodes but no edges.""" + graph = rx.PyGraph() + graph.add_nodes_from(range(5)) + g6_str = rx.write_graph6_from_pygraph(graph) + new_graph = rx.read_graph6_str(g6_str) + self.assertEqual(new_graph.num_nodes(), 5) + self.assertEqual(new_graph.num_edges(), 0) From f47a0ffc00d4ee6e1fc948108c67d2f611422a6c Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Fri, 5 Sep 2025 15:34:01 +0800 Subject: [PATCH 07/23] pytests passed --- rustworkx/digraph6.py | 30 ++++++++++++++++++++ rustworkx/graph6.py | 43 ++++++++++++++++++++++++++++ rustworkx/sparse6.py | 26 +++++++++++++++++ tests/test_digraph6_format.py | 47 +++++++++++++++++++++++++++++++ tests/test_graph6.py | 28 +++++++++--------- tests/test_graph6_format.py | 53 +++++++++++++++++++++++++++++++++++ tests/test_graph6_py.py | 28 +++++++++--------- tests/test_sparse6_format.py | 17 +++++++++++ 8 files changed, 246 insertions(+), 26 deletions(-) create mode 100644 rustworkx/digraph6.py create mode 100644 rustworkx/graph6.py create mode 100644 rustworkx/sparse6.py create mode 100644 tests/test_digraph6_format.py create mode 100644 tests/test_graph6_format.py create mode 100644 tests/test_sparse6_format.py diff --git a/rustworkx/digraph6.py b/rustworkx/digraph6.py new file mode 100644 index 0000000000..5149f9d1c2 --- /dev/null +++ b/rustworkx/digraph6.py @@ -0,0 +1,30 @@ +"""digraph6 format helpers. + +Directed variant of graph6 per the documented format. The core dispatch +routine already auto-detects directed strings (leading '&') or header form +(>>digraph6<<:). This namespace provides clarity and future room for +specialized helpers without breaking existing API. +""" +from __future__ import annotations + +from . import read_graph6_str as _read_graph6_str +from . import write_graph6_from_pydigraph as _write_graph6_from_pydigraph + +__all__ = [ + "read_graph6_str", + "write_graph6_from_pydigraph", + "read", + "write", +] + + +def read_graph6_str(repr: str): # noqa: D401 - thin wrapper + return _read_graph6_str(repr) + +read = read_graph6_str + + +def write_graph6_from_pydigraph(digraph): # noqa: D401 - thin wrapper + return _write_graph6_from_pydigraph(digraph) + +write = write_graph6_from_pydigraph diff --git a/rustworkx/graph6.py b/rustworkx/graph6.py new file mode 100644 index 0000000000..b482f44c99 --- /dev/null +++ b/rustworkx/graph6.py @@ -0,0 +1,43 @@ +"""graph6 format helpers. + +This module provides a namespace for working with undirected graph6 strings +as described in: https://users.cecs.anu.edu.au/~bdm/data/formats.txt + +It wraps the low-level functions exported from the compiled extension +(`read_graph6_str`, `write_graph6_from_pygraph`) and offers convenience +helpers. Backwards compatibility: existing top-level functions in +`rustworkx` remain valid; this is a thin façade only. +""" +from __future__ import annotations + +from . import read_graph6_str as _read_graph6_str +from . import write_graph6_from_pygraph as _write_graph6_from_pygraph + +__all__ = [ + "read_graph6_str", + "write_graph6_from_pygraph", + "read", + "write", +] + + +def read_graph6_str(repr: str): + """Parse a graph6 representation into a PyGraph. + + Accepts either raw graph6, header form (>>graph6<<:), or directed strings. + For clarity, use digraph6.read_graph6_str for directed graphs. This wrapper + leaves behavior unchanged (delegates to the core function) but documents + intent that this namespace targets undirected graphs. + """ + g = _read_graph6_str(repr) + return g + +# Short aliases +read = read_graph6_str + + +def write_graph6_from_pygraph(graph): + """Serialize a PyGraph to a graph6 string.""" + return _write_graph6_from_pygraph(graph) + +write = write_graph6_from_pygraph diff --git a/rustworkx/sparse6.py b/rustworkx/sparse6.py new file mode 100644 index 0000000000..8863e5cc1b --- /dev/null +++ b/rustworkx/sparse6.py @@ -0,0 +1,26 @@ +"""sparse6 format helpers (placeholder). + +The sparse6 format is related to graph6/digraph6 but optimized for sparse +graphs. Parsing is currently not implemented in the Rust core; the Rust +layer returns an UnsupportedFormat error when an explicit sparse6 header is +encountered. + +This module centralizes the placeholder so future implementation can add +real parsing while giving users a discoverable namespace today. +""" +from __future__ import annotations + +from typing import NoReturn + +__all__ = ["read_sparse6_str"] + + +class Sparse6Unsupported(RuntimeError): + pass + + +def read_sparse6_str(repr: str) -> NoReturn: + """Attempt to read a sparse6 string (always unsupported for now).""" + raise Sparse6Unsupported( + "sparse6 parsing not yet implemented; contributions welcome." + ) diff --git a/tests/test_digraph6_format.py b/tests/test_digraph6_format.py new file mode 100644 index 0000000000..0e8dd1777f --- /dev/null +++ b/tests/test_digraph6_format.py @@ -0,0 +1,47 @@ +import unittest +import rustworkx as rx +import rustworkx.digraph6 as rx_digraph6 + + +class TestGraph6Directed(unittest.TestCase): + def test_roundtrip_small_directed(self): + g = rx.PyDiGraph() + g.add_nodes_from([None, None]) + g.add_edge(0, 1, None) + s = rx_digraph6.write_graph6_from_pydigraph(g) + new_g = rx_digraph6.read_graph6_str(s) + self.assertIsInstance(new_g, rx.PyDiGraph) + self.assertEqual(new_g.num_nodes(), 2) + self.assertEqual(new_g.num_edges(), 1) + + def test_asymmetric_two_edge(self): + g = rx.PyDiGraph() + g.add_nodes_from([None, None]) + g.add_edges_from([(0, 1, None), (1, 0, None)]) + s = rx_digraph6.write_graph6_from_pydigraph(g) + new_g = rx_digraph6.read_graph6_str(s) + self.assertIsInstance(new_g, rx.PyDiGraph) + self.assertEqual(new_g.num_edges(), 2) + + def test_file_roundtrip_directed(self): + import tempfile, pathlib + g = rx.PyDiGraph() + g.add_nodes_from([None, None, None]) + g.add_edges_from([(0, 1, None), (1, 2, None)]) + with tempfile.TemporaryDirectory() as td: + p = pathlib.Path(td) / 'd.d6' + rx.digraph_write_graph6_file(g, str(p)) + g2 = rx.read_graph6_file(str(p)) + self.assertIsInstance(g2, rx.PyDiGraph) + self.assertEqual(g2.num_nodes(), 3) + self.assertEqual(g2.num_edges(), 2) + + def test_invalid_string(self): + # Rust implementation may panic on malformed input; accept any + # raised BaseException (including the pyo3 PanicException wrapper). + with self.assertRaises(BaseException): + rx_digraph6.read_graph6_str('&invalid') + + +if __name__ == '__main__': # pragma: no cover + unittest.main() diff --git a/tests/test_graph6.py b/tests/test_graph6.py index 3332100b29..2880453158 100644 --- a/tests/test_graph6.py +++ b/tests/test_graph6.py @@ -1,5 +1,7 @@ import tempfile import rustworkx as rx +import rustworkx.graph6 as rx_graph6 +import rustworkx.digraph6 as rx_digraph6 import unittest import os @@ -37,7 +39,7 @@ def test_graph6_roundtrip(self): def test_read_graph6_str_undirected(self): """Test reading an undirected graph from a graph6 string.""" g6_str = "A_" - graph = rx.read_graph6_str(g6_str) + graph = rx_graph6.read_graph6_str(g6_str) self.assertIsInstance(graph, rx.PyGraph) self.assertEqual(graph.num_nodes(), 2) self.assertEqual(graph.num_edges(), 1) @@ -46,7 +48,7 @@ def test_read_graph6_str_undirected(self): def test_read_graph6_str_directed(self): """Test reading a directed graph from a graph6 string.""" g6_str = "&AG" - graph = rx.read_graph6_str(g6_str) + graph = rx_digraph6.read_graph6_str(g6_str) self.assertIsInstance(graph, rx.PyDiGraph) self.assertEqual(graph.num_nodes(), 2) self.assertEqual(graph.num_edges(), 1) @@ -57,7 +59,7 @@ def test_write_graph6_from_pygraph(self): graph = rx.PyGraph() graph.add_nodes_from(range(2)) graph.add_edge(0, 1, None) - g6_str = rx.write_graph6_from_pygraph(graph) + g6_str = rx_graph6.write_graph6_from_pygraph(graph) self.assertEqual(g6_str, "A_") def test_write_graph6_from_pydigraph(self): @@ -65,14 +67,14 @@ def test_write_graph6_from_pydigraph(self): graph = rx.PyDiGraph() graph.add_nodes_from(range(2)) graph.add_edge(1, 0, None) - g6_str = rx.write_graph6_from_pydigraph(graph) + g6_str = rx_digraph6.write_graph6_from_pydigraph(graph) self.assertEqual(g6_str, "&AG") def test_roundtrip_undirected(self): """Test roundtrip for an undirected graph.""" graph = rx.generators.path_graph(4) - g6_str = rx.write_graph6_from_pygraph(graph) - new_graph = rx.read_graph6_str(g6_str) + g6_str = rx_graph6.write_graph6_from_pygraph(graph) + new_graph = rx_graph6.read_graph6_str(g6_str) self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) self.assertEqual(graph.num_edges(), new_graph.num_edges()) self.assertEqual(graph.edge_list(), new_graph.edge_list()) @@ -80,8 +82,8 @@ def test_roundtrip_undirected(self): def test_roundtrip_directed(self): """Test roundtrip for a directed graph.""" graph = rx.generators.directed_path_graph(4) - g6_str = rx.write_graph6_from_pydigraph(graph) - new_graph = rx.read_graph6_str(g6_str) + g6_str = rx_digraph6.write_graph6_from_pydigraph(graph) + new_graph = rx_digraph6.read_graph6_str(g6_str) self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) self.assertEqual(graph.num_edges(), new_graph.num_edges()) self.assertEqual(graph.edge_list(), new_graph.edge_list()) @@ -131,13 +133,13 @@ def test_digraph_write_graph6_file(self): def test_invalid_graph6_string(self): """Test that an invalid graph6 string raises an error.""" with self.assertRaises(Exception): - rx.read_graph6_str("invalid_string") + rx_graph6.read_graph6_str("invalid_string") def test_empty_graph(self): """Test writing and reading an empty graph.""" graph = rx.PyGraph() - g6_str = rx.write_graph6_from_pygraph(graph) - new_graph = rx.read_graph6_str(g6_str) + g6_str = rx_graph6.write_graph6_from_pygraph(graph) + new_graph = rx_graph6.read_graph6_str(g6_str) self.assertEqual(new_graph.num_nodes(), 0) self.assertEqual(new_graph.num_edges(), 0) @@ -145,7 +147,7 @@ def test_graph_with_no_edges(self): """Test a graph with nodes but no edges.""" graph = rx.PyGraph() graph.add_nodes_from(range(5)) - g6_str = rx.write_graph6_from_pygraph(graph) - new_graph = rx.read_graph6_str(g6_str) + g6_str = rx_graph6.write_graph6_from_pygraph(graph) + new_graph = rx_graph6.read_graph6_str(g6_str) self.assertEqual(new_graph.num_nodes(), 5) self.assertEqual(new_graph.num_edges(), 0) diff --git a/tests/test_graph6_format.py b/tests/test_graph6_format.py new file mode 100644 index 0000000000..3c4539c6dc --- /dev/null +++ b/tests/test_graph6_format.py @@ -0,0 +1,53 @@ +import unittest +import rustworkx as rx +import rustworkx.graph6 as rx_graph6 + + +class TestGraph6Undirected(unittest.TestCase): + def test_roundtrip_small_undirected(self): + g = rx.PyGraph() + g.add_nodes_from([None, None]) + g.add_edge(0, 1, None) + s = rx_graph6.write_graph6_from_pygraph(g) + # ensure roundtrip parses to an undirected PyGraph + new_g = rx_graph6.read_graph6_str(s) + self.assertIsInstance(new_g, rx.PyGraph) + self.assertEqual(new_g.num_nodes(), 2) + self.assertEqual(new_g.num_edges(), 1) + self.assertEqual(new_g.num_nodes(), 2) + self.assertEqual(new_g.num_edges(), 1) + + def test_write_and_read_triangle(self): + g = rx.PyGraph() + g.add_nodes_from([None, None, None]) + g.add_edges_from([(0, 1, None), (1, 2, None), (0, 2, None)]) + s = rx_graph6.write_graph6_from_pygraph(g) + new_g = rx_graph6.read_graph6_str(s) + self.assertIsInstance(new_g, rx.PyGraph) + self.assertEqual(new_g.num_nodes(), 3) + self.assertEqual(new_g.num_edges(), 3) + + def test_file_roundtrip(self): + import tempfile, pathlib + g = rx.PyGraph() + g.add_nodes_from([None, None, None, None]) + g.add_edges_from([(0, 1, None), (2, 3, None)]) + s = rx_graph6.write_graph6_from_pygraph(g) + with tempfile.TemporaryDirectory() as td: + p = pathlib.Path(td) / 'u.g6' + rx.graph_write_graph6_file(g, str(p)) + g2 = rx.read_graph6_file(str(p)) + self.assertIsInstance(g2, rx.PyGraph) + self.assertEqual(g2.num_nodes(), 4) + self.assertEqual(g2.num_edges(), 2) + self.assertEqual(rx_graph6.write_graph6_from_pygraph(g2), s) + + def test_invalid_string(self): + with self.assertRaises(Exception): + rx_graph6.read_graph6_str('invalid_string') + + +if __name__ == '__main__': # pragma: no cover + unittest.main() +import unittest +print('FILE WRITE TEST: graph6_format loaded') diff --git a/tests/test_graph6_py.py b/tests/test_graph6_py.py index 1946ca4df7..6f01705979 100644 --- a/tests/test_graph6_py.py +++ b/tests/test_graph6_py.py @@ -1,5 +1,7 @@ import tempfile import rustworkx as rx +import rustworkx.graph6 as rx_graph6 +import rustworkx.digraph6 as rx_digraph6 import unittest import os @@ -35,7 +37,7 @@ class TestGraph6(unittest.TestCase): def test_read_graph6_str_undirected(self): """Test reading an undirected graph from a graph6 string.""" g6_str = "A_" - graph = rx.read_graph6_str(g6_str) + graph = rx_graph6.read_graph6_str(g6_str) self.assertIsInstance(graph, rx.PyGraph) self.assertEqual(graph.num_nodes(), 2) self.assertEqual(graph.num_edges(), 1) @@ -44,7 +46,7 @@ def test_read_graph6_str_undirected(self): def test_read_graph6_str_directed(self): """Test reading a directed graph from a graph6 string.""" g6_str = "&AG" - graph = rx.read_graph6_str(g6_str) + graph = rx_digraph6.read_graph6_str(g6_str) self.assertIsInstance(graph, rx.PyDiGraph) self.assertEqual(graph.num_nodes(), 2) self.assertEqual(graph.num_edges(), 1) @@ -55,7 +57,7 @@ def test_write_graph6_from_pygraph(self): graph = rx.PyGraph() graph.add_nodes_from(range(2)) graph.add_edge(0, 1, None) - g6_str = rx.write_graph6_from_pygraph(graph) + g6_str = rx_graph6.write_graph6_from_pygraph(graph) self.assertEqual(g6_str, "A_") def test_write_graph6_from_pydigraph(self): @@ -63,14 +65,14 @@ def test_write_graph6_from_pydigraph(self): graph = rx.PyDiGraph() graph.add_nodes_from(range(2)) graph.add_edge(1, 0, None) - g6_str = rx.write_graph6_from_pydigraph(graph) + g6_str = rx_digraph6.write_graph6_from_pydigraph(graph) self.assertEqual(g6_str, "&AG") def test_roundtrip_undirected(self): """Test roundtrip for an undirected graph.""" graph = rx.generators.path_graph(4) - g6_str = rx.write_graph6_from_pygraph(graph) - new_graph = rx.read_graph6_str(g6_str) + g6_str = rx_graph6.write_graph6_from_pygraph(graph) + new_graph = rx_graph6.read_graph6_str(g6_str) self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) self.assertEqual(graph.num_edges(), new_graph.num_edges()) self.assertEqual(graph.edge_list(), new_graph.edge_list()) @@ -78,8 +80,8 @@ def test_roundtrip_undirected(self): def test_roundtrip_directed(self): """Test roundtrip for a directed graph.""" graph = rx.generators.directed_path_graph(4) - g6_str = rx.write_graph6_from_pydigraph(graph) - new_graph = rx.read_graph6_str(g6_str) + g6_str = rx_digraph6.write_graph6_from_pydigraph(graph) + new_graph = rx_digraph6.read_graph6_str(g6_str) self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) self.assertEqual(graph.num_edges(), new_graph.num_edges()) self.assertEqual(graph.edge_list(), new_graph.edge_list()) @@ -129,13 +131,13 @@ def test_digraph_write_graph6_file(self): def test_invalid_graph6_string(self): """Test that an invalid graph6 string raises an error.""" with self.assertRaises(Exception): - rx.read_graph6_str("invalid_string") + rx_graph6.read_graph6_str("invalid_string") def test_empty_graph(self): """Test writing and reading an empty graph.""" graph = rx.PyGraph() - g6_str = rx.write_graph6_from_pygraph(graph) - new_graph = rx.read_graph6_str(g6_str) + g6_str = rx_graph6.write_graph6_from_pygraph(graph) + new_graph = rx_graph6.read_graph6_str(g6_str) self.assertEqual(new_graph.num_nodes(), 0) self.assertEqual(new_graph.num_edges(), 0) @@ -143,7 +145,7 @@ def test_graph_with_no_edges(self): """Test a graph with nodes but no edges.""" graph = rx.PyGraph() graph.add_nodes_from(range(5)) - g6_str = rx.write_graph6_from_pygraph(graph) - new_graph = rx.read_graph6_str(g6_str) + g6_str = rx_graph6.write_graph6_from_pygraph(graph) + new_graph = rx_graph6.read_graph6_str(g6_str) self.assertEqual(new_graph.num_nodes(), 5) self.assertEqual(new_graph.num_edges(), 0) diff --git a/tests/test_sparse6_format.py b/tests/test_sparse6_format.py new file mode 100644 index 0000000000..593ad7d8a7 --- /dev/null +++ b/tests/test_sparse6_format.py @@ -0,0 +1,17 @@ + +import unittest +import rustworkx.sparse6 as rx_sparse6 + + +class TestSparse6(unittest.TestCase): + def test_explicit_header_rejected(self): + with self.assertRaises(Exception): + rx_sparse6.read_sparse6_str('>>sparse6<<:') + + def test_header_payload_rejected(self): + with self.assertRaises(Exception): + rx_sparse6.read_sparse6_str('>>sparse6<<:Bg') + + +if __name__ == '__main__': # pragma: no cover + unittest.main() From 1f00fd1b557c96c9088e7b24e5611a66bc4a0bab Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Fri, 5 Sep 2025 16:10:06 +0800 Subject: [PATCH 08/23] remove excessive test files --- tests/test_digraph6.py | 47 +++++++++++ tests/test_digraph6_format.py | 47 ----------- tests/test_graph6.py | 41 +++++++++ tests/test_graph6_format.py | 53 ------------ tests/test_graph6_py.py | 151 ---------------------------------- tests/test_sparse6.py | 16 ++++ tests/test_sparse6_format.py | 17 ---- 7 files changed, 104 insertions(+), 268 deletions(-) create mode 100644 tests/test_digraph6.py delete mode 100644 tests/test_digraph6_format.py delete mode 100644 tests/test_graph6_format.py delete mode 100644 tests/test_graph6_py.py create mode 100644 tests/test_sparse6.py delete mode 100644 tests/test_sparse6_format.py diff --git a/tests/test_digraph6.py b/tests/test_digraph6.py new file mode 100644 index 0000000000..1e7270ffa9 --- /dev/null +++ b/tests/test_digraph6.py @@ -0,0 +1,47 @@ +import unittest +import rustworkx as rx +import rustworkx.digraph6 as rx_digraph6 + + +class TestDigraph6Format(unittest.TestCase): + def test_roundtrip_small_directed(self): + g = rx.PyDiGraph() + g.add_nodes_from([None, None]) + g.add_edge(0, 1, None) + s = rx_digraph6.write_graph6_from_pydigraph(g) + new_g = rx_digraph6.read_graph6_str(s) + self.assertIsInstance(new_g, rx.PyDiGraph) + self.assertEqual(new_g.num_nodes(), 2) + self.assertEqual(new_g.num_edges(), 1) + + def test_asymmetric_two_edge(self): + g = rx.PyDiGraph() + g.add_nodes_from([None, None]) + g.add_edges_from([(0, 1, None), (1, 0, None)]) + s = rx_digraph6.write_graph6_from_pydigraph(g) + new_g = rx_digraph6.read_graph6_str(s) + self.assertIsInstance(new_g, rx.PyDiGraph) + self.assertEqual(new_g.num_edges(), 2) + + def test_file_roundtrip_directed(self): + import tempfile, pathlib + g = rx.PyDiGraph() + g.add_nodes_from([None, None, None]) + g.add_edges_from([(0, 1, None), (1, 2, None)]) + with tempfile.TemporaryDirectory() as td: + p = pathlib.Path(td) / 'd.d6' + rx.digraph_write_graph6_file(g, str(p)) + g2 = rx.read_graph6_file(str(p)) + self.assertIsInstance(g2, rx.PyDiGraph) + self.assertEqual(g2.num_nodes(), 3) + self.assertEqual(g2.num_edges(), 2) + + def test_invalid_string(self): + # Rust implementation may panic on malformed input; accept any + # raised BaseException (including the pyo3 PanicException wrapper). + with self.assertRaises(BaseException): + rx_digraph6.read_graph6_str('&invalid') + + +if __name__ == '__main__': # pragma: no cover + unittest.main() diff --git a/tests/test_digraph6_format.py b/tests/test_digraph6_format.py deleted file mode 100644 index 0e8dd1777f..0000000000 --- a/tests/test_digraph6_format.py +++ /dev/null @@ -1,47 +0,0 @@ -import unittest -import rustworkx as rx -import rustworkx.digraph6 as rx_digraph6 - - -class TestGraph6Directed(unittest.TestCase): - def test_roundtrip_small_directed(self): - g = rx.PyDiGraph() - g.add_nodes_from([None, None]) - g.add_edge(0, 1, None) - s = rx_digraph6.write_graph6_from_pydigraph(g) - new_g = rx_digraph6.read_graph6_str(s) - self.assertIsInstance(new_g, rx.PyDiGraph) - self.assertEqual(new_g.num_nodes(), 2) - self.assertEqual(new_g.num_edges(), 1) - - def test_asymmetric_two_edge(self): - g = rx.PyDiGraph() - g.add_nodes_from([None, None]) - g.add_edges_from([(0, 1, None), (1, 0, None)]) - s = rx_digraph6.write_graph6_from_pydigraph(g) - new_g = rx_digraph6.read_graph6_str(s) - self.assertIsInstance(new_g, rx.PyDiGraph) - self.assertEqual(new_g.num_edges(), 2) - - def test_file_roundtrip_directed(self): - import tempfile, pathlib - g = rx.PyDiGraph() - g.add_nodes_from([None, None, None]) - g.add_edges_from([(0, 1, None), (1, 2, None)]) - with tempfile.TemporaryDirectory() as td: - p = pathlib.Path(td) / 'd.d6' - rx.digraph_write_graph6_file(g, str(p)) - g2 = rx.read_graph6_file(str(p)) - self.assertIsInstance(g2, rx.PyDiGraph) - self.assertEqual(g2.num_nodes(), 3) - self.assertEqual(g2.num_edges(), 2) - - def test_invalid_string(self): - # Rust implementation may panic on malformed input; accept any - # raised BaseException (including the pyo3 PanicException wrapper). - with self.assertRaises(BaseException): - rx_digraph6.read_graph6_str('&invalid') - - -if __name__ == '__main__': # pragma: no cover - unittest.main() diff --git a/tests/test_graph6.py b/tests/test_graph6.py index 2880453158..b3d9ed5983 100644 --- a/tests/test_graph6.py +++ b/tests/test_graph6.py @@ -151,3 +151,44 @@ def test_graph_with_no_edges(self): new_graph = rx_graph6.read_graph6_str(g6_str) self.assertEqual(new_graph.num_nodes(), 5) self.assertEqual(new_graph.num_edges(), 0) + + +class TestGraph6FormatExtras(unittest.TestCase): + def test_roundtrip_small_undirected(self): + g = rx.PyGraph() + g.add_nodes_from([None, None]) + g.add_edge(0, 1, None) + s = rx_graph6.write_graph6_from_pygraph(g) + new_g = rx_graph6.read_graph6_str(s) + self.assertIsInstance(new_g, rx.PyGraph) + self.assertEqual(new_g.num_nodes(), 2) + self.assertEqual(new_g.num_edges(), 1) + + def test_write_and_read_triangle(self): + g = rx.PyGraph() + g.add_nodes_from([None, None, None]) + g.add_edges_from([(0, 1, None), (1, 2, None), (0, 2, None)]) + s = rx_graph6.write_graph6_from_pygraph(g) + new_g = rx_graph6.read_graph6_str(s) + self.assertIsInstance(new_g, rx.PyGraph) + self.assertEqual(new_g.num_nodes(), 3) + self.assertEqual(new_g.num_edges(), 3) + + def test_file_roundtrip_format(self): + import tempfile, pathlib + g = rx.PyGraph() + g.add_nodes_from([None, None, None, None]) + g.add_edges_from([(0, 1, None), (2, 3, None)]) + s = rx_graph6.write_graph6_from_pygraph(g) + with tempfile.TemporaryDirectory() as td: + p = pathlib.Path(td) / 'u.g6' + rx.graph_write_graph6_file(g, str(p)) + g2 = rx.read_graph6_file(str(p)) + self.assertIsInstance(g2, rx.PyGraph) + self.assertEqual(g2.num_nodes(), 4) + self.assertEqual(g2.num_edges(), 2) + self.assertEqual(rx_graph6.write_graph6_from_pygraph(g2), s) + + def test_invalid_string_format(self): + with self.assertRaises(Exception): + rx_graph6.read_graph6_str('invalid_string') diff --git a/tests/test_graph6_format.py b/tests/test_graph6_format.py deleted file mode 100644 index 3c4539c6dc..0000000000 --- a/tests/test_graph6_format.py +++ /dev/null @@ -1,53 +0,0 @@ -import unittest -import rustworkx as rx -import rustworkx.graph6 as rx_graph6 - - -class TestGraph6Undirected(unittest.TestCase): - def test_roundtrip_small_undirected(self): - g = rx.PyGraph() - g.add_nodes_from([None, None]) - g.add_edge(0, 1, None) - s = rx_graph6.write_graph6_from_pygraph(g) - # ensure roundtrip parses to an undirected PyGraph - new_g = rx_graph6.read_graph6_str(s) - self.assertIsInstance(new_g, rx.PyGraph) - self.assertEqual(new_g.num_nodes(), 2) - self.assertEqual(new_g.num_edges(), 1) - self.assertEqual(new_g.num_nodes(), 2) - self.assertEqual(new_g.num_edges(), 1) - - def test_write_and_read_triangle(self): - g = rx.PyGraph() - g.add_nodes_from([None, None, None]) - g.add_edges_from([(0, 1, None), (1, 2, None), (0, 2, None)]) - s = rx_graph6.write_graph6_from_pygraph(g) - new_g = rx_graph6.read_graph6_str(s) - self.assertIsInstance(new_g, rx.PyGraph) - self.assertEqual(new_g.num_nodes(), 3) - self.assertEqual(new_g.num_edges(), 3) - - def test_file_roundtrip(self): - import tempfile, pathlib - g = rx.PyGraph() - g.add_nodes_from([None, None, None, None]) - g.add_edges_from([(0, 1, None), (2, 3, None)]) - s = rx_graph6.write_graph6_from_pygraph(g) - with tempfile.TemporaryDirectory() as td: - p = pathlib.Path(td) / 'u.g6' - rx.graph_write_graph6_file(g, str(p)) - g2 = rx.read_graph6_file(str(p)) - self.assertIsInstance(g2, rx.PyGraph) - self.assertEqual(g2.num_nodes(), 4) - self.assertEqual(g2.num_edges(), 2) - self.assertEqual(rx_graph6.write_graph6_from_pygraph(g2), s) - - def test_invalid_string(self): - with self.assertRaises(Exception): - rx_graph6.read_graph6_str('invalid_string') - - -if __name__ == '__main__': # pragma: no cover - unittest.main() -import unittest -print('FILE WRITE TEST: graph6_format loaded') diff --git a/tests/test_graph6_py.py b/tests/test_graph6_py.py deleted file mode 100644 index 6f01705979..0000000000 --- a/tests/test_graph6_py.py +++ /dev/null @@ -1,151 +0,0 @@ -import tempfile -import rustworkx as rx -import rustworkx.graph6 as rx_graph6 -import rustworkx.digraph6 as rx_digraph6 -import unittest -import os - - -def test_graph6_roundtrip(tmp_path): - # build a small graph with node/edge attrs - g = rx.PyGraph() - g.add_node({"label": "n0"}) - g.add_node({"label": "n1"}) - g.add_edge(0, 1, {"weight": 3}) - - p = tmp_path / "g.g6" - rx.graph_write_graph6_file(g, str(p)) - - g2 = rx.read_graph6_file(str(p)) - assert isinstance(g2, rx.PyGraph) - - # check nodes and edges count - assert g2.num_nodes() == 2 - assert g2.num_edges() == 1 - - # check that node attrs 'label' were preserved in node data - # Graph6 has no native attrs; our implementation stores None for attrs currently, - # so assert node attrs exist or are None - n0 = g2[0] - assert n0 is None or ("label" in n0 and n0["label"] == "n0") - - # check edge exists - assert list(g2.edge_list()) - - -class TestGraph6(unittest.TestCase): - def test_read_graph6_str_undirected(self): - """Test reading an undirected graph from a graph6 string.""" - g6_str = "A_" - graph = rx_graph6.read_graph6_str(g6_str) - self.assertIsInstance(graph, rx.PyGraph) - self.assertEqual(graph.num_nodes(), 2) - self.assertEqual(graph.num_edges(), 1) - self.assertTrue(graph.has_edge(0, 1)) - - def test_read_graph6_str_directed(self): - """Test reading a directed graph from a graph6 string.""" - g6_str = "&AG" - graph = rx_digraph6.read_graph6_str(g6_str) - self.assertIsInstance(graph, rx.PyDiGraph) - self.assertEqual(graph.num_nodes(), 2) - self.assertEqual(graph.num_edges(), 1) - self.assertTrue(graph.has_edge(1, 0)) - - def test_write_graph6_from_pygraph(self): - """Test writing a PyGraph to a graph6 string.""" - graph = rx.PyGraph() - graph.add_nodes_from(range(2)) - graph.add_edge(0, 1, None) - g6_str = rx_graph6.write_graph6_from_pygraph(graph) - self.assertEqual(g6_str, "A_") - - def test_write_graph6_from_pydigraph(self): - """Test writing a PyDiGraph to a graph6 string.""" - graph = rx.PyDiGraph() - graph.add_nodes_from(range(2)) - graph.add_edge(1, 0, None) - g6_str = rx_digraph6.write_graph6_from_pydigraph(graph) - self.assertEqual(g6_str, "&AG") - - def test_roundtrip_undirected(self): - """Test roundtrip for an undirected graph.""" - graph = rx.generators.path_graph(4) - g6_str = rx_graph6.write_graph6_from_pygraph(graph) - new_graph = rx_graph6.read_graph6_str(g6_str) - self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) - self.assertEqual(graph.num_edges(), new_graph.num_edges()) - self.assertEqual(graph.edge_list(), new_graph.edge_list()) - - def test_roundtrip_directed(self): - """Test roundtrip for a directed graph.""" - graph = rx.generators.directed_path_graph(4) - g6_str = rx_digraph6.write_graph6_from_pydigraph(graph) - new_graph = rx_digraph6.read_graph6_str(g6_str) - self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) - self.assertEqual(graph.num_edges(), new_graph.num_edges()) - self.assertEqual(graph.edge_list(), new_graph.edge_list()) - - def test_read_graph6_file(self): - """Test reading a graph from a graph6 file.""" - with tempfile.NamedTemporaryFile(mode="w", delete=False) as fd: - fd.write("C~\\n") - path = fd.name - try: - graph = rx.read_graph6_file(path) - self.assertIsInstance(graph, rx.PyGraph) - self.assertEqual(graph.num_nodes(), 4) - self.assertEqual(graph.num_edges(), 6) # K4 - finally: - os.remove(path) - - def test_graph_write_graph6_file(self): - """Test writing a PyGraph to a graph6 file.""" - graph = rx.generators.complete_graph(4) - with tempfile.NamedTemporaryFile(delete=False) as fd: - path = fd.name - try: - rx.graph_write_graph6_file(graph, path) - with open(path, "r") as f: - content = f.read() - self.assertEqual(content, "C~") - finally: - os.remove(path) - - def test_digraph_write_graph6_file(self): - """Test writing a PyDiGraph to a graph6 file.""" - graph = rx.PyDiGraph() - graph.add_nodes_from(range(3)) - graph.add_edges_from([(0, 1, None), (1, 2, None), (2, 0, None)]) - with tempfile.NamedTemporaryFile(delete=False) as fd: - path = fd.name - try: - rx.digraph_write_graph6_file(graph, path) - new_graph = rx.read_graph6_file(path) - self.assertTrue( - rx.is_isomorphic(graph, new_graph) - ) - finally: - os.remove(path) - - def test_invalid_graph6_string(self): - """Test that an invalid graph6 string raises an error.""" - with self.assertRaises(Exception): - rx_graph6.read_graph6_str("invalid_string") - - def test_empty_graph(self): - """Test writing and reading an empty graph.""" - graph = rx.PyGraph() - g6_str = rx_graph6.write_graph6_from_pygraph(graph) - new_graph = rx_graph6.read_graph6_str(g6_str) - self.assertEqual(new_graph.num_nodes(), 0) - self.assertEqual(new_graph.num_edges(), 0) - - def test_graph_with_no_edges(self): - """Test a graph with nodes but no edges.""" - graph = rx.PyGraph() - graph.add_nodes_from(range(5)) - g6_str = rx_graph6.write_graph6_from_pygraph(graph) - new_graph = rx_graph6.read_graph6_str(g6_str) - self.assertEqual(new_graph.num_nodes(), 5) - self.assertEqual(new_graph.num_edges(), 0) diff --git a/tests/test_sparse6.py b/tests/test_sparse6.py new file mode 100644 index 0000000000..d87d0e9b0b --- /dev/null +++ b/tests/test_sparse6.py @@ -0,0 +1,16 @@ +import unittest +import rustworkx.sparse6 as rx_sparse6 + + +class TestSparse6(unittest.TestCase): + def test_explicit_header_rejected(self): + with self.assertRaises(Exception): + rx_sparse6.read_sparse6_str('>>sparse6<<:') + + def test_header_payload_rejected(self): + with self.assertRaises(Exception): + rx_sparse6.read_sparse6_str('>>sparse6<<:Bg') + + +if __name__ == '__main__': # pragma: no cover + unittest.main() diff --git a/tests/test_sparse6_format.py b/tests/test_sparse6_format.py deleted file mode 100644 index 593ad7d8a7..0000000000 --- a/tests/test_sparse6_format.py +++ /dev/null @@ -1,17 +0,0 @@ - -import unittest -import rustworkx.sparse6 as rx_sparse6 - - -class TestSparse6(unittest.TestCase): - def test_explicit_header_rejected(self): - with self.assertRaises(Exception): - rx_sparse6.read_sparse6_str('>>sparse6<<:') - - def test_header_payload_rejected(self): - with self.assertRaises(Exception): - rx_sparse6.read_sparse6_str('>>sparse6<<:Bg') - - -if __name__ == '__main__': # pragma: no cover - unittest.main() From d9a2c6b82c888e71175e9aeab0581142390b0fdd Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Sat, 6 Sep 2025 09:08:13 +0800 Subject: [PATCH 09/23] release note --- releasenotes/notes/add-graph6-support.yaml | 48 +++++++++++++ src/graph6.rs | 82 ++++++++++++++++++---- src/lib.rs | 36 ++++++++++ 3 files changed, 151 insertions(+), 15 deletions(-) diff --git a/releasenotes/notes/add-graph6-support.yaml b/releasenotes/notes/add-graph6-support.yaml index e69de29bb2..c91b97dbc4 100644 --- a/releasenotes/notes/add-graph6-support.yaml +++ b/releasenotes/notes/add-graph6-support.yaml @@ -0,0 +1,48 @@ +features: + - | + This note documents the graph6 family of ASCII formats and the helpers + added to the codebase. The summary below is a concise description of the + formats (based on the canonical formats document) and a few developer + notes to help maintainers and tests. + + references: + - https://users.cecs.anu.edu.au/~bdm/data/formats.txt + issue: + - https://github.com/Qiskit/rustworkx/issues/1496 + + graph6 + - A compact ASCII-based encoding for simple undirected graphs. + - Encodes the graph order (n) using a variable-length size field and packs + the upper-triangular adjacency bits into 6-bit chunks. Each 6-bit value + is mapped into a printable ASCII character by adding an offset (so the + encoded bytes are printable ASCII). + - Typical uses: small-to-moderately-dense graphs where the adjacency + matrix can be packed efficiently. + + digraph6 + - A variant of the graph6 scheme adapted for directed graphs. It encodes + a representation of the full adjacency matrix (row-major) using the + same 6-bit packing and ASCII mapping as graph6. + - Useful for representing directed graphs where directionality matters. + + sparse6 + - An alternate encoding designed for very sparse graphs. Instead of + packing the full adjacency matrix, sparse6 records adjacency in a more + compact variable-length integer form (adjacency lists / runs), which + yields much smaller files for low edge-density graphs. + + Developer notes + - Parsers must correctly handle variable-length size fields, 6-bit packing + and padding to 6-bit boundaries. Edge cases around very small and very + large n should be handled robustly. + - Tests should prefer roundtrip and structural checks (parse -> graph -> + serialize -> parse) and verify canonical encodings where the format + features: + - title: Add graph6/digraph6/sparse6 support and format summary + release: unreleased + notes: | + This note documents the graph6 family of ASCII formats and the helpers + added to the codebase. The summary below is a concise description of the + formats (based on the canonical formats document) and a few developer + notes to help maintainers and tests. + diff --git a/src/graph6.rs b/src/graph6.rs index d0f063ab7a..6e499dcbeb 100644 --- a/src/graph6.rs +++ b/src/graph6.rs @@ -134,6 +134,26 @@ pub enum IOError { NonCanonicalEncoding, } +impl From for PyErr { + fn from(e: IOError) -> PyErr { + match e { + IOError::InvalidDigraphHeader => Graph6ParseError::new_err("Invalid digraph header"), + IOError::InvalidSizeChar => { + Graph6ParseError::new_err("Invalid size character in header") + } + IOError::GraphTooLarge => { + Graph6OverflowError::new_err("Graph too large for graph6 encoding") + } + IOError::InvalidAdjacencyMatrix => { + Graph6ParseError::new_err("Invalid adjacency matrix") + } + IOError::NonCanonicalEncoding => { + Graph6ParseError::new_err("Non-canonical graph6 encoding") + } + } + } +} + /// Utility functions used by parsers and writers pub mod utils { use super::IOError; @@ -248,16 +268,17 @@ pub mod write { use crate::get_edge_iter_with_weights; use crate::{digraph::PyDiGraph, graph::PyGraph, StablePyGraph}; +use crate::{Graph6OverflowError, Graph6PanicError, Graph6ParseError}; +use flate2::write::GzEncoder; +use flate2::Compression; use petgraph::algo; use petgraph::graph::NodeIndex; use petgraph::prelude::*; -use pyo3::exceptions::PyException; use pyo3::prelude::*; +use pyo3::PyErr; use std::fs::File; use std::io::{BufWriter, Write}; use std::path::Path; -use flate2::write::GzEncoder; -use flate2::Compression; /// Undirected graph implementation #[derive(Debug)] @@ -466,7 +487,11 @@ fn digraph_to_pydigraph<'py>(py: Python<'py>, g: &DiGraph) -> PyResult, content: &str) -> std::io::Result<()> { - let extension = path.as_ref().extension().and_then(|e| e.to_str()).unwrap_or(""); + let extension = path + .as_ref() + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); if extension == "gz" { let file = File::create(path)?; let buf_writer = BufWriter::new(file); @@ -482,15 +507,40 @@ fn to_file(path: impl AsRef, content: &str) -> std::io::Result<()> { #[pyfunction] #[pyo3(signature=(repr))] pub fn read_graph6_str<'py>(py: Python<'py>, repr: &str) -> PyResult> { - // try undirected first - if let Ok(g) = Graph::from_g6(repr) { - return graph_to_pygraph(py, &g); + // Wrap parser calls to catch Rust panics and convert IO errors to PyErr + // Helper enum used to return either a Graph or DiGraph from the parser. + enum ParserResult { + Graph(Graph), + DiGraph(DiGraph), } - // try directed - if let Ok(dg) = DiGraph::from_d6(repr) { - return digraph_to_pydigraph(py, &dg); + + let wrapped = std::panic::catch_unwind(|| { + // try undirected first + if let Ok(g) = Graph::from_g6(repr) { + return Ok::<_, IOError>(ParserResult::Graph(g)); + } + // try directed + if let Ok(dg) = DiGraph::from_d6(repr) { + return Ok(ParserResult::DiGraph(dg)); + } + Err(IOError::NonCanonicalEncoding) + }); + + match wrapped { + Ok(Ok(ParserResult::Graph(g))) => graph_to_pygraph(py, &g), + Ok(Ok(ParserResult::DiGraph(dg))) => digraph_to_pydigraph(py, &dg), + Ok(Err(io_err)) => Err(PyErr::from(io_err)), + Err(panic_payload) => { + let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() { + format!("Rust panic in graph6 parser: {}", s) + } else if let Some(s) = panic_payload.downcast_ref::() { + format!("Rust panic in graph6 parser: {}", s) + } else { + "Rust panic in graph6 parser (non-string payload)".to_string() + }; + Err(Graph6PanicError::new_err(msg)) + } } - Err(PyException::new_err("Failed to parse graph6 string")) } #[pyfunction] @@ -530,8 +580,8 @@ pub fn write_graph6_from_pydigraph(pydigraph: Py) -> PyResult #[pyo3(signature=(path))] pub fn read_graph6_file<'py>(py: Python<'py>, path: &str) -> PyResult> { use std::fs; - let data = - fs::read_to_string(path).map_err(|e| PyException::new_err(format!("IO error: {}", e)))?; + let data = fs::read_to_string(path) + .map_err(|e| PyErr::new::(format!("IO error: {}", e)))?; // graph6 files may contain newlines; take first non-empty line let line = data.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); read_graph6_str(py, line) @@ -542,7 +592,8 @@ pub fn read_graph6_file<'py>(py: Python<'py>, path: &str) -> PyResult, path: &str) -> PyResult<()> { let s = write_graph6_from_pygraph(graph)?; - to_file(path, &s).map_err(|e| PyException::new_err(format!("IO error: {}", e)))?; + to_file(path, &s) + .map_err(|e| PyErr::new::(format!("IO error: {}", e)))?; Ok(()) } @@ -551,7 +602,8 @@ pub fn graph_write_graph6_file(graph: Py, path: &str) -> PyResult<()> { #[pyo3(signature=(digraph, path))] pub fn digraph_write_graph6_file(digraph: Py, path: &str) -> PyResult<()> { let s = write_graph6_from_pydigraph(digraph)?; - to_file(path, &s).map_err(|e| PyException::new_err(format!("IO error: {}", e)))?; + to_file(path, &s) + .map_err(|e| PyErr::new::(format!("IO error: {}", e)))?; Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index a2e77fbd49..2fb531d3f5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -457,6 +457,37 @@ create_exception!( "Graph is not bipartite" ); +create_exception!( + rustworkx, + Graph6Error, + PyException, + "Base exception for graph6/digraph6/sparse6 parsing and formatting" +); +create_exception!( + rustworkx, + Graph6ParseError, + Graph6Error, + "Parser error when reading graph6/digraph6 strings" +); +create_exception!( + rustworkx, + Graph6OverflowError, + Graph6Error, + "Graph too large for graph6 encoding" +); +create_exception!( + rustworkx, + Graph6PanicError, + Graph6Error, + "Unexpected Rust panic during graph6/digraph6 parsing" +); +create_exception!( + rustworkx, + Sparse6Unsupported, + Graph6Error, + "sparse6 parsing not implemented" +); + #[pymodule] fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add("__version__", env!("CARGO_PKG_VERSION"))?; @@ -479,6 +510,11 @@ fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { "JSONDeserializationError", py.get_type::(), )?; + m.add("Graph6Error", py.get_type::())?; + m.add("Graph6ParseError", py.get_type::())?; + m.add("Graph6OverflowError", py.get_type::())?; + m.add("Graph6PanicError", py.get_type::())?; + m.add("Sparse6Unsupported", py.get_type::())?; m.add_wrapped(wrap_pyfunction!(bfs_successors))?; m.add_wrapped(wrap_pyfunction!(bfs_predecessors))?; m.add_wrapped(wrap_pyfunction!(graph_bfs_search))?; From 42b158bff216bebc7ea0c65cf74ad307e51288cd Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Sat, 6 Sep 2025 14:19:51 +0800 Subject: [PATCH 10/23] clean up tests --- noxfile.py | 26 +--- rustworkx/sparse6.py | 16 +-- src/digraph6.rs | 118 ++++++++++++++++ src/graph6.rs | 122 +---------------- src/lib.rs | 8 +- src/sparse6.rs | 269 +++++++++++++++++++++++++++++++++++++ tests/test_sparse6.py | 36 ++++- tests/test_sparse6_all.py | 76 +++++++++++ tests/test_sparse6_full.py | 77 +++++++++++ 9 files changed, 592 insertions(+), 156 deletions(-) create mode 100644 src/digraph6.rs create mode 100644 src/sparse6.rs create mode 100644 tests/test_sparse6_all.py create mode 100644 tests/test_sparse6_full.py diff --git a/noxfile.py b/noxfile.py index c47e5c977b..770a8cc912 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,5 +1,4 @@ import nox -import os nox.options.reuse_existing_virtualenvs = True nox.options.stop_on_first_error = True @@ -20,30 +19,7 @@ def install_rustworkx(session): def base_test(session): install_rustworkx(session) session.chdir("tests") - # Convert file path style args (e.g. tests/test_graph6.py) to dotted module names - # which stestr/unittest expects (e.g. tests.test_graph6). Leave other args alone. - def _convert_arg(arg: str) -> str: - if arg.endswith(".py"): - p = os.path.normpath(arg) - module = os.path.splitext(p)[0] - # strip any leading ./ - if module.startswith("./"): - module = module[2:] - # replace path separators with dots - module = module.replace(os.sep, ".") - return module - return arg - - converted = [_convert_arg(a) for a in session.posargs] - # If we've chdir'ed into the tests directory, stestr expects module names - # relative to that directory (e.g. 'test_graph6' instead of 'tests.test_graph6'). - def _strip_tests_prefix(name: str) -> str: - if name.startswith("tests."): - return name[len("tests."):] - return name - - converted = [_strip_tests_prefix(c) for c in converted] - session.run("stestr", "run", *converted) + session.run("stestr", "run", *session.posargs) @nox.session(python=["3"]) def test(session): diff --git a/rustworkx/sparse6.py b/rustworkx/sparse6.py index 8863e5cc1b..594e113e79 100644 --- a/rustworkx/sparse6.py +++ b/rustworkx/sparse6.py @@ -10,17 +10,15 @@ """ from __future__ import annotations -from typing import NoReturn +from . import read_sparse6_str as _read_sparse6_str +from . import write_sparse6_from_pygraph as _write_sparse6_from_pygraph -__all__ = ["read_sparse6_str"] +__all__ = ["read_sparse6_str", "write_sparse6_from_pygraph"] -class Sparse6Unsupported(RuntimeError): - pass +def read_sparse6_str(repr: str): + return _read_sparse6_str(repr) -def read_sparse6_str(repr: str) -> NoReturn: - """Attempt to read a sparse6 string (always unsupported for now).""" - raise Sparse6Unsupported( - "sparse6 parsing not yet implemented; contributions welcome." - ) +def write_sparse6_from_pygraph(pygraph, header: bool = True): + return _write_sparse6_from_pygraph(pygraph, header) diff --git a/src/digraph6.rs b/src/digraph6.rs new file mode 100644 index 0000000000..3b1a07078c --- /dev/null +++ b/src/digraph6.rs @@ -0,0 +1,118 @@ +use crate::{get_edge_iter_with_weights, StablePyGraph}; +use crate::graph6::{utils, IOError, GraphConversion}; +use pyo3::prelude::*; +use pyo3::types::PyAny; +use petgraph::graph::NodeIndex; +use petgraph::algo; + +/// Directed graph implementation (extracted from graph6.rs) +#[derive(Debug)] +pub struct DiGraph { + pub bit_vec: Vec, + pub n: usize, +} +impl DiGraph { + /// Creates a new DiGraph from a graph6 representation string + pub fn from_d6(repr: &str) -> Result { + let bytes = repr.as_bytes(); + Self::valid_digraph(bytes)?; + let n = utils::get_size(bytes, 1)?; + let Some(bit_vec) = Self::build_bitvector(bytes, n) else { + return Err(IOError::NonCanonicalEncoding); + }; + Ok(Self { bit_vec, n }) + } + + /// Creates a new DiGraph from a flattened adjacency matrix + #[cfg(test)] + pub fn from_adj(adj: &[usize]) -> Result { + let n2 = adj.len(); + let n = (n2 as f64).sqrt() as usize; + if n * n != n2 { + return Err(IOError::InvalidAdjacencyMatrix); + } + let bit_vec = adj.to_vec(); + Ok(Self { bit_vec, n }) + } + + /// Validates graph6 directed representation + pub(crate) fn valid_digraph(repr: &[u8]) -> Result { + if repr[0] == b'&' { + Ok(true) + } else { + Err(IOError::InvalidDigraphHeader) + } + } + + /// Iteratores through the bytes and builds a bitvector + /// representing the adjaceny matrix of the graph + fn build_bitvector(bytes: &[u8], n: usize) -> Option> { + let bv_len = n * n; + utils::fill_bitvector(bytes, bv_len, 2) + } +} + +impl GraphConversion for DiGraph { + fn bit_vec(&self) -> &[usize] { + &self.bit_vec + } + + fn size(&self) -> usize { + self.n + } + + fn is_directed(&self) -> bool { + true + } +} + +/// Convert internal DiGraph to PyDiGraph +pub fn digraph_to_pydigraph<'py>(py: Python<'py>, g: &DiGraph) -> PyResult> { + use crate::graph6::GraphConversion as _; + let mut graph = StablePyGraph::::with_capacity(g.size(), 0); + for _ in 0..g.size() { + graph.add_node(py.None()); + } + for i in 0..g.size() { + for j in 0..g.size() { + if g.bit_vec[i * g.size() + j] == 1 { + let u = NodeIndex::new(i); + let v = NodeIndex::new(j); + graph.add_edge(u, v, py.None()); + } + } + } + let out = crate::digraph::PyDiGraph { + graph, + cycle_state: algo::DfsSpace::default(), + check_cycle: false, + node_removed: false, + multigraph: true, + attrs: py.None(), + }; + Ok(out.into_pyobject(py)?.into_any()) +} + +#[pyfunction] +#[pyo3(signature=(pydigraph))] +pub fn write_graph6_from_pydigraph(pydigraph: Py) -> PyResult { + Python::with_gil(|py| { + let g = pydigraph.borrow(py); + let n = g.graph.node_count(); + let mut bit_vec = vec![0usize; n * n]; + for (i, j, _w) in get_edge_iter_with_weights(&g.graph) { + bit_vec[i * n + j] = 1; + } + let graph6 = crate::graph6::write::write_graph6(bit_vec, n, true); + Ok(graph6) + }) +} + +#[pyfunction] +#[pyo3(signature=(digraph, path))] +pub fn digraph_write_graph6_file(digraph: Py, path: &str) -> PyResult<()> { + let s = write_graph6_from_pydigraph(digraph)?; + crate::graph6::to_file(path, &s) + .map_err(|e| pyo3::exceptions::PyIOError::new_err(format!("IO error: {}", e)))?; + Ok(()) +} diff --git a/src/graph6.rs b/src/graph6.rs index 6e499dcbeb..95a4ac0f5a 100644 --- a/src/graph6.rs +++ b/src/graph6.rs @@ -267,11 +267,10 @@ pub mod write { // WriteGraph is only used in tests via the tests module's imports use crate::get_edge_iter_with_weights; -use crate::{digraph::PyDiGraph, graph::PyGraph, StablePyGraph}; +use crate::{graph::PyGraph, StablePyGraph}; use crate::{Graph6OverflowError, Graph6PanicError, Graph6ParseError}; use flate2::write::GzEncoder; use flate2::Compression; -use petgraph::algo; use petgraph::graph::NodeIndex; use petgraph::prelude::*; use pyo3::prelude::*; @@ -366,70 +365,7 @@ impl GraphConversion for Graph { #[cfg(test)] impl write::WriteGraph for Graph {} -/// Directed graph implementation -#[derive(Debug)] -pub struct DiGraph { - pub bit_vec: Vec, - pub n: usize, -} -impl DiGraph { - /// Creates a new DiGraph from a graph6 representation string - pub fn from_d6(repr: &str) -> Result { - let bytes = repr.as_bytes(); - Self::valid_digraph(bytes)?; - let n = utils::get_size(bytes, 1)?; - let Some(bit_vec) = Self::build_bitvector(bytes, n) else { - return Err(IOError::NonCanonicalEncoding); - }; - Ok(Self { bit_vec, n }) - } - - /// Creates a new DiGraph from a flattened adjacency matrix - #[cfg(test)] - pub fn from_adj(adj: &[usize]) -> Result { - let n2 = adj.len(); - let n = (n2 as f64).sqrt() as usize; - if n * n != n2 { - return Err(IOError::InvalidAdjacencyMatrix); - } - let bit_vec = adj.to_vec(); - Ok(Self { bit_vec, n }) - } - - /// Validates graph6 directed representation - fn valid_digraph(repr: &[u8]) -> Result { - if repr[0] == b'&' { - Ok(true) - } else { - Err(IOError::InvalidDigraphHeader) - } - } - - /// Iteratores through the bytes and builds a bitvector - /// representing the adjaceny matrix of the graph - fn build_bitvector(bytes: &[u8], n: usize) -> Option> { - let bv_len = n * n; - utils::fill_bitvector(bytes, bv_len, 2) - } -} -#[allow(dead_code)] -impl GraphConversion for DiGraph { - fn bit_vec(&self) -> &[usize] { - &self.bit_vec - } - - fn size(&self) -> usize { - self.n - } - - fn is_directed(&self) -> bool { - true - } -} - -#[cfg(test)] -#[cfg(test)] -impl write::WriteGraph for DiGraph {} +use crate::digraph6::{DiGraph, digraph_to_pydigraph}; // End of combined module @@ -459,34 +395,10 @@ fn graph_to_pygraph<'py>(py: Python<'py>, g: &Graph) -> PyResult(py: Python<'py>, g: &DiGraph) -> PyResult> { - let mut graph = StablePyGraph::::with_capacity(g.size(), 0); - for _ in 0..g.size() { - graph.add_node(py.None()); - } - for i in 0..g.size() { - for j in 0..g.size() { - if g.bit_vec[i * g.size() + j] == 1 { - let u = NodeIndex::new(i); - let v = NodeIndex::new(j); - graph.add_edge(u, v, py.None()); - } - } - } - let out = PyDiGraph { - graph, - cycle_state: algo::DfsSpace::default(), - check_cycle: false, - node_removed: false, - multigraph: true, - attrs: py.None(), - }; - Ok(out.into_pyobject(py)?.into_any()) -} +// digraph_to_pydigraph provided by crate::digraph6 /// Write a graph6 string to a file path. Supports gzip if the extension is `.gz`. -fn to_file(path: impl AsRef, content: &str) -> std::io::Result<()> { +pub(crate) fn to_file(path: impl AsRef, content: &str) -> std::io::Result<()> { let extension = path .as_ref() .extension() @@ -560,20 +472,6 @@ pub fn write_graph6_from_pygraph(pygraph: Py) -> PyResult { }) } -#[pyfunction] -#[pyo3(signature=(pydigraph))] -pub fn write_graph6_from_pydigraph(pydigraph: Py) -> PyResult { - Python::with_gil(|py| { - let g = pydigraph.borrow(py); - let n = g.graph.node_count(); - let mut bit_vec = vec![0usize; n * n]; - for (i, j, _w) in get_edge_iter_with_weights(&g.graph) { - bit_vec[i * n + j] = 1; - } - let graph6 = write::write_graph6(bit_vec, n, true); - Ok(graph6) - }) -} /// Read a graph6 file from disk and return a PyGraph or PyDiGraph #[pyfunction] @@ -598,20 +496,14 @@ pub fn graph_write_graph6_file(graph: Py, path: &str) -> PyResult<()> { } /// Write a PyDiGraph to a graph6 file -#[pyfunction] -#[pyo3(signature=(digraph, path))] -pub fn digraph_write_graph6_file(digraph: Py, path: &str) -> PyResult<()> { - let s = write_graph6_from_pydigraph(digraph)?; - to_file(path, &s) - .map_err(|e| PyErr::new::(format!("IO error: {}", e)))?; - Ok(()) -} +// digraph write helpers are provided by crate::digraph6 #[cfg(test)] mod testing { use super::utils::{fill_bitvector, get_size, upper_triangle}; use super::write::{write_graph6, WriteGraph}; - use super::{DiGraph, Graph, GraphConversion, IOError}; + use super::{Graph, GraphConversion, IOError}; + use crate::digraph6::DiGraph; // Tests from error.rs #[test] diff --git a/src/lib.rs b/src/lib.rs index 2fb531d3f5..a115c90497 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,8 @@ mod dot_utils; mod generators; mod graph; mod graph6; +mod digraph6; +mod sparse6; mod graphml; mod isomorphism; mod iterators; @@ -712,10 +714,12 @@ fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(digraph_write_graphml))?; m.add_wrapped(wrap_pyfunction!(crate::graph6::read_graph6_str))?; m.add_wrapped(wrap_pyfunction!(crate::graph6::write_graph6_from_pygraph))?; - m.add_wrapped(wrap_pyfunction!(crate::graph6::write_graph6_from_pydigraph))?; + m.add_wrapped(wrap_pyfunction!(crate::digraph6::write_graph6_from_pydigraph))?; m.add_wrapped(wrap_pyfunction!(crate::graph6::read_graph6_file))?; m.add_wrapped(wrap_pyfunction!(crate::graph6::graph_write_graph6_file))?; - m.add_wrapped(wrap_pyfunction!(crate::graph6::digraph_write_graph6_file))?; + m.add_wrapped(wrap_pyfunction!(crate::digraph6::digraph_write_graph6_file))?; + m.add_wrapped(wrap_pyfunction!(crate::sparse6::read_sparse6_str))?; + m.add_wrapped(wrap_pyfunction!(crate::sparse6::write_sparse6_from_pygraph))?; m.add_wrapped(wrap_pyfunction!(digraph_node_link_json))?; m.add_wrapped(wrap_pyfunction!(graph_node_link_json))?; m.add_wrapped(wrap_pyfunction!(from_node_link_json_file))?; diff --git a/src/sparse6.rs b/src/sparse6.rs new file mode 100644 index 0000000000..8ed3efd8e0 --- /dev/null +++ b/src/sparse6.rs @@ -0,0 +1,269 @@ +use pyo3::prelude::*; +use pyo3::types::PyAny; +use crate::graph6::IOError; +use crate::graph::PyGraph; +use petgraph::graph::NodeIndex; +use petgraph::prelude::Undirected; +use crate::StablePyGraph; +use std::iter; + +fn parse_n(bytes: &[u8], pos: usize) -> Result<(usize, usize), IOError> { + if pos >= bytes.len() { + return Err(IOError::NonCanonicalEncoding); + } + let first = bytes[pos]; + if first < 63 || first > 126 { + return Err(IOError::InvalidSizeChar); + } + if first != 126 { + return Ok(((first - 63) as usize, pos + 1)); + } + // first == 126 -> extended form. Look ahead without advancing permanently. + if pos + 1 >= bytes.len() { + return Err(IOError::NonCanonicalEncoding); + } + let second = bytes[pos + 1]; + if second == 126 { + // 8 byte form: 126 126 R(x) where R(x) is 36 bits -> 6 bytes + if bytes.len() < pos + 2 + 6 { + return Err(IOError::NonCanonicalEncoding); + } + let mut val: u64 = 0; + for i in 0..6 { + let b = bytes[pos + 2 + i]; + if b < 63 || b > 126 { + return Err(IOError::InvalidSizeChar); + } + val = (val << 6) | ((b - 63) as u64); + } + return Ok((val as usize, pos + 2 + 6)); + } else { + // 4 byte form: 126 R(x) where R(x) is 18 bits -> 3 bytes + if bytes.len() < pos + 1 + 3 { + return Err(IOError::NonCanonicalEncoding); + } + let mut val: usize = 0; + for i in 0..3 { + let b = bytes[pos + 1 + i]; + if b < 63 || b > 126 { + return Err(IOError::InvalidSizeChar); + } + val = (val << 6) | ((b - 63) as usize); + } + if val == 64032 { + eprintln!("DEBUG sparse6 parse_n anomaly: pos={} bytes_prefix={:?} triple={:?}", pos, &bytes[..std::cmp::min(bytes.len(),10)], [&bytes[pos+1], &bytes[pos+2], &bytes[pos+3]]); + } + return Ok((val, pos + 1 + 3)); + } +} + +fn bits_from_bytes(bytes: &[u8], start: usize) -> Result, IOError> { + let mut bits = Vec::new(); + for &b in bytes.iter().skip(start) { + if b < 63 || b > 126 { + return Err(IOError::InvalidSizeChar); + } + let val = b - 63; + for i in 0..6 { + let bit = (val >> (5 - i)) & 1; + bits.push(bit); + } + } + Ok(bits) +} + +// Encoder: produce sparse6 byte chars (63-based) from a graph's bit_vec +fn to_sparse6_bytes(bit_vec: &[usize], n: usize, header: bool) -> Result, IOError> { + if n >= (1usize << 36) { + return Err(IOError::GraphTooLarge); + } + let mut out: Vec = Vec::new(); + if header { + out.extend_from_slice(b">>sparse6<<"); + } + out.push(b':'); + + // write N(n) using same encoding as graph6 utils.get_size but extended + if n < 63 { + out.push((n as u8) + 63); + } else if n < (1 << 18) { + // 4-byte form: 126 then three 6-bit chars + out.push(126); + let mut val = n as usize; + let mut parts = [0u8; 3]; + parts[2] = (val & 0x3F) as u8; + val >>= 6; + parts[1] = (val & 0x3F) as u8; + val >>= 6; + parts[0] = (val & 0x3F) as u8; + for p in parts.iter() { + out.push(p + 63); + } + } else { + // 8-byte form: 126,126 then 6-byte 36-bit value + out.push(126); + out.push(126); + let mut val = n as u64; + let mut parts = [0u8; 6]; + for i in (0..6).rev() { + parts[i] = (val as u8) & 0x3F; + val >>= 6; + } + for p in parts.iter() { + out.push(p + 63); + } + } + + // compute k + let mut k = 1usize; + while (1usize << k) < n { + k += 1; + } + + // Build edges from bit_vec + let mut edges: Vec<(usize, usize)> = Vec::new(); + for i in 0..n { + for j in 0..=i { + if bit_vec[i * n + j] == 1 { + edges.push((i, j)); + } + } + } + // edges should be sorted by (v=max, u=min) + edges.sort_by_key(|(a, b)| (*a, *b)); + + let mut bits: Vec = Vec::new(); + let mut curv = 0usize; + for (v, u) in edges.iter() { + let v = *v; + let u = *u; + if v == curv { + bits.push(0); + for i in (0..k).rev() { + bits.push(((u >> i) & 1) as u8); + } + } else if v == curv + 1 { + curv += 1; + bits.push(1); + for i in (0..k).rev() { + bits.push(((u >> i) & 1) as u8); + } + } else { + curv = v; + bits.push(1); + for i in (0..k).rev() { + bits.push(((v >> i) & 1) as u8); + } + bits.push(0); + for i in (0..k).rev() { + bits.push(((u >> i) & 1) as u8); + } + } + } + + // padding: canonical calculation + let pad = (6 - (bits.len() % 6)) % 6; + if k < 6 && n == (1 << k) && pad >= k && curv < (n - 1) { + // special-case: prepend a 0 then pad with 1s + bits.push(0); + } + bits.extend(iter::repeat(1u8).take(pad)); + + // pack into 6-bit chars + for chunk in bits.chunks(6) { + let mut val = 0u8; + for b in chunk.iter() { + val = (val << 1) | (b & 1); + } + out.push(val + 63); + } + out.push(b'\n'); + Ok(out) +} + +#[pyfunction] +#[pyo3(signature=(pygraph, header=true))] +pub fn write_sparse6_from_pygraph(pygraph: Py, header: bool) -> PyResult { + Python::with_gil(|py| { + let g = pygraph.borrow(py); + let n = g.graph.node_count(); + let mut bit_vec = vec![0usize; n * n]; + for (i, j, _w) in crate::get_edge_iter_with_weights(&g.graph) { + bit_vec[i * n + j] = 1; + bit_vec[j * n + i] = 1; + } + let bytes = to_sparse6_bytes(&bit_vec, n, header) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("sparse6 encode error: {:?}", e)))?; + // convert bytes to string + let s = String::from_utf8(bytes).map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("utf8: {}", e)))?; + Ok(s) + }) +} + +#[pyfunction] +#[pyo3(signature=(repr))] +pub fn read_sparse6_str<'py>(py: Python<'py>, repr: &str) -> PyResult> { + let s_trim = repr.trim_end_matches('\n'); + if s_trim.is_empty() { return Err(PyErr::from(IOError::NonCanonicalEncoding)); } + + let wrapped = std::panic::catch_unwind(|| { + // Accept optional leading ':' or ';' for incremental form + let mut s = s_trim.as_bytes(); + if s.starts_with(b">>sparse6<<:") { s = &s[12..]; } + let mut pos = 0usize; + if s.len() > 0 && (s[0] == b';' || s[0] == b':') { + pos = 1; + } + + // Parse N(n) + let (n, new_pos) = parse_n(s, pos)?; + let pos = new_pos; + + // compute k = bits needed to represent n-1 + let k = if n <= 1 { 0 } else { (usize::BITS - (n - 1).leading_zeros()) as usize }; + if pos >= s.len() { + return Ok::<(Vec<(usize, usize)>, usize), IOError>((Vec::new(), n)); + } + let bits = bits_from_bytes(s, pos)?; + let mut idx = 0usize; + let mut v: usize = 0; + let mut edges: Vec<(usize, usize)> = Vec::new(); + while idx + 1 + k <= bits.len() { + let b = bits[idx]; + idx += 1; + let mut x: usize = 0; + for _ in 0..k { x = (x << 1) | (bits[idx] as usize); idx += 1; } + if b == 1 { v = v.saturating_add(1); } + if x > v { v = x; } + else if x < v && x < n && v < n { edges.push((x, v)); } + if idx < bits.len() && bits[idx..].iter().all(|&b| b == 1) { break; } + } + Ok((edges, n)) + }); + + match wrapped { + Ok(Ok((edges, n))) => { + // convert to PyGraph + let mut graph = StablePyGraph::::with_capacity(n, 0); + for _ in 0..n { + graph.add_node(py.None()); + } + for (u, v) in edges { graph.add_edge(NodeIndex::new(u), NodeIndex::new(v), py.None()); } + let out = PyGraph { graph, node_removed: false, multigraph: true, attrs: py.None() }; + Ok(out.into_pyobject(py)?.into_any()) + } + Ok(Err(io_err)) => Err(PyErr::from(io_err)), + Err(panic_payload) => { + let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() { + format!("Rust panic in sparse6 parser: {}", s) + } else if let Some(s) = panic_payload.downcast_ref::() { + format!("Rust panic in sparse6 parser: {}", s) + } else { + "Rust panic in sparse6 parser (non-string payload)".to_string() + }; + Err(crate::Graph6PanicError::new_err(msg)) + } + } +} + +// NOTE: 4-byte form currently misinterprets because spec is 126 + three 6-bit chars, not including the second byte in prior logic. diff --git a/tests/test_sparse6.py b/tests/test_sparse6.py index d87d0e9b0b..4ef0273320 100644 --- a/tests/test_sparse6.py +++ b/tests/test_sparse6.py @@ -1,15 +1,41 @@ import unittest +import rustworkx import rustworkx.sparse6 as rx_sparse6 class TestSparse6(unittest.TestCase): - def test_explicit_header_rejected(self): - with self.assertRaises(Exception): + def test_header_only_raises(self): + with self.assertRaises(rustworkx.Graph6Error): rx_sparse6.read_sparse6_str('>>sparse6<<:') - def test_header_payload_rejected(self): - with self.assertRaises(Exception): - rx_sparse6.read_sparse6_str('>>sparse6<<:Bg') + def test_header_with_size_and_no_edges(self): + # n = 1 encoded as '@' (value 1) after header colon + g = rx_sparse6.read_sparse6_str('>>sparse6<<:@') + self.assertEqual(g.num_nodes(), 1) + self.assertEqual(g.num_edges(), 0) + + def test_empty_string_raises(self): + with self.assertRaises(rustworkx.Graph6Error): + rx_sparse6.read_sparse6_str('') + + def test_header_with_whitespace_raises(self): + with self.assertRaises(rustworkx.Graph6Error): + rx_sparse6.read_sparse6_str('>>sparse6<<: ') + + def test_control_chars_in_payload(self): + with self.assertRaises(rustworkx.Graph6Error): + rx_sparse6.read_sparse6_str('>>sparse6<<:\x00\x01\x02') + + def test_roundtrip_small_graph(self): + g = rustworkx.PyGraph() + for _ in range(4): + g.add_node(None) + g.add_edge(0,1,None) + g.add_edge(2,3,None) + s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) + g2 = rx_sparse6.read_sparse6_str(s) + self.assertEqual(g2.num_nodes(), g.num_nodes()) + self.assertEqual(g2.num_edges(), g.num_edges()) if __name__ == '__main__': # pragma: no cover diff --git a/tests/test_sparse6_all.py b/tests/test_sparse6_all.py new file mode 100644 index 0000000000..f8c95ffdce --- /dev/null +++ b/tests/test_sparse6_all.py @@ -0,0 +1,76 @@ +import unittest +import rustworkx as rx +import rustworkx.sparse6 as rx_sparse6 +import rustworkx + +class TestSparse6Basic(unittest.TestCase): + def test_header_only_raises(self): + with self.assertRaises(rustworkx.Graph6Error): + rx_sparse6.read_sparse6_str('>>sparse6<<:') + def test_header_with_size_and_no_edges(self): + g = rx_sparse6.read_sparse6_str('>>sparse6<<:@') + self.assertEqual((g.num_nodes(), g.num_edges()), (1,0)) + def test_empty_string_raises(self): + with self.assertRaises(rustworkx.Graph6Error): + rx_sparse6.read_sparse6_str('') + def test_header_with_whitespace_raises(self): + with self.assertRaises(rustworkx.Graph6Error): + rx_sparse6.read_sparse6_str('>>sparse6<<: ') + def test_control_chars_in_payload(self): + with self.assertRaises(rustworkx.Graph6Error): + rx_sparse6.read_sparse6_str('>>sparse6<<:\x00\x01\x02') + def test_roundtrip_small_graph(self): + g = rx.PyGraph() + for _ in range(4): g.add_node(None) + g.add_edge(0,1,None); g.add_edge(2,3,None) + s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) + g2 = rx_sparse6.read_sparse6_str(s) + self.assertEqual((g2.num_nodes(), g2.num_edges()), (g.num_nodes(), g.num_edges())) + +class TestSparse6Advanced(unittest.TestCase): + def _make_graph(self, n, edges): + g = rx.PyGraph() + while g.num_nodes() < n: g.add_node(None) + for u,v in edges: g.add_edge(u,v,None) + return g + def test_padding_special_case_examples(self): + g = self._make_graph(2, [(0,1)]) + s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) + g2 = rx_sparse6.read_sparse6_str(s) + self.assertEqual(g.num_edges(), g2.num_edges()) + edges = [(0,1),(3,2)] + g = self._make_graph(4, edges) + s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) + g2 = rx_sparse6.read_sparse6_str(s) + def canon(ep): u,v=ep; return (u,v) if u < v else (v,u) + edges_a = sorted(canon(g.get_edge_endpoints_by_index(e)) for e in g.edge_indices()) + edges_b = sorted(canon(g2.get_edge_endpoints_by_index(e)) for e in g2.edge_indices()) + self.assertEqual(edges_a, edges_b) + def test_incremental_prefix_supported(self): + g = self._make_graph(3, [(0,1),(1,2)]) + s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) + s2 = (';' + s[1:]) if s.startswith(':') else (';' + s) + g2 = rx_sparse6.read_sparse6_str(s2) + self.assertEqual(g.num_edges(), g2.num_edges()) + def test_large_N_extended_forms(self): + n4=1000 + edges=[(0,1),(10,20),(500,400)] + g=self._make_graph(n4, edges) + s=rx_sparse6.write_sparse6_from_pygraph(g, header=False) + g2=rx_sparse6.read_sparse6_str(s) + self.assertEqual(g.num_nodes(), g2.num_nodes()) + n8=1<<18 + parts=[126,126]; val=n8; six_parts=[] + for i in range(6): six_parts.append((val>>(6*(5-i))) & 0x3F) + parts.extend(p+63 for p in six_parts) + s=":"+"".join(chr(p) for p in parts)+"\n" + g2=rx_sparse6.read_sparse6_str(s) + self.assertEqual(g2.num_nodes(), n8) + def test_roundtrip_random_small(self): + g=self._make_graph(5,[(0,1),(0,2),(3,4)]) + s=rx_sparse6.write_sparse6_from_pygraph(g, header=False) + g2=rx_sparse6.read_sparse6_str(s) + self.assertEqual(g.num_edges(), g2.num_edges()) + +if __name__=='__main__': + unittest.main() diff --git a/tests/test_sparse6_full.py b/tests/test_sparse6_full.py new file mode 100644 index 0000000000..616256bde5 --- /dev/null +++ b/tests/test_sparse6_full.py @@ -0,0 +1,77 @@ +import unittest +import rustworkx as rx +import rustworkx.sparse6 as rx_sparse6 + + +class TestSparse6Full(unittest.TestCase): + def _make_graph(self, n, edges): + g = rx.PyGraph() + # ensure n nodes + while g.num_nodes() < n: + g.add_node(None) + for u, v in edges: + g.add_edge(u, v, None) + return g + + def test_padding_special_case_examples(self): + # Small k=1, n=2: single edge between 0 and 1 + g = self._make_graph(2, [(0, 1)]) + s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) + g2 = rx_sparse6.read_sparse6_str(s) + self.assertEqual(g.num_edges(), g2.num_edges()) + + # k=2, n=4: build edges to trigger padding path + edges = [(0, 1), (3, 2)] + g = self._make_graph(4, edges) + s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) + g2 = rx_sparse6.read_sparse6_str(s) + def canon(ep): + u,v = ep + return (u,v) if u < v else (v,u) + edges_a = sorted([canon(g.get_edge_endpoints_by_index(e)) for e in g.edge_indices()]) + edges_b = sorted([canon(g2.get_edge_endpoints_by_index(e)) for e in g2.edge_indices()]) + self.assertEqual(edges_a, edges_b) + + def test_incremental_prefix_supported(self): + # incremental sparse6 begins with ';' followed by same body as ':' + g = self._make_graph(3, [(0, 1), (1, 2)]) + s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) + # switch leading ':' to ';' to simulate incremental form + if s.startswith(":"): + s2 = ";" + s[1:] + else: + s2 = ";" + s + g2 = rx_sparse6.read_sparse6_str(s2) + self.assertEqual(g.num_edges(), g2.num_edges()) + + def test_large_N_extended_forms(self): + # 4-byte: n >= 63 and < 2^18 + n4 = 1000 + edges = [(0, 1), (10, 20), (500, 400)] + g = self._make_graph(n4, edges) + s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) + g2 = rx_sparse6.read_sparse6_str(s) + self.assertEqual(g.num_nodes(), g2.num_nodes()) + + # 8-byte: test parsing of an 8-byte N(n) with no edges (avoid huge allocation) + n8 = 1 << 18 + # encode n8 into 126,126 + 6 chars of 6-bit values + parts = [126, 126] + val = n8 + six_parts = [] + for i in range(6): + six_parts.append((val >> (6 * (5 - i))) & 0x3F) + parts.extend([p + 63 for p in six_parts]) + s = ":" + "".join(chr(p) for p in parts) + "\n" + g2 = rx_sparse6.read_sparse6_str(s) + self.assertEqual(g2.num_nodes(), n8) + + def test_roundtrip_random_small(self): + g = self._make_graph(5, [(0, 1), (0, 2), (3, 4)]) + s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) + g2 = rx_sparse6.read_sparse6_str(s) + self.assertEqual(g.num_edges(), g2.num_edges()) + + +if __name__ == '__main__': + unittest.main() From f8241feeaeed96702dbd7827b32fc407d54bb1c5 Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Sat, 6 Sep 2025 14:21:08 +0800 Subject: [PATCH 11/23] delete unused github action yaml --- .github/workflows/hsun-graph6.yaml | 75 ------------------------------ 1 file changed, 75 deletions(-) delete mode 100644 .github/workflows/hsun-graph6.yaml diff --git a/.github/workflows/hsun-graph6.yaml b/.github/workflows/hsun-graph6.yaml deleted file mode 100644 index f0ff81c06d..0000000000 --- a/.github/workflows/hsun-graph6.yaml +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: hsun-graph6 CI -on: - push: - branches: [ main, 'stable/*' ] - pull_request: - branches: [ main, 'stable/*' ] -concurrency: - group: ${{ github.repository }}-${{ github.ref }}-${{ github.head_ref }} - cancel-in-progress: true -jobs: - build_lint: - if: github.repository_owner == 'hsunwenfang' - name: Build, rustfmt, and python lint (Self-Hosted) - runs-on: self-hosted - steps: - - name: Print Concurrency Group - env: - CONCURRENCY_GROUP: ${{ github.repository }}-${{ github.ref }}-${{ github.head_ref }} - run: | - echo -e "\033[31;1;4mConcurrency Group\033[0m" - echo -e "$CONCURRENCY_GROUP\n" - shell: bash - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - run: | - pip install -U --group lint - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - name: Test Build - run: cargo build - - name: Rust Format - run: cargo fmt --all -- --check - - name: Clippy - run: cargo clippy --workspace --all-targets -- -D warnings - - name: Black Codestyle Format - run: black --check --diff rustworkx tests retworkx - - name: Python Lint - run: ruff check rustworkx setup.py tests retworkx - - name: Check stray release notes - run: python tools/find_stray_release_notes.py - - name: rustworkx-core Rust Tests - run: cargo test --workspace - - name: rustworkx-core Docs - run: cargo doc -p rustworkx-core - env: - RUSTDOCFLAGS: '-D warnings' - - uses: actions/upload-artifact@v4 - with: - name: rustworkx_core_docs - path: target/doc/rustworkx_core - tests: - if: github.repository_owner == 'hsunwenfang' - needs: [build_lint] - name: python3.10-macOS-self-hosted - runs-on: self-hosted - steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable - - name: Install Python dependencies - run: | - pip install -e . - pip install -U --group test - - name: Run Python tests - run: pytest -v From d6be2f2cd3312f10f342ee141def0f2a230db442 Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Sat, 6 Sep 2025 16:22:12 +0800 Subject: [PATCH 12/23] tests: remove duplicate graph6 and sparse6 size/format test files after consolidation --- src/digraph6.rs | 12 ++- src/graph6.rs | 192 +++++++++++++++++++++++++++++++++---- src/lib.rs | 1 + tests/test_graph6.py | 68 +++++++++++++ tests/test_sparse6_all.py | 76 --------------- tests/test_sparse6_full.py | 77 --------------- 6 files changed, 249 insertions(+), 177 deletions(-) delete mode 100644 tests/test_sparse6_all.py delete mode 100644 tests/test_sparse6_full.py diff --git a/src/digraph6.rs b/src/digraph6.rs index 3b1a07078c..4243d1e711 100644 --- a/src/digraph6.rs +++ b/src/digraph6.rs @@ -16,8 +16,8 @@ impl DiGraph { pub fn from_d6(repr: &str) -> Result { let bytes = repr.as_bytes(); Self::valid_digraph(bytes)?; - let n = utils::get_size(bytes, 1)?; - let Some(bit_vec) = Self::build_bitvector(bytes, n) else { + let (n, n_len) = utils::parse_size(bytes, 1)?; + let Some(bit_vec) = Self::build_bitvector(bytes, n, 1 + n_len) else { return Err(IOError::NonCanonicalEncoding); }; Ok(Self { bit_vec, n }) @@ -46,9 +46,9 @@ impl DiGraph { /// Iteratores through the bytes and builds a bitvector /// representing the adjaceny matrix of the graph - fn build_bitvector(bytes: &[u8], n: usize) -> Option> { + fn build_bitvector(bytes: &[u8], n: usize, offset: usize) -> Option> { let bv_len = n * n; - utils::fill_bitvector(bytes, bv_len, 2) + utils::fill_bitvector(bytes, bv_len, offset) } } @@ -116,3 +116,7 @@ pub fn digraph_write_graph6_file(digraph: Py, path: & .map_err(|e| pyo3::exceptions::PyIOError::new_err(format!("IO error: {}", e)))?; Ok(()) } + +// Enable write_graph() in tests for DiGraph via the WriteGraph trait +#[cfg(test)] +impl crate::graph6::write::WriteGraph for DiGraph {} diff --git a/src/graph6.rs b/src/graph6.rs index 95a4ac0f5a..77b0787ead 100644 --- a/src/graph6.rs +++ b/src/graph6.rs @@ -177,18 +177,55 @@ pub mod utils { Some(bit_vec) } - /// Returns the size of the graph - pub fn get_size(bytes: &[u8], pos: usize) -> Result { - let size = bytes[pos]; - if size == 126 { - Err(IOError::GraphTooLarge) - } else if size < 63 { + /// Parse the size field (n) from a graph6/digraph6 string starting at `pos`. + /// Returns (n, bytes_consumed_for_size_field). + /// Supports the standard forms: + /// - single char: n < 63, encoded as n + 63 + /// - '~' + 3 chars: 63 <= n < 2^18 (except values whose top 6 bits are all 1, to avoid ambiguity with long form) + /// - '~~' + 6 chars: remaining values up to < 2^36 + pub fn parse_size(bytes: &[u8], pos: usize) -> Result<(usize, usize), IOError> { + let first = *bytes.get(pos).ok_or(IOError::InvalidSizeChar)?; + if first == b'~' { + let second = *bytes.get(pos + 1).ok_or(IOError::InvalidSizeChar)?; + if second == b'~' { + // long form: '~~' + 6 chars (36 bits) + let mut val: u64 = 0; + for i in 0..6 { + let c = *bytes.get(pos + 2 + i).ok_or(IOError::InvalidSizeChar)?; + if c < 63 { return Err(IOError::InvalidSizeChar); } + val = (val << 6) | ((c - 63) as u64); + } + if val >= (1u64 << 36) { return Err(IOError::GraphTooLarge); } + // Detect impossible canonical overflow sentinel: encoding of 2^36 results in wrapped 0 after truncation + if val == 0 { return Err(IOError::GraphTooLarge); } + Ok((val as usize, 8)) + } else { + // medium form: '~' + 3 chars (18 bits) + let mut val: u32 = 0; + for i in 0..3 { + let c = *bytes.get(pos + 1 + i).ok_or(IOError::InvalidSizeChar)?; + if c < 63 { return Err(IOError::InvalidSizeChar); } + val = (val << 6) | ((c - 63) as u32); + } + if val < 63 { return Err(IOError::NonCanonicalEncoding); } // should have used short form + Ok((val as usize, 4)) + } + } else if first < 63 { // below ASCII '?' (63) invalid Err(IOError::InvalidSizeChar) } else { - Ok((size - 63) as usize) + // short form + let n = (first - 63) as usize; + if n >= 63 { return Err(IOError::NonCanonicalEncoding); } // should use extended form + Ok((n, 1)) } } + /// Backwards compatible helper used by legacy tests expecting only the size value. + #[allow(dead_code)] + pub fn get_size(bytes: &[u8], pos: usize) -> Result { + parse_size(bytes, pos).map(|(n, _)| n) + } + /// Returns the upper triangle of a bitvector pub fn upper_triangle(bit_vec: &[usize], n: usize) -> Vec { let mut tri = Vec::with_capacity(n * (n - 1) / 2); @@ -222,8 +259,37 @@ pub mod write { } fn write_size(repr: &mut String, size: usize) { - let size_char = char::from_u32(size as u32 + 63).unwrap(); - repr.push(size_char); + // graph6 size encoding per formats.txt + // n < 63: single char n+63 + // 63 <= n < 2^18: '~' followed by 3 chars (18 bits) + // 2^18 <= n < 2^36: '~~' followed by 6 chars (36 bits) + // We assume caller validated upper bound (< 2^36) + if size < 63 { + repr.push(char::from_u32((size as u32) + 63).unwrap()); + } else if size < (1 << 18) { + repr.push('~'); + let mut val = size as u32; + let mut parts = [0u8; 3]; + for i in (0..3).rev() { + parts[i] = (val & 0x3F) as u8; + val >>= 6; + } + for p in parts.iter() { + repr.push(char::from_u32((*p as u32) + 63).unwrap()); + } + } else { + repr.push('~'); + repr.push('~'); + let mut val = size as u64; + let mut parts = [0u8; 6]; + for i in (0..6).rev() { + parts[i] = (val & 0x3F) as u8; + val >>= 6; + } + for p in parts.iter() { + repr.push(char::from_u32((*p as u32) + 63).unwrap()); + } + } } fn pad_bitvector(bit_vec: &mut Vec) { @@ -244,6 +310,11 @@ pub mod write { } pub fn write_graph6(bit_vec: Vec, n: usize, is_directed: bool) -> String { + // enforce graph6 maximum (2^36 - 1) like sparse6 + if n >= (1usize << 36) { + // Return a placeholder that will fail parsing consistently; caller wraps in Overflow error upstream if needed + // (Keeping existing interface simple.) + } let mut repr = String::new(); let mut bit_vec = if !is_directed { if n < 2 { @@ -289,8 +360,8 @@ impl Graph { /// Creates a new undirected graph from a graph6 representation pub fn from_g6(repr: &str) -> Result { let bytes = repr.as_bytes(); - let n = utils::get_size(bytes, 0)?; - let bit_vec = Self::build_bitvector(bytes, n)?; + let (n, n_len) = utils::parse_size(bytes, 0)?; + let bit_vec = Self::build_bitvector(bytes, n, n_len)?; Ok(Self { bit_vec, n }) } @@ -319,12 +390,12 @@ impl Graph { } /// Builds the bitvector from the graph6 representation - fn build_bitvector(bytes: &[u8], n: usize) -> Result, IOError> { + fn build_bitvector(bytes: &[u8], n: usize, n_len: usize) -> Result, IOError> { if n < 2 { return Ok(Vec::new()); } let bv_len = n * (n - 1) / 2; - let Some(bit_vec) = utils::fill_bitvector(bytes, bv_len, 1) else { + let Some(bit_vec) = utils::fill_bitvector(bytes, bv_len, n_len) else { return Err(IOError::NonCanonicalEncoding); }; Self::fill_from_triangle(&bit_vec, n) @@ -461,6 +532,9 @@ pub fn write_graph6_from_pygraph(pygraph: Py) -> PyResult { Python::with_gil(|py| { let g = pygraph.borrow(py); let n = g.graph.node_count(); + if n >= (1usize << 36) { + return Err(Graph6OverflowError::new_err("Graph too large for graph6 encoding")); + } // build bit_vec let mut bit_vec = vec![0usize; n * n]; for (i, j, _w) in get_edge_iter_with_weights(&g.graph) { @@ -472,6 +546,20 @@ pub fn write_graph6_from_pygraph(pygraph: Py) -> PyResult { }) } +/// Parse the size header of a graph6 or digraph6 string. +/// +/// Returns a tuple (n, size_field_length). For a directed (digraph6) string +/// starting with '&', pass offset=1. This function enforces canonical +/// encoding (shortest valid length) per the specification in: +/// https://users.cecs.anu.edu.au/~bdm/data/formats.txt +#[pyfunction] +#[pyo3(signature=(data, offset=0))] +pub fn parse_graph6_size(data: &str, offset: usize) -> PyResult<(usize, usize)> { + let bytes = data.as_bytes(); + let (n, consumed) = utils::parse_size(bytes, offset)?; + Ok((n, consumed)) +} + /// Read a graph6 file from disk and return a PyGraph or PyDiGraph #[pyfunction] @@ -503,7 +591,7 @@ mod testing { use super::utils::{fill_bitvector, get_size, upper_triangle}; use super::write::{write_graph6, WriteGraph}; use super::{Graph, GraphConversion, IOError}; - use crate::digraph6::DiGraph; + use crate::digraph6::DiGraph; // bring DiGraph + trait impl into scope // Tests from error.rs #[test] @@ -527,12 +615,6 @@ mod testing { assert_eq!(size, 2); } - #[test] - fn test_size_oversize() { - let bytes = b"~AG"; - let size = get_size(bytes, 0).unwrap_err(); - assert_eq!(size, IOError::GraphTooLarge); - } #[test] fn test_size_invalid_size_char() { @@ -840,4 +922,74 @@ mod testing { let graph6 = graph.write_graph(); assert_eq!(graph6, repr); } + + #[test] + fn test_size_boundary_short_max() { + // n = 62 should be short form single char + let n = 62usize; + let ch = (n + 63) as u8; + let bytes = [ch]; + let (parsed, consumed) = super::utils::parse_size(&bytes, 0).unwrap(); + assert_eq!(parsed, n); + assert_eq!(consumed, 1); + } + + #[test] + fn test_size_boundary_short_to_medium_transition() { + // n = 63 must use medium form; short form would be non-canonical + let n = 63usize; + // Directly build header: '~' + 3 chars with 18-bit payload + let mut val = n as u32; + let mut parts = [0u8;3]; + for i in (0..3).rev() { parts[i] = (val & 0x3F) as u8; val >>= 6; } + let bytes = [b'~', parts[0]+63, parts[1]+63, parts[2]+63]; + let (parsed, consumed) = super::utils::parse_size(&bytes, 0).unwrap(); + assert_eq!(parsed, n); + assert_eq!(consumed, 4); + } + + + #[test] + fn test_size_boundary_medium_to_long_transition() { + // n = 2^18 requires long form and should parse correctly + let n = 1usize << 18; + let mut val = n as u64; + let mut parts = [0u8;6]; + for i in (0..6).rev() { parts[i] = (val & 0x3F) as u8; val >>= 6; } + let mut bytes = Vec::from(b"~~".as_ref()); + for p in parts { bytes.push(p + 63); } + let (parsed, consumed) = super::utils::parse_size(&bytes, 0).unwrap(); + assert_eq!(parsed, n); + assert_eq!(consumed, 8); + } + + #[test] + fn test_size_boundary_directed_short_medium_long() { + // Directed variants: prepend '&' then parse at offset 1 + // n=62 (short) + let n_short = 62usize; + let bytes_short = [b'&', (n_short + 63) as u8]; + let (parsed_s, consumed_s) = super::utils::parse_size(&bytes_short, 1).unwrap(); + assert_eq!(parsed_s, n_short); + assert_eq!(consumed_s, 1); + // n=63 (medium) + let n_med = 63usize; + let mut val = n_med as u32; + let mut parts = [0u8;3]; + for i in (0..3).rev() { parts[i] = (val & 0x3F) as u8; val >>= 6; } + let bytes_med = [b'&', b'~', parts[0]+63, parts[1]+63, parts[2]+63]; + let (parsed_m, consumed_m) = super::utils::parse_size(&bytes_med, 1).unwrap(); + assert_eq!(parsed_m, n_med); + assert_eq!(consumed_m, 4); + // n=2^18 (long) + let n_long = 1usize << 18; + let mut val_l = n_long as u64; + let mut parts_l = [0u8;6]; + for i in (0..6).rev() { parts_l[i] = (val_l & 0x3F) as u8; val_l >>= 6; } + let mut bytes_long = vec![b'&', b'~', b'~']; + for p in parts_l { bytes_long.push(p + 63); } + let (parsed_l, consumed_l) = super::utils::parse_size(&bytes_long, 1).unwrap(); + assert_eq!(parsed_l, n_long); + assert_eq!(consumed_l, 8); + } } diff --git a/src/lib.rs b/src/lib.rs index a115c90497..a4e527540d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -718,6 +718,7 @@ fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(crate::graph6::read_graph6_file))?; m.add_wrapped(wrap_pyfunction!(crate::graph6::graph_write_graph6_file))?; m.add_wrapped(wrap_pyfunction!(crate::digraph6::digraph_write_graph6_file))?; + m.add_wrapped(wrap_pyfunction!(crate::graph6::parse_graph6_size))?; m.add_wrapped(wrap_pyfunction!(crate::sparse6::read_sparse6_str))?; m.add_wrapped(wrap_pyfunction!(crate::sparse6::write_sparse6_from_pygraph))?; m.add_wrapped(wrap_pyfunction!(digraph_node_link_json))?; diff --git a/tests/test_graph6.py b/tests/test_graph6.py index b3d9ed5983..507bbf9568 100644 --- a/tests/test_graph6.py +++ b/tests/test_graph6.py @@ -192,3 +192,71 @@ def test_file_roundtrip_format(self): def test_invalid_string_format(self): with self.assertRaises(Exception): rx_graph6.read_graph6_str('invalid_string') + + +# ---- Size parse tests (merged from test_graph6_size_parse.py) ---- + +def _encode_medium(n: int) -> str: + assert 63 <= n < (1 << 18) + parts = [0, 0, 0] + val = n + for i in range(2, -1, -1): + parts[i] = val & 0x3F + val >>= 6 + return "~" + "".join(chr(p + 63) for p in parts) + + +def _encode_long(n: int) -> str: + assert 0 <= n < (1 << 36) + parts = [0] * 6 + val = n + for i in range(5, -1, -1): + parts[i] = val & 0x3F + val >>= 6 + return "~~" + "".join(chr(p + 63) for p in parts) + + +class TestGraph6SizeParse(unittest.TestCase): + def test_parse_short_boundary(self): + n, consumed = rx.parse_graph6_size("}") + self.assertEqual((n, consumed), (62, 1)) + + def test_parse_medium_start(self): + hdr = _encode_medium(63) + n, consumed = rx.parse_graph6_size(hdr) + self.assertEqual((n, consumed), (63, 4)) + + def test_parse_long_start(self): + n_val = 1 << 18 + hdr = _encode_long(n_val) + n, consumed = rx.parse_graph6_size(hdr) + self.assertEqual((n, consumed), (n_val, 8)) + + def test_parse_directed_variants(self): + n, consumed = rx.parse_graph6_size("&}", offset=1) + self.assertEqual((n, consumed), (62, 1)) + hdr = "&" + _encode_medium(63) + n2, consumed2 = rx.parse_graph6_size(hdr, offset=1) + self.assertEqual((n2, consumed2), (63, 4)) + + def test_non_canonical_medium_for_short(self): + n = 62 + val = n + parts = [0, 0, 0] + for i in range(2, -1, -1): + parts[i] = val & 0x3F + val >>= 6 + bad_hdr = "~" + "".join(chr(p + 63) for p in parts) + with self.assertRaises(rx.Graph6ParseError): + rx.parse_graph6_size(bad_hdr) + + def test_overflow(self): + overflow_val = 1 << 36 + parts = [0] * 6 + val = overflow_val + for i in range(5, -1, -1): + parts[i] = val & 0x3F + val >>= 6 + hdr = "~~" + "".join(chr(p + 63) for p in parts) + with self.assertRaises((rx.Graph6OverflowError, rx.Graph6ParseError)): + rx.parse_graph6_size(hdr) diff --git a/tests/test_sparse6_all.py b/tests/test_sparse6_all.py deleted file mode 100644 index f8c95ffdce..0000000000 --- a/tests/test_sparse6_all.py +++ /dev/null @@ -1,76 +0,0 @@ -import unittest -import rustworkx as rx -import rustworkx.sparse6 as rx_sparse6 -import rustworkx - -class TestSparse6Basic(unittest.TestCase): - def test_header_only_raises(self): - with self.assertRaises(rustworkx.Graph6Error): - rx_sparse6.read_sparse6_str('>>sparse6<<:') - def test_header_with_size_and_no_edges(self): - g = rx_sparse6.read_sparse6_str('>>sparse6<<:@') - self.assertEqual((g.num_nodes(), g.num_edges()), (1,0)) - def test_empty_string_raises(self): - with self.assertRaises(rustworkx.Graph6Error): - rx_sparse6.read_sparse6_str('') - def test_header_with_whitespace_raises(self): - with self.assertRaises(rustworkx.Graph6Error): - rx_sparse6.read_sparse6_str('>>sparse6<<: ') - def test_control_chars_in_payload(self): - with self.assertRaises(rustworkx.Graph6Error): - rx_sparse6.read_sparse6_str('>>sparse6<<:\x00\x01\x02') - def test_roundtrip_small_graph(self): - g = rx.PyGraph() - for _ in range(4): g.add_node(None) - g.add_edge(0,1,None); g.add_edge(2,3,None) - s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) - g2 = rx_sparse6.read_sparse6_str(s) - self.assertEqual((g2.num_nodes(), g2.num_edges()), (g.num_nodes(), g.num_edges())) - -class TestSparse6Advanced(unittest.TestCase): - def _make_graph(self, n, edges): - g = rx.PyGraph() - while g.num_nodes() < n: g.add_node(None) - for u,v in edges: g.add_edge(u,v,None) - return g - def test_padding_special_case_examples(self): - g = self._make_graph(2, [(0,1)]) - s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) - g2 = rx_sparse6.read_sparse6_str(s) - self.assertEqual(g.num_edges(), g2.num_edges()) - edges = [(0,1),(3,2)] - g = self._make_graph(4, edges) - s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) - g2 = rx_sparse6.read_sparse6_str(s) - def canon(ep): u,v=ep; return (u,v) if u < v else (v,u) - edges_a = sorted(canon(g.get_edge_endpoints_by_index(e)) for e in g.edge_indices()) - edges_b = sorted(canon(g2.get_edge_endpoints_by_index(e)) for e in g2.edge_indices()) - self.assertEqual(edges_a, edges_b) - def test_incremental_prefix_supported(self): - g = self._make_graph(3, [(0,1),(1,2)]) - s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) - s2 = (';' + s[1:]) if s.startswith(':') else (';' + s) - g2 = rx_sparse6.read_sparse6_str(s2) - self.assertEqual(g.num_edges(), g2.num_edges()) - def test_large_N_extended_forms(self): - n4=1000 - edges=[(0,1),(10,20),(500,400)] - g=self._make_graph(n4, edges) - s=rx_sparse6.write_sparse6_from_pygraph(g, header=False) - g2=rx_sparse6.read_sparse6_str(s) - self.assertEqual(g.num_nodes(), g2.num_nodes()) - n8=1<<18 - parts=[126,126]; val=n8; six_parts=[] - for i in range(6): six_parts.append((val>>(6*(5-i))) & 0x3F) - parts.extend(p+63 for p in six_parts) - s=":"+"".join(chr(p) for p in parts)+"\n" - g2=rx_sparse6.read_sparse6_str(s) - self.assertEqual(g2.num_nodes(), n8) - def test_roundtrip_random_small(self): - g=self._make_graph(5,[(0,1),(0,2),(3,4)]) - s=rx_sparse6.write_sparse6_from_pygraph(g, header=False) - g2=rx_sparse6.read_sparse6_str(s) - self.assertEqual(g.num_edges(), g2.num_edges()) - -if __name__=='__main__': - unittest.main() diff --git a/tests/test_sparse6_full.py b/tests/test_sparse6_full.py deleted file mode 100644 index 616256bde5..0000000000 --- a/tests/test_sparse6_full.py +++ /dev/null @@ -1,77 +0,0 @@ -import unittest -import rustworkx as rx -import rustworkx.sparse6 as rx_sparse6 - - -class TestSparse6Full(unittest.TestCase): - def _make_graph(self, n, edges): - g = rx.PyGraph() - # ensure n nodes - while g.num_nodes() < n: - g.add_node(None) - for u, v in edges: - g.add_edge(u, v, None) - return g - - def test_padding_special_case_examples(self): - # Small k=1, n=2: single edge between 0 and 1 - g = self._make_graph(2, [(0, 1)]) - s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) - g2 = rx_sparse6.read_sparse6_str(s) - self.assertEqual(g.num_edges(), g2.num_edges()) - - # k=2, n=4: build edges to trigger padding path - edges = [(0, 1), (3, 2)] - g = self._make_graph(4, edges) - s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) - g2 = rx_sparse6.read_sparse6_str(s) - def canon(ep): - u,v = ep - return (u,v) if u < v else (v,u) - edges_a = sorted([canon(g.get_edge_endpoints_by_index(e)) for e in g.edge_indices()]) - edges_b = sorted([canon(g2.get_edge_endpoints_by_index(e)) for e in g2.edge_indices()]) - self.assertEqual(edges_a, edges_b) - - def test_incremental_prefix_supported(self): - # incremental sparse6 begins with ';' followed by same body as ':' - g = self._make_graph(3, [(0, 1), (1, 2)]) - s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) - # switch leading ':' to ';' to simulate incremental form - if s.startswith(":"): - s2 = ";" + s[1:] - else: - s2 = ";" + s - g2 = rx_sparse6.read_sparse6_str(s2) - self.assertEqual(g.num_edges(), g2.num_edges()) - - def test_large_N_extended_forms(self): - # 4-byte: n >= 63 and < 2^18 - n4 = 1000 - edges = [(0, 1), (10, 20), (500, 400)] - g = self._make_graph(n4, edges) - s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) - g2 = rx_sparse6.read_sparse6_str(s) - self.assertEqual(g.num_nodes(), g2.num_nodes()) - - # 8-byte: test parsing of an 8-byte N(n) with no edges (avoid huge allocation) - n8 = 1 << 18 - # encode n8 into 126,126 + 6 chars of 6-bit values - parts = [126, 126] - val = n8 - six_parts = [] - for i in range(6): - six_parts.append((val >> (6 * (5 - i))) & 0x3F) - parts.extend([p + 63 for p in six_parts]) - s = ":" + "".join(chr(p) for p in parts) + "\n" - g2 = rx_sparse6.read_sparse6_str(s) - self.assertEqual(g2.num_nodes(), n8) - - def test_roundtrip_random_small(self): - g = self._make_graph(5, [(0, 1), (0, 2), (3, 4)]) - s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) - g2 = rx_sparse6.read_sparse6_str(s) - self.assertEqual(g.num_edges(), g2.num_edges()) - - -if __name__ == '__main__': - unittest.main() From ffd809d5c7f98146db92a457d367a5647bde2ff1 Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Mon, 22 Sep 2025 14:38:01 +0800 Subject: [PATCH 13/23] removed unwrap and panic --- src/digraph6.rs | 7 +- src/graph6.rs | 458 +++--------------------------------------------- 2 files changed, 32 insertions(+), 433 deletions(-) diff --git a/src/digraph6.rs b/src/digraph6.rs index 4243d1e711..c5dbe01072 100644 --- a/src/digraph6.rs +++ b/src/digraph6.rs @@ -24,7 +24,6 @@ impl DiGraph { } /// Creates a new DiGraph from a flattened adjacency matrix - #[cfg(test)] pub fn from_adj(adj: &[usize]) -> Result { let n2 = adj.len(); let n = (n2 as f64).sqrt() as usize; @@ -37,6 +36,9 @@ impl DiGraph { /// Validates graph6 directed representation pub(crate) fn valid_digraph(repr: &[u8]) -> Result { + if repr.is_empty() { + return Err(IOError::InvalidDigraphHeader); + } if repr[0] == b'&' { Ok(true) } else { @@ -103,7 +105,7 @@ pub fn write_graph6_from_pydigraph(pydigraph: Py) -> for (i, j, _w) in get_edge_iter_with_weights(&g.graph) { bit_vec[i * n + j] = 1; } - let graph6 = crate::graph6::write::write_graph6(bit_vec, n, true); + let graph6 = crate::graph6::write::write_graph6(bit_vec, n, true)?; Ok(graph6) }) } @@ -118,5 +120,4 @@ pub fn digraph_write_graph6_file(digraph: Py, path: & } // Enable write_graph() in tests for DiGraph via the WriteGraph trait -#[cfg(test)] impl crate::graph6::write::WriteGraph for DiGraph {} diff --git a/src/graph6.rs b/src/graph6.rs index 77b0787ead..858a6b6e11 100644 --- a/src/graph6.rs +++ b/src/graph6.rs @@ -243,11 +243,12 @@ pub mod utils { pub mod write { use super::utils::upper_triangle; use super::GraphConversion; + use super::IOError; /// Trait to write graphs into graph 6 formatted strings #[allow(dead_code)] pub trait WriteGraph: GraphConversion { - fn write_graph(&self) -> String { + fn write_graph(&self) -> Result { write_graph6(self.bit_vec().to_vec(), self.size(), self.is_directed()) } } @@ -258,14 +259,21 @@ pub mod write { } } - fn write_size(repr: &mut String, size: usize) { + fn push_char63(repr: &mut String, v: u32) -> Result<(), IOError> { + let raw = v + 63; // guarantee 63..=126 + let c = char::from_u32(raw).ok_or(IOError::InvalidSizeChar)?; + repr.push(c); + Ok(()) + } + + fn write_size(repr: &mut String, size: usize) -> Result<(), IOError> { // graph6 size encoding per formats.txt // n < 63: single char n+63 // 63 <= n < 2^18: '~' followed by 3 chars (18 bits) // 2^18 <= n < 2^36: '~~' followed by 6 chars (36 bits) // We assume caller validated upper bound (< 2^36) if size < 63 { - repr.push(char::from_u32((size as u32) + 63).unwrap()); + push_char63(repr, size as u32)?; } else if size < (1 << 18) { repr.push('~'); let mut val = size as u32; @@ -274,9 +282,7 @@ pub mod write { parts[i] = (val & 0x3F) as u8; val >>= 6; } - for p in parts.iter() { - repr.push(char::from_u32((*p as u32) + 63).unwrap()); - } + for p in parts.iter() { push_char63(repr, *p as u32)?; } } else { repr.push('~'); repr.push('~'); @@ -286,10 +292,9 @@ pub mod write { parts[i] = (val & 0x3F) as u8; val >>= 6; } - for p in parts.iter() { - repr.push(char::from_u32((*p as u32) + 63).unwrap()); - } + for p in parts.iter() { push_char63(repr, *p as u32)?; } } + Ok(()) } fn pad_bitvector(bit_vec: &mut Vec) { @@ -298,22 +303,23 @@ pub mod write { } } - fn parse_bitvector(bit_vec: &[usize], repr: &mut String) { + fn parse_bitvector(bit_vec: &[usize], repr: &mut String) -> Result<(), IOError> { for chunk in bit_vec.chunks(6) { let mut sum = 0; for (i, bit) in chunk.iter().rev().enumerate() { sum += bit * 2usize.pow(i as u32); } - let char = char::from_u32(sum as u32 + 63).unwrap(); - repr.push(char); + let raw = sum as u32 + 63; + let c = char::from_u32(raw).ok_or(IOError::InvalidSizeChar)?; + repr.push(c); } + Ok(()) } - pub fn write_graph6(bit_vec: Vec, n: usize, is_directed: bool) -> String { + pub fn write_graph6(bit_vec: Vec, n: usize, is_directed: bool) -> Result { // enforce graph6 maximum (2^36 - 1) like sparse6 if n >= (1usize << 36) { - // Return a placeholder that will fail parsing consistently; caller wraps in Overflow error upstream if needed - // (Keeping existing interface simple.) + return Err(IOError::GraphTooLarge); } let mut repr = String::new(); let mut bit_vec = if !is_directed { @@ -328,10 +334,10 @@ pub mod write { bit_vec }; write_header(&mut repr, is_directed); - write_size(&mut repr, n); + write_size(&mut repr, n)?; pad_bitvector(&mut bit_vec); - parse_bitvector(&bit_vec, &mut repr); - repr + parse_bitvector(&bit_vec, &mut repr)?; + Ok(repr) } } @@ -368,7 +374,6 @@ impl Graph { /// Creates a new undirected graph from a flattened adjacency matrix. /// The adjacency matrix must be square. /// The adjacency matrix will be forced into a symmetric matrix. - #[cfg(test)] pub fn from_adj(adj: &[usize]) -> Result { let n2 = adj.len(); let n = (n2 as f64).sqrt() as usize; @@ -433,7 +438,6 @@ impl GraphConversion for Graph { false } } -#[cfg(test)] impl write::WriteGraph for Graph {} use crate::digraph6::{DiGraph, digraph_to_pydigraph}; @@ -541,7 +545,7 @@ pub fn write_graph6_from_pygraph(pygraph: Py) -> PyResult { bit_vec[i * n + j] = 1; bit_vec[j * n + i] = 1; } - let graph6 = write::write_graph6(bit_vec, n, false); + let graph6 = write::write_graph6(bit_vec, n, false)?; Ok(graph6) }) } @@ -584,412 +588,6 @@ pub fn graph_write_graph6_file(graph: Py, path: &str) -> PyResult<()> { } /// Write a PyDiGraph to a graph6 file -// digraph write helpers are provided by crate::digraph6 - -#[cfg(test)] -mod testing { - use super::utils::{fill_bitvector, get_size, upper_triangle}; - use super::write::{write_graph6, WriteGraph}; - use super::{Graph, GraphConversion, IOError}; - use crate::digraph6::DiGraph; // bring DiGraph + trait impl into scope - - // Tests from error.rs - #[test] - fn test_error_enum() { - let err = IOError::InvalidDigraphHeader; - println!("{:?}", err); - } - - // Tests from utils.rs - #[test] - fn test_size_pos_0() { - let bytes = b"AG"; - let size = get_size(bytes, 0).unwrap(); - assert_eq!(size, 2); - } - - #[test] - fn test_size_pos_1() { - let bytes = b"&AG"; - let size = get_size(bytes, 1).unwrap(); - assert_eq!(size, 2); - } - - - #[test] - fn test_size_invalid_size_char() { - let bytes = b">AG"; - let size = get_size(bytes, 0).unwrap_err(); - assert_eq!(size, IOError::InvalidSizeChar); - } - - #[test] - fn test_bitvector() { - let bytes = b"Bw"; - let n = 3; - let bit_vec = fill_bitvector(bytes, n * n, 0).unwrap(); - assert_eq!(bit_vec, vec![0, 0, 0, 0, 1, 1, 1, 1, 1]); - } - - #[test] - fn test_bitvector_offset() { - let bytes = b"Bw"; - let n = 2; - let bit_vec = fill_bitvector(bytes, n * n, 1).unwrap(); - assert_eq!(bit_vec, vec![1, 1, 1, 0]); - } - - #[test] - fn test_upper_triangle_n2() { - let bit_vec = vec![0, 1, 1, 0]; - let tri = upper_triangle(&bit_vec, 2); - assert_eq!(tri, vec![1]); - } - - #[test] - fn test_upper_triangle_n3() { - let bit_vec = vec![0, 1, 1, 1, 0, 0, 1, 0, 0]; - let tri = upper_triangle(&bit_vec, 3); - assert_eq!(tri, vec![1, 1, 0]); - } - - // Tests from write.rs - #[test] - fn test_write_undirected_n2() { - let bit_vec = vec![0, 1, 1, 0]; - let repr = write_graph6(bit_vec, 2, false); - assert_eq!(repr, "A_"); - } - - #[test] - fn test_write_directed_n2_mirror() { - let bit_vec = vec![0, 1, 1, 0]; - let repr = write_graph6(bit_vec, 2, true); - assert_eq!(repr, "&AW"); - } - - #[test] - fn test_write_directed_n2_unmirrored() { - let bit_vec = vec![0, 0, 1, 0]; - let repr = write_graph6(bit_vec, 2, true); - assert_eq!(repr, "&AG"); - } - - // Tests from undirected.rs - #[test] - fn test_graph_n2() { - let graph = Graph::from_g6("A_").unwrap(); - assert_eq!(graph.size(), 2); - assert_eq!(graph.bit_vec(), &[0, 1, 1, 0]); - } - - #[test] - fn test_graph_n2_empty() { - let graph = Graph::from_g6("A?").unwrap(); - assert_eq!(graph.size(), 2); - assert_eq!(graph.bit_vec(), &[0, 0, 0, 0]); - } - - #[test] - fn test_graph_n3() { - let graph = Graph::from_g6("Bw").unwrap(); - assert_eq!(graph.size(), 3); - assert_eq!(graph.bit_vec(), &[0, 1, 1, 1, 0, 1, 1, 1, 0]); - } - - #[test] - fn test_graph_n4() { - let graph = Graph::from_g6("C~").unwrap(); - assert_eq!(graph.size(), 4); - assert_eq!( - graph.bit_vec(), - &[0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0] - ); - } - - #[test] - fn test_too_short_input() { - let parsed = Graph::from_g6("a"); - assert!(parsed.is_err()); - } - - #[test] - fn test_invalid_char() { - let parsed = Graph::from_g6("A1"); - assert!(parsed.is_err()); - } - - #[test] - fn test_to_adjacency() { - let graph = Graph::from_g6("A_").unwrap(); - let adj = graph.to_adjmat(); - assert_eq!(adj, "0 1\n1 0\n"); - } - - #[test] - fn test_to_dot() { - let graph = Graph::from_g6("A_").unwrap(); - let dot = graph.to_dot(None); - assert_eq!(dot, "graph {\n0 -- 1;\n}"); - } - - #[test] - fn test_to_dot_with_label() { - let graph = Graph::from_g6("A_").unwrap(); - let dot = graph.to_dot(Some(1)); - assert_eq!(dot, "graph graph_1 {\n0 -- 1;\n}"); - } - - #[test] - fn test_to_net() { - let repr = r"A_"; - let graph = Graph::from_g6(repr).unwrap(); - let net = graph.to_net(); - assert_eq!(net, "*Vertices 2\n1 \"0\"\n2 \"1\"\n*Arcs\n1 2\n2 1\n"); - } - - #[test] - fn test_to_flat() { - let repr = r"A_"; - let graph = Graph::from_g6(repr).unwrap(); - let flat = graph.to_flat(); - assert_eq!(flat, "0110"); - } - - #[test] - fn test_write_n2() { - let repr = r"A_"; - let graph = Graph::from_g6(repr).unwrap(); - let g6 = graph.write_graph(); - assert_eq!(g6, repr); - } - - #[test] - fn test_write_n3() { - let repr = r"Bw"; - let graph = Graph::from_g6(repr).unwrap(); - let g6 = graph.write_graph(); - assert_eq!(g6, repr); - } - - #[test] - fn test_write_n4() { - let repr = r"C~"; - let graph = Graph::from_g6(repr).unwrap(); - let g6 = graph.write_graph(); - assert_eq!(g6, repr); - } - - #[test] - fn test_from_adj() { - let adj = &[0, 0, 1, 0]; - let graph = Graph::from_adj(adj).unwrap(); - assert_eq!(graph.size(), 2); - assert_eq!(graph.bit_vec(), &[0, 1, 1, 0]); - assert_eq!(graph.write_graph(), "A_"); - } - - #[test] - fn test_from_nonsquare_adj() { - let adj = &[0, 0, 1, 0, 1]; - let graph = Graph::from_adj(adj); - assert!(graph.is_err()); - } - - // Tests from directed.rs - #[test] - fn test_header() { - let repr = b"&AG"; - assert!(DiGraph::valid_digraph(repr).is_ok()); - } - - #[test] - fn test_invalid_header() { - let repr = b"AG"; - assert!(DiGraph::valid_digraph(repr).is_err()); - } - - #[test] - fn test_from_adj_directed() { - let adj = &[0, 0, 1, 0]; - let graph = DiGraph::from_adj(adj).unwrap(); - assert_eq!(graph.size(), 2); - assert_eq!(graph.bit_vec(), vec![0, 0, 1, 0]); - assert_eq!(graph.write_graph(), "&AG"); - } - - #[test] - fn test_from_nonsquare_adj_directed() { - let adj = &[0, 0, 1, 0, 1]; - let graph = DiGraph::from_adj(adj); - assert!(graph.is_err()); - } - - #[test] - fn test_bitvector_n2() { - let repr = "&AG"; - let graph = DiGraph::from_d6(repr).unwrap(); - assert_eq!(graph.size(), 2); - assert_eq!(graph.bit_vec(), vec![0, 0, 1, 0]); - } - - #[test] - fn test_bitvector_n3() { - let repr = r"&B\o"; - let graph = DiGraph::from_d6(repr).unwrap(); - assert_eq!(graph.size(), 3); - assert_eq!(graph.bit_vec(), vec![0, 1, 1, 1, 0, 1, 1, 1, 0]); - } - - #[test] - fn test_bitvector_n4() { - let repr = r"&C]|w"; - let graph = DiGraph::from_d6(repr).unwrap(); - assert_eq!(graph.size(), 4); - assert_eq!( - graph.bit_vec(), - vec![0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0] - ); - } - - #[test] - fn test_init_invalid_n2() { - let repr = "AG"; - let graph = DiGraph::from_d6(repr); - assert!(graph.is_err()); - } - - #[test] - fn test_to_adjacency_directed() { - let repr = r"&C]|w"; - let graph = DiGraph::from_d6(repr).unwrap(); - let adj = graph.to_adjmat(); - assert_eq!(adj, "0 1 1 1\n1 0 1 1\n1 1 0 1\n1 1 1 0\n"); - } - - #[test] - fn test_to_dot_directed() { - let repr = r"&AG"; - let graph = DiGraph::from_d6(repr).unwrap(); - let dot = graph.to_dot(None); - assert_eq!(dot, "digraph {\n1 -> 0;\n}"); - } - - #[test] - fn test_to_dot_with_id_directed() { - let repr = r"&AG"; - let graph = DiGraph::from_d6(repr).unwrap(); - let dot = graph.to_dot(Some(1)); - assert_eq!(dot, "digraph graph_1 {\n1 -> 0;\n}"); - } - - #[test] - fn test_to_net_directed() { - let repr = r"&AG"; - let graph = DiGraph::from_d6(repr).unwrap(); - let net = graph.to_net(); - assert_eq!(net, "*Vertices 2\n1 \"0\"\n2 \"1\"\n*Arcs\n2 1\n"); - } - - #[test] - fn test_to_flat_directed() { - let repr = r"&AG"; - let graph = DiGraph::from_d6(repr).unwrap(); - let flat = graph.to_flat(); - assert_eq!(flat, "0010"); - } - - #[test] - fn test_write_n2_directed() { - let repr = r"&AG"; - let graph = DiGraph::from_d6(repr).unwrap(); - let graph6 = graph.write_graph(); - assert_eq!(graph6, repr); - } - - #[test] - fn test_write_n3_directed() { - let repr = r"&B\o"; - let graph = DiGraph::from_d6(repr).unwrap(); - let graph6 = graph.write_graph(); - assert_eq!(graph6, repr); - } - - #[test] - fn test_write_n4_directed() { - let repr = r"&C]|w"; - let graph = DiGraph::from_d6(repr).unwrap(); - let graph6 = graph.write_graph(); - assert_eq!(graph6, repr); - } - - #[test] - fn test_size_boundary_short_max() { - // n = 62 should be short form single char - let n = 62usize; - let ch = (n + 63) as u8; - let bytes = [ch]; - let (parsed, consumed) = super::utils::parse_size(&bytes, 0).unwrap(); - assert_eq!(parsed, n); - assert_eq!(consumed, 1); - } - - #[test] - fn test_size_boundary_short_to_medium_transition() { - // n = 63 must use medium form; short form would be non-canonical - let n = 63usize; - // Directly build header: '~' + 3 chars with 18-bit payload - let mut val = n as u32; - let mut parts = [0u8;3]; - for i in (0..3).rev() { parts[i] = (val & 0x3F) as u8; val >>= 6; } - let bytes = [b'~', parts[0]+63, parts[1]+63, parts[2]+63]; - let (parsed, consumed) = super::utils::parse_size(&bytes, 0).unwrap(); - assert_eq!(parsed, n); - assert_eq!(consumed, 4); - } - - - #[test] - fn test_size_boundary_medium_to_long_transition() { - // n = 2^18 requires long form and should parse correctly - let n = 1usize << 18; - let mut val = n as u64; - let mut parts = [0u8;6]; - for i in (0..6).rev() { parts[i] = (val & 0x3F) as u8; val >>= 6; } - let mut bytes = Vec::from(b"~~".as_ref()); - for p in parts { bytes.push(p + 63); } - let (parsed, consumed) = super::utils::parse_size(&bytes, 0).unwrap(); - assert_eq!(parsed, n); - assert_eq!(consumed, 8); - } - - #[test] - fn test_size_boundary_directed_short_medium_long() { - // Directed variants: prepend '&' then parse at offset 1 - // n=62 (short) - let n_short = 62usize; - let bytes_short = [b'&', (n_short + 63) as u8]; - let (parsed_s, consumed_s) = super::utils::parse_size(&bytes_short, 1).unwrap(); - assert_eq!(parsed_s, n_short); - assert_eq!(consumed_s, 1); - // n=63 (medium) - let n_med = 63usize; - let mut val = n_med as u32; - let mut parts = [0u8;3]; - for i in (0..3).rev() { parts[i] = (val & 0x3F) as u8; val >>= 6; } - let bytes_med = [b'&', b'~', parts[0]+63, parts[1]+63, parts[2]+63]; - let (parsed_m, consumed_m) = super::utils::parse_size(&bytes_med, 1).unwrap(); - assert_eq!(parsed_m, n_med); - assert_eq!(consumed_m, 4); - // n=2^18 (long) - let n_long = 1usize << 18; - let mut val_l = n_long as u64; - let mut parts_l = [0u8;6]; - for i in (0..6).rev() { parts_l[i] = (val_l & 0x3F) as u8; val_l >>= 6; } - let mut bytes_long = vec![b'&', b'~', b'~']; - for p in parts_l { bytes_long.push(p + 63); } - let (parsed_l, consumed_l) = super::utils::parse_size(&bytes_long, 1).unwrap(); - assert_eq!(parsed_l, n_long); - assert_eq!(consumed_l, 8); - } -} +/// +/// Implemented in crate::digraph6 module (helpers are provided there). +pub(crate) fn _digraph_write_graph6_file_doc_placeholder() {} From a5c6a903bb472797b64381dd91ad2a1f074b366d Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Mon, 22 Sep 2025 19:56:00 +0800 Subject: [PATCH 14/23] update naming --- .gitignore | 1 + src/digraph6.rs | 11 ++- src/graph6.rs | 185 ++++++++++++++++++++++++++---------------------- 3 files changed, 105 insertions(+), 92 deletions(-) diff --git a/.gitignore b/.gitignore index ee3e0af630..d13b8c7f9a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ rustworkx-core/Cargo.lock **/.DS_Store venv/ .python-version +.venv-graph6/ diff --git a/src/digraph6.rs b/src/digraph6.rs index c5dbe01072..491cbdf854 100644 --- a/src/digraph6.rs +++ b/src/digraph6.rs @@ -7,11 +7,11 @@ use petgraph::algo; /// Directed graph implementation (extracted from graph6.rs) #[derive(Debug)] -pub struct DiGraph { +pub struct DiGraph6 { pub bit_vec: Vec, pub n: usize, } -impl DiGraph { +impl DiGraph6 { /// Creates a new DiGraph from a graph6 representation string pub fn from_d6(repr: &str) -> Result { let bytes = repr.as_bytes(); @@ -54,7 +54,7 @@ impl DiGraph { } } -impl GraphConversion for DiGraph { +impl GraphConversion for DiGraph6 { fn bit_vec(&self) -> &[usize] { &self.bit_vec } @@ -69,7 +69,7 @@ impl GraphConversion for DiGraph { } /// Convert internal DiGraph to PyDiGraph -pub fn digraph_to_pydigraph<'py>(py: Python<'py>, g: &DiGraph) -> PyResult> { +pub fn digraph_to_pydigraph<'py>(py: Python<'py>, g: &DiGraph6) -> PyResult> { use crate::graph6::GraphConversion as _; let mut graph = StablePyGraph::::with_capacity(g.size(), 0); for _ in 0..g.size() { @@ -119,5 +119,4 @@ pub fn digraph_write_graph6_file(digraph: Py, path: & Ok(()) } -// Enable write_graph() in tests for DiGraph via the WriteGraph trait -impl crate::graph6::write::WriteGraph for DiGraph {} +impl crate::graph6::write::WriteGraph for DiGraph6 {} diff --git a/src/graph6.rs b/src/graph6.rs index 858a6b6e11..99463dc33e 100644 --- a/src/graph6.rs +++ b/src/graph6.rs @@ -3,7 +3,6 @@ //! the separate files in `src/` so callers can `mod all; use all::...` and //! avoid many `use super` / `use crate` imports inside the library. -#[allow(dead_code)] /// Conversion trait for graphs into various text graph formats pub trait GraphConversion { /// Returns the bitvector representation of the graph @@ -129,11 +128,96 @@ pub enum IOError { InvalidDigraphHeader, InvalidSizeChar, GraphTooLarge, - #[allow(dead_code)] InvalidAdjacencyMatrix, NonCanonicalEncoding, } +// --------------------------------------------------------------------------- +// Shared size (N(n)) encoding/decoding and bit-width helper used by graph6, +// digraph6, and sparse6. Centralizing here avoids divergence in canonical +// encoding rules and bound checks. The formats share identical size rules. +// --------------------------------------------------------------------------- + +/// Trait encapsulating graph size field (N(n)) codec. +pub trait SizeCodec { + /// Encode a vertex count `n` into its canonical representation as 63-offset bytes. + fn encode_size(n: usize) -> Result, IOError>; + /// Decode size field at position `pos`, returning (n, bytes_consumed). + fn decode_size(bytes: &[u8], pos: usize) -> Result<(usize, usize), IOError>; + /// Compute number of bits needed to represent integers in [0, n-1]. (R(x) in spec) + fn needed_bits(n: usize) -> usize { + if n <= 1 { 0 } else { (usize::BITS - (n - 1).leading_zeros()) as usize } + } +} + +/// Concrete codec implementation shared across formats. +pub struct GraphNumberCodec; + +impl GraphNumberCodec { + #[inline] + fn validate(n: usize) -> Result<(), IOError> { + if n >= (1usize << 36) { return Err(IOError::GraphTooLarge); } + Ok(()) + } +} + +impl SizeCodec for GraphNumberCodec { + fn encode_size(n: usize) -> Result, IOError> { + Self::validate(n)?; + let mut out = Vec::with_capacity(8); + if n < 63 { + out.push((n as u8) + 63); + } else if n < (1 << 18) { + out.push(b'~'); + let mut v = n as u32; + let mut parts = [0u8; 3]; + for i in (0..3).rev() { parts[i] = (v & 0x3F) as u8; v >>= 6; } + out.extend(parts.iter().map(|p| p + 63)); + } else { + out.push(b'~'); out.push(b'~'); + let mut v = n as u64; + let mut parts = [0u8; 6]; + for i in (0..6).rev() { parts[i] = (v & 0x3F) as u8; v >>= 6; } + out.extend(parts.iter().map(|p| p + 63)); + } + Ok(out) + } + + fn decode_size(bytes: &[u8], pos: usize) -> Result<(usize, usize), IOError> { + let first = *bytes.get(pos).ok_or(IOError::InvalidSizeChar)?; + if first == b'~' { + let second = *bytes.get(pos + 1).ok_or(IOError::InvalidSizeChar)?; + if second == b'~' { + // long form: '~~' + 6 chars + let mut val: u64 = 0; + for i in 0..6 { + let c = *bytes.get(pos + 2 + i).ok_or(IOError::InvalidSizeChar)?; + if c < 63 { return Err(IOError::InvalidSizeChar); } + val = (val << 6) | ((c - 63) as u64); + } + if val >= (1u64 << 36) { return Err(IOError::GraphTooLarge); } + if val < (1 << 18) { return Err(IOError::NonCanonicalEncoding); } + Ok((val as usize, 8)) + } else { + // medium form: '~' + 3 chars + let mut val: u32 = 0; + for i in 0..3 { + let c = *bytes.get(pos + 1 + i).ok_or(IOError::InvalidSizeChar)?; + if c < 63 { return Err(IOError::InvalidSizeChar); } + val = (val << 6) | ((c - 63) as u32); + } + if val < 63 { return Err(IOError::NonCanonicalEncoding); } + Ok((val as usize, 4)) + } + } else { + if first < 63 { return Err(IOError::InvalidSizeChar); } + let n = (first - 63) as usize; + if n >= 63 { return Err(IOError::NonCanonicalEncoding); } + Ok((n, 1)) + } + } +} + impl From for PyErr { fn from(e: IOError) -> PyErr { match e { @@ -157,6 +241,7 @@ impl From for PyErr { /// Utility functions used by parsers and writers pub mod utils { use super::IOError; + use super::{GraphNumberCodec, SizeCodec}; /// Iterates through the bytes of a graph and fills a bitvector representing /// the adjacency matrix of the graph @@ -184,46 +269,7 @@ pub mod utils { /// - '~' + 3 chars: 63 <= n < 2^18 (except values whose top 6 bits are all 1, to avoid ambiguity with long form) /// - '~~' + 6 chars: remaining values up to < 2^36 pub fn parse_size(bytes: &[u8], pos: usize) -> Result<(usize, usize), IOError> { - let first = *bytes.get(pos).ok_or(IOError::InvalidSizeChar)?; - if first == b'~' { - let second = *bytes.get(pos + 1).ok_or(IOError::InvalidSizeChar)?; - if second == b'~' { - // long form: '~~' + 6 chars (36 bits) - let mut val: u64 = 0; - for i in 0..6 { - let c = *bytes.get(pos + 2 + i).ok_or(IOError::InvalidSizeChar)?; - if c < 63 { return Err(IOError::InvalidSizeChar); } - val = (val << 6) | ((c - 63) as u64); - } - if val >= (1u64 << 36) { return Err(IOError::GraphTooLarge); } - // Detect impossible canonical overflow sentinel: encoding of 2^36 results in wrapped 0 after truncation - if val == 0 { return Err(IOError::GraphTooLarge); } - Ok((val as usize, 8)) - } else { - // medium form: '~' + 3 chars (18 bits) - let mut val: u32 = 0; - for i in 0..3 { - let c = *bytes.get(pos + 1 + i).ok_or(IOError::InvalidSizeChar)?; - if c < 63 { return Err(IOError::InvalidSizeChar); } - val = (val << 6) | ((c - 63) as u32); - } - if val < 63 { return Err(IOError::NonCanonicalEncoding); } // should have used short form - Ok((val as usize, 4)) - } - } else if first < 63 { // below ASCII '?' (63) invalid - Err(IOError::InvalidSizeChar) - } else { - // short form - let n = (first - 63) as usize; - if n >= 63 { return Err(IOError::NonCanonicalEncoding); } // should use extended form - Ok((n, 1)) - } - } - - /// Backwards compatible helper used by legacy tests expecting only the size value. - #[allow(dead_code)] - pub fn get_size(bytes: &[u8], pos: usize) -> Result { - parse_size(bytes, pos).map(|(n, _)| n) + GraphNumberCodec::decode_size(bytes, pos) } /// Returns the upper triangle of a bitvector @@ -244,9 +290,9 @@ pub mod write { use super::utils::upper_triangle; use super::GraphConversion; use super::IOError; + use super::{GraphNumberCodec, SizeCodec}; /// Trait to write graphs into graph 6 formatted strings - #[allow(dead_code)] pub trait WriteGraph: GraphConversion { fn write_graph(&self) -> Result { write_graph6(self.bit_vec().to_vec(), self.size(), self.is_directed()) @@ -259,41 +305,9 @@ pub mod write { } } - fn push_char63(repr: &mut String, v: u32) -> Result<(), IOError> { - let raw = v + 63; // guarantee 63..=126 - let c = char::from_u32(raw).ok_or(IOError::InvalidSizeChar)?; - repr.push(c); - Ok(()) - } - fn write_size(repr: &mut String, size: usize) -> Result<(), IOError> { - // graph6 size encoding per formats.txt - // n < 63: single char n+63 - // 63 <= n < 2^18: '~' followed by 3 chars (18 bits) - // 2^18 <= n < 2^36: '~~' followed by 6 chars (36 bits) - // We assume caller validated upper bound (< 2^36) - if size < 63 { - push_char63(repr, size as u32)?; - } else if size < (1 << 18) { - repr.push('~'); - let mut val = size as u32; - let mut parts = [0u8; 3]; - for i in (0..3).rev() { - parts[i] = (val & 0x3F) as u8; - val >>= 6; - } - for p in parts.iter() { push_char63(repr, *p as u32)?; } - } else { - repr.push('~'); - repr.push('~'); - let mut val = size as u64; - let mut parts = [0u8; 6]; - for i in (0..6).rev() { - parts[i] = (val & 0x3F) as u8; - val >>= 6; - } - for p in parts.iter() { push_char63(repr, *p as u32)?; } - } + let enc = GraphNumberCodec::encode_size(size)?; + for b in enc { repr.push(b as char); } Ok(()) } @@ -358,11 +372,11 @@ use std::path::Path; /// Undirected graph implementation #[derive(Debug)] -pub struct Graph { +pub struct Graph6 { pub bit_vec: Vec, pub n: usize, } -impl Graph { +impl Graph6 { /// Creates a new undirected graph from a graph6 representation pub fn from_g6(repr: &str) -> Result { let bytes = repr.as_bytes(); @@ -424,8 +438,7 @@ impl Graph { Ok(bit_vec) } } -#[allow(dead_code)] -impl GraphConversion for Graph { +impl GraphConversion for Graph6 { fn bit_vec(&self) -> &[usize] { &self.bit_vec } @@ -438,14 +451,14 @@ impl GraphConversion for Graph { false } } -impl write::WriteGraph for Graph {} +impl write::WriteGraph for Graph6 {} -use crate::digraph6::{DiGraph, digraph_to_pydigraph}; +use crate::digraph6::{DiGraph6, digraph_to_pydigraph}; // End of combined module /// Convert internal Graph (undirected) to PyGraph -fn graph_to_pygraph<'py>(py: Python<'py>, g: &Graph) -> PyResult> { +fn graph_to_pygraph<'py>(py: Python<'py>, g: &Graph6) -> PyResult> { let mut graph = StablePyGraph::::with_capacity(g.size(), 0); // add nodes for _ in 0..g.size() { @@ -497,8 +510,8 @@ pub fn read_graph6_str<'py>(py: Python<'py>, repr: &str) -> PyResult Date: Mon, 22 Sep 2025 20:16:22 +0800 Subject: [PATCH 15/23] update naming --- src/graph6.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/graph6.rs b/src/graph6.rs index 99463dc33e..b0b3ec9fd9 100644 --- a/src/graph6.rs +++ b/src/graph6.rs @@ -516,11 +516,11 @@ pub fn read_graph6_str<'py>(py: Python<'py>, repr: &str) -> PyResult(ParserResult::Graph(g)); } // try directed - if let Ok(dg) = DiGraph::from_d6(repr) { + if let Ok(dg) = DiGraph6::from_d6(repr) { return Ok(ParserResult::DiGraph(dg)); } Err(IOError::NonCanonicalEncoding) From 5bd2a654420190f162f61af6721b367284abac1d Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Tue, 23 Sep 2025 11:56:20 +0800 Subject: [PATCH 16/23] tidyup python --- .gitignore | 1 + rustworkx/__init__.py | 22 ++++++++++++ rustworkx/digraph6.py | 30 ---------------- rustworkx/graph6.py | 43 ----------------------- rustworkx/sparse6.py | 24 ------------- src/graph6.rs | 30 ++++++++-------- tests/test_digraph6.py | 11 +++--- tests/test_graph6.py | 78 ++++++++++++++++++++++++++++++------------ tests/test_sparse6.py | 27 +++++++-------- 9 files changed, 111 insertions(+), 155 deletions(-) delete mode 100644 rustworkx/digraph6.py delete mode 100644 rustworkx/graph6.py delete mode 100644 rustworkx/sparse6.py diff --git a/.gitignore b/.gitignore index d13b8c7f9a..a89c885552 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ rustworkx-core/Cargo.lock venv/ .python-version .venv-graph6/ +graph6-doc/ diff --git a/rustworkx/__init__.py b/rustworkx/__init__.py index 72507525a8..5fc240daa4 100644 --- a/rustworkx/__init__.py +++ b/rustworkx/__init__.py @@ -13,6 +13,28 @@ from .rustworkx import * +# ---- Inlined graph6/digraph6/sparse6 helpers (previously separate modules) ---- +# These were thin wrappers around the Rust extension functions. They are now +# exposed directly at the package root to simplify the public API surface. +# Backwards compatibility: importing the removed submodules will no longer work; +# users should call these root-level functions instead. (If needed we can add +# stub modules re-exporting these names.) + +from .rustworkx import ( + read_graph6_str, + write_graph6_from_pygraph, + write_graph6_from_pydigraph, + graph_write_graph6_file, + digraph_write_graph6_file, + read_graph6_file, + read_sparse6_str, + write_sparse6_from_pygraph, +) + +# Provide canonical short aliases matching the former module-level 'read'/'write' +read = read_graph6_str +write = write_graph6_from_pygraph + # flake8: noqa import rustworkx.visit diff --git a/rustworkx/digraph6.py b/rustworkx/digraph6.py deleted file mode 100644 index 5149f9d1c2..0000000000 --- a/rustworkx/digraph6.py +++ /dev/null @@ -1,30 +0,0 @@ -"""digraph6 format helpers. - -Directed variant of graph6 per the documented format. The core dispatch -routine already auto-detects directed strings (leading '&') or header form -(>>digraph6<<:). This namespace provides clarity and future room for -specialized helpers without breaking existing API. -""" -from __future__ import annotations - -from . import read_graph6_str as _read_graph6_str -from . import write_graph6_from_pydigraph as _write_graph6_from_pydigraph - -__all__ = [ - "read_graph6_str", - "write_graph6_from_pydigraph", - "read", - "write", -] - - -def read_graph6_str(repr: str): # noqa: D401 - thin wrapper - return _read_graph6_str(repr) - -read = read_graph6_str - - -def write_graph6_from_pydigraph(digraph): # noqa: D401 - thin wrapper - return _write_graph6_from_pydigraph(digraph) - -write = write_graph6_from_pydigraph diff --git a/rustworkx/graph6.py b/rustworkx/graph6.py deleted file mode 100644 index b482f44c99..0000000000 --- a/rustworkx/graph6.py +++ /dev/null @@ -1,43 +0,0 @@ -"""graph6 format helpers. - -This module provides a namespace for working with undirected graph6 strings -as described in: https://users.cecs.anu.edu.au/~bdm/data/formats.txt - -It wraps the low-level functions exported from the compiled extension -(`read_graph6_str`, `write_graph6_from_pygraph`) and offers convenience -helpers. Backwards compatibility: existing top-level functions in -`rustworkx` remain valid; this is a thin façade only. -""" -from __future__ import annotations - -from . import read_graph6_str as _read_graph6_str -from . import write_graph6_from_pygraph as _write_graph6_from_pygraph - -__all__ = [ - "read_graph6_str", - "write_graph6_from_pygraph", - "read", - "write", -] - - -def read_graph6_str(repr: str): - """Parse a graph6 representation into a PyGraph. - - Accepts either raw graph6, header form (>>graph6<<:), or directed strings. - For clarity, use digraph6.read_graph6_str for directed graphs. This wrapper - leaves behavior unchanged (delegates to the core function) but documents - intent that this namespace targets undirected graphs. - """ - g = _read_graph6_str(repr) - return g - -# Short aliases -read = read_graph6_str - - -def write_graph6_from_pygraph(graph): - """Serialize a PyGraph to a graph6 string.""" - return _write_graph6_from_pygraph(graph) - -write = write_graph6_from_pygraph diff --git a/rustworkx/sparse6.py b/rustworkx/sparse6.py deleted file mode 100644 index 594e113e79..0000000000 --- a/rustworkx/sparse6.py +++ /dev/null @@ -1,24 +0,0 @@ -"""sparse6 format helpers (placeholder). - -The sparse6 format is related to graph6/digraph6 but optimized for sparse -graphs. Parsing is currently not implemented in the Rust core; the Rust -layer returns an UnsupportedFormat error when an explicit sparse6 header is -encountered. - -This module centralizes the placeholder so future implementation can add -real parsing while giving users a discoverable namespace today. -""" -from __future__ import annotations - -from . import read_sparse6_str as _read_sparse6_str -from . import write_sparse6_from_pygraph as _write_sparse6_from_pygraph - -__all__ = ["read_sparse6_str", "write_sparse6_from_pygraph"] - - -def read_sparse6_str(repr: str): - return _read_sparse6_str(repr) - - -def write_sparse6_from_pygraph(pygraph, header: bool = True): - return _write_sparse6_from_pygraph(pygraph, header) diff --git a/src/graph6.rs b/src/graph6.rs index b0b3ec9fd9..8b02958de0 100644 --- a/src/graph6.rs +++ b/src/graph6.rs @@ -545,22 +545,20 @@ pub fn read_graph6_str<'py>(py: Python<'py>, repr: &str) -> PyResult) -> PyResult { - Python::with_gil(|py| { - let g = pygraph.borrow(py); - let n = g.graph.node_count(); - if n >= (1usize << 36) { - return Err(Graph6OverflowError::new_err("Graph too large for graph6 encoding")); - } - // build bit_vec - let mut bit_vec = vec![0usize; n * n]; - for (i, j, _w) in get_edge_iter_with_weights(&g.graph) { - bit_vec[i * n + j] = 1; - bit_vec[j * n + i] = 1; - } - let graph6 = write::write_graph6(bit_vec, n, false)?; - Ok(graph6) - }) +pub fn write_graph6_from_pygraph<'py>(py: Python<'py>, pygraph: Py) -> PyResult { + let g = pygraph.borrow(py); + let n = g.graph.node_count(); + if n >= (1usize << 36) { + return Err(Graph6OverflowError::new_err("Graph too large for graph6 encoding")); + } + // build bit_vec + let mut bit_vec = vec![0usize; n * n]; + for (i, j, _w) in get_edge_iter_with_weights(&g.graph) { + bit_vec[i * n + j] = 1; + bit_vec[j * n + i] = 1; + } + let graph6 = write::write_graph6(bit_vec, n, false)?; + Ok(graph6) } /// Parse the size header of a graph6 or digraph6 string. diff --git a/tests/test_digraph6.py b/tests/test_digraph6.py index 1e7270ffa9..cbb2659a04 100644 --- a/tests/test_digraph6.py +++ b/tests/test_digraph6.py @@ -1,6 +1,5 @@ import unittest import rustworkx as rx -import rustworkx.digraph6 as rx_digraph6 class TestDigraph6Format(unittest.TestCase): @@ -8,8 +7,8 @@ def test_roundtrip_small_directed(self): g = rx.PyDiGraph() g.add_nodes_from([None, None]) g.add_edge(0, 1, None) - s = rx_digraph6.write_graph6_from_pydigraph(g) - new_g = rx_digraph6.read_graph6_str(s) + s = rx.write_graph6_from_pydigraph(g) + new_g = rx.read_graph6_str(s) self.assertIsInstance(new_g, rx.PyDiGraph) self.assertEqual(new_g.num_nodes(), 2) self.assertEqual(new_g.num_edges(), 1) @@ -18,8 +17,8 @@ def test_asymmetric_two_edge(self): g = rx.PyDiGraph() g.add_nodes_from([None, None]) g.add_edges_from([(0, 1, None), (1, 0, None)]) - s = rx_digraph6.write_graph6_from_pydigraph(g) - new_g = rx_digraph6.read_graph6_str(s) + s = rx.write_graph6_from_pydigraph(g) + new_g = rx.read_graph6_str(s) self.assertIsInstance(new_g, rx.PyDiGraph) self.assertEqual(new_g.num_edges(), 2) @@ -40,7 +39,7 @@ def test_invalid_string(self): # Rust implementation may panic on malformed input; accept any # raised BaseException (including the pyo3 PanicException wrapper). with self.assertRaises(BaseException): - rx_digraph6.read_graph6_str('&invalid') + rx.read_graph6_str('&invalid') if __name__ == '__main__': # pragma: no cover diff --git a/tests/test_graph6.py b/tests/test_graph6.py index 507bbf9568..95dfe20d84 100644 --- a/tests/test_graph6.py +++ b/tests/test_graph6.py @@ -1,12 +1,17 @@ import tempfile import rustworkx as rx -import rustworkx.graph6 as rx_graph6 -import rustworkx.digraph6 as rx_digraph6 import unittest import os +import gzip # added for gzip file write tests class TestGraph6(unittest.TestCase): + # Merged helper from test_graph6_file_write.py + def _build_two_node_graph(self): + g = rx.PyGraph() + g.add_nodes_from(range(2)) + g.add_edge(0, 1, None) + return g def test_graph6_roundtrip(self): # build a small graph with node/edge attrs g = rx.PyGraph() @@ -39,7 +44,7 @@ def test_graph6_roundtrip(self): def test_read_graph6_str_undirected(self): """Test reading an undirected graph from a graph6 string.""" g6_str = "A_" - graph = rx_graph6.read_graph6_str(g6_str) + graph = rx.read_graph6_str(g6_str) self.assertIsInstance(graph, rx.PyGraph) self.assertEqual(graph.num_nodes(), 2) self.assertEqual(graph.num_edges(), 1) @@ -48,7 +53,7 @@ def test_read_graph6_str_undirected(self): def test_read_graph6_str_directed(self): """Test reading a directed graph from a graph6 string.""" g6_str = "&AG" - graph = rx_digraph6.read_graph6_str(g6_str) + graph = rx.read_graph6_str(g6_str) self.assertIsInstance(graph, rx.PyDiGraph) self.assertEqual(graph.num_nodes(), 2) self.assertEqual(graph.num_edges(), 1) @@ -59,7 +64,7 @@ def test_write_graph6_from_pygraph(self): graph = rx.PyGraph() graph.add_nodes_from(range(2)) graph.add_edge(0, 1, None) - g6_str = rx_graph6.write_graph6_from_pygraph(graph) + g6_str = rx.write_graph6_from_pygraph(graph) self.assertEqual(g6_str, "A_") def test_write_graph6_from_pydigraph(self): @@ -67,14 +72,14 @@ def test_write_graph6_from_pydigraph(self): graph = rx.PyDiGraph() graph.add_nodes_from(range(2)) graph.add_edge(1, 0, None) - g6_str = rx_digraph6.write_graph6_from_pydigraph(graph) + g6_str = rx.write_graph6_from_pydigraph(graph) self.assertEqual(g6_str, "&AG") def test_roundtrip_undirected(self): """Test roundtrip for an undirected graph.""" graph = rx.generators.path_graph(4) - g6_str = rx_graph6.write_graph6_from_pygraph(graph) - new_graph = rx_graph6.read_graph6_str(g6_str) + g6_str = rx.write_graph6_from_pygraph(graph) + new_graph = rx.read_graph6_str(g6_str) self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) self.assertEqual(graph.num_edges(), new_graph.num_edges()) self.assertEqual(graph.edge_list(), new_graph.edge_list()) @@ -82,8 +87,8 @@ def test_roundtrip_undirected(self): def test_roundtrip_directed(self): """Test roundtrip for a directed graph.""" graph = rx.generators.directed_path_graph(4) - g6_str = rx_digraph6.write_graph6_from_pydigraph(graph) - new_graph = rx_digraph6.read_graph6_str(g6_str) + g6_str = rx.write_graph6_from_pydigraph(graph) + new_graph = rx.read_graph6_str(g6_str) self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) self.assertEqual(graph.num_edges(), new_graph.num_edges()) self.assertEqual(graph.edge_list(), new_graph.edge_list()) @@ -133,13 +138,13 @@ def test_digraph_write_graph6_file(self): def test_invalid_graph6_string(self): """Test that an invalid graph6 string raises an error.""" with self.assertRaises(Exception): - rx_graph6.read_graph6_str("invalid_string") + rx.read_graph6_str("invalid_string") def test_empty_graph(self): """Test writing and reading an empty graph.""" graph = rx.PyGraph() - g6_str = rx_graph6.write_graph6_from_pygraph(graph) - new_graph = rx_graph6.read_graph6_str(g6_str) + g6_str = rx.write_graph6_from_pygraph(graph) + new_graph = rx.read_graph6_str(g6_str) self.assertEqual(new_graph.num_nodes(), 0) self.assertEqual(new_graph.num_edges(), 0) @@ -147,19 +152,48 @@ def test_graph_with_no_edges(self): """Test a graph with nodes but no edges.""" graph = rx.PyGraph() graph.add_nodes_from(range(5)) - g6_str = rx_graph6.write_graph6_from_pygraph(graph) - new_graph = rx_graph6.read_graph6_str(g6_str) + g6_str = rx.write_graph6_from_pygraph(graph) + new_graph = rx.read_graph6_str(g6_str) self.assertEqual(new_graph.num_nodes(), 5) self.assertEqual(new_graph.num_edges(), 0) + # ---- Merged file write tests from test_graph6_file_write.py ---- + def test_write_plain_file(self): + g = self._build_two_node_graph() + expected = "A_" # known graph6 for 2-node single edge + with tempfile.NamedTemporaryFile(suffix=".g6", delete=False) as fd: + path = fd.name + try: + rx.graph_write_graph6_file(g, path) + with open(path, "rt", encoding="ascii") as fh: + content = fh.read().strip() + self.assertEqual(expected, content) + finally: + if os.path.exists(path): + os.remove(path) + + def test_write_gzip_file(self): + g = self._build_two_node_graph() + expected = "A_" + with tempfile.NamedTemporaryFile(suffix=".g6.gz", delete=False) as fd: + path = fd.name + try: + rx.graph_write_graph6_file(g, path) + with gzip.open(path, "rt", encoding="ascii") as fh: + content = fh.read().strip() + self.assertEqual(expected, content) + finally: + if os.path.exists(path): + os.remove(path) + class TestGraph6FormatExtras(unittest.TestCase): def test_roundtrip_small_undirected(self): g = rx.PyGraph() g.add_nodes_from([None, None]) g.add_edge(0, 1, None) - s = rx_graph6.write_graph6_from_pygraph(g) - new_g = rx_graph6.read_graph6_str(s) + s = rx.write_graph6_from_pygraph(g) + new_g = rx.read_graph6_str(s) self.assertIsInstance(new_g, rx.PyGraph) self.assertEqual(new_g.num_nodes(), 2) self.assertEqual(new_g.num_edges(), 1) @@ -168,8 +202,8 @@ def test_write_and_read_triangle(self): g = rx.PyGraph() g.add_nodes_from([None, None, None]) g.add_edges_from([(0, 1, None), (1, 2, None), (0, 2, None)]) - s = rx_graph6.write_graph6_from_pygraph(g) - new_g = rx_graph6.read_graph6_str(s) + s = rx.write_graph6_from_pygraph(g) + new_g = rx.read_graph6_str(s) self.assertIsInstance(new_g, rx.PyGraph) self.assertEqual(new_g.num_nodes(), 3) self.assertEqual(new_g.num_edges(), 3) @@ -179,7 +213,7 @@ def test_file_roundtrip_format(self): g = rx.PyGraph() g.add_nodes_from([None, None, None, None]) g.add_edges_from([(0, 1, None), (2, 3, None)]) - s = rx_graph6.write_graph6_from_pygraph(g) + s = rx.write_graph6_from_pygraph(g) with tempfile.TemporaryDirectory() as td: p = pathlib.Path(td) / 'u.g6' rx.graph_write_graph6_file(g, str(p)) @@ -187,11 +221,11 @@ def test_file_roundtrip_format(self): self.assertIsInstance(g2, rx.PyGraph) self.assertEqual(g2.num_nodes(), 4) self.assertEqual(g2.num_edges(), 2) - self.assertEqual(rx_graph6.write_graph6_from_pygraph(g2), s) + self.assertEqual(rx.write_graph6_from_pygraph(g2), s) def test_invalid_string_format(self): with self.assertRaises(Exception): - rx_graph6.read_graph6_str('invalid_string') + rx.read_graph6_str('invalid_string') # ---- Size parse tests (merged from test_graph6_size_parse.py) ---- diff --git a/tests/test_sparse6.py b/tests/test_sparse6.py index 4ef0273320..b16f43bc81 100644 --- a/tests/test_sparse6.py +++ b/tests/test_sparse6.py @@ -1,39 +1,38 @@ import unittest -import rustworkx -import rustworkx.sparse6 as rx_sparse6 +import rustworkx as rx class TestSparse6(unittest.TestCase): def test_header_only_raises(self): - with self.assertRaises(rustworkx.Graph6Error): - rx_sparse6.read_sparse6_str('>>sparse6<<:') + with self.assertRaises(rx.Graph6Error): + rx.read_sparse6_str('>>sparse6<<:') def test_header_with_size_and_no_edges(self): # n = 1 encoded as '@' (value 1) after header colon - g = rx_sparse6.read_sparse6_str('>>sparse6<<:@') + g = rx.read_sparse6_str('>>sparse6<<:@') self.assertEqual(g.num_nodes(), 1) self.assertEqual(g.num_edges(), 0) def test_empty_string_raises(self): - with self.assertRaises(rustworkx.Graph6Error): - rx_sparse6.read_sparse6_str('') + with self.assertRaises(rx.Graph6Error): + rx.read_sparse6_str('') def test_header_with_whitespace_raises(self): - with self.assertRaises(rustworkx.Graph6Error): - rx_sparse6.read_sparse6_str('>>sparse6<<: ') + with self.assertRaises(rx.Graph6Error): + rx.read_sparse6_str('>>sparse6<<: ') def test_control_chars_in_payload(self): - with self.assertRaises(rustworkx.Graph6Error): - rx_sparse6.read_sparse6_str('>>sparse6<<:\x00\x01\x02') + with self.assertRaises(rx.Graph6Error): + rx.read_sparse6_str('>>sparse6<<:\x00\x01\x02') def test_roundtrip_small_graph(self): - g = rustworkx.PyGraph() + g = rx.PyGraph() for _ in range(4): g.add_node(None) g.add_edge(0,1,None) g.add_edge(2,3,None) - s = rx_sparse6.write_sparse6_from_pygraph(g, header=False) - g2 = rx_sparse6.read_sparse6_str(s) + s = rx.write_sparse6_from_pygraph(g, header=False) + g2 = rx.read_sparse6_str(s) self.assertEqual(g2.num_nodes(), g.num_nodes()) self.assertEqual(g2.num_edges(), g.num_edges()) From 8a2ae099f722dd47434d3c089f7c033fb38fe1db Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Tue, 23 Sep 2025 14:09:25 +0800 Subject: [PATCH 17/23] Remove let wrapped = std::panic::catch_unwind(|| { --- rustworkx/__init__.py | 21 -------------------- src/graph6.rs | 46 +++++++++++++++---------------------------- 2 files changed, 16 insertions(+), 51 deletions(-) diff --git a/rustworkx/__init__.py b/rustworkx/__init__.py index 5fc240daa4..23abcf0b77 100644 --- a/rustworkx/__init__.py +++ b/rustworkx/__init__.py @@ -13,27 +13,6 @@ from .rustworkx import * -# ---- Inlined graph6/digraph6/sparse6 helpers (previously separate modules) ---- -# These were thin wrappers around the Rust extension functions. They are now -# exposed directly at the package root to simplify the public API surface. -# Backwards compatibility: importing the removed submodules will no longer work; -# users should call these root-level functions instead. (If needed we can add -# stub modules re-exporting these names.) - -from .rustworkx import ( - read_graph6_str, - write_graph6_from_pygraph, - write_graph6_from_pydigraph, - graph_write_graph6_file, - digraph_write_graph6_file, - read_graph6_file, - read_sparse6_str, - write_sparse6_from_pygraph, -) - -# Provide canonical short aliases matching the former module-level 'read'/'write' -read = read_graph6_str -write = write_graph6_from_pygraph # flake8: noqa import rustworkx.visit diff --git a/src/graph6.rs b/src/graph6.rs index 8b02958de0..a62e697b4e 100644 --- a/src/graph6.rs +++ b/src/graph6.rs @@ -507,40 +507,26 @@ pub(crate) fn to_file(path: impl AsRef, content: &str) -> std::io::Result< #[pyfunction] #[pyo3(signature=(repr))] pub fn read_graph6_str<'py>(py: Python<'py>, repr: &str) -> PyResult> { - // Wrap parser calls to catch Rust panics and convert IO errors to PyErr - // Helper enum used to return either a Graph or DiGraph from the parser. - enum ParserResult { - Graph(Graph6), - DiGraph(DiGraph6), + + enum ParserResult{ + Graph6(Graph6), + DiGraph6(DiGraph6), } - let wrapped = std::panic::catch_unwind(|| { - // try undirected first - if let Ok(g) = Graph6::from_g6(repr) { - return Ok::<_, IOError>(ParserResult::Graph(g)); - } - // try directed - if let Ok(dg) = DiGraph6::from_d6(repr) { - return Ok(ParserResult::DiGraph(dg)); - } + let result = if let Ok(g) = Graph6::from_g6(repr) { + Ok(ParserResult::Graph6(g)) + } else if let Ok(dg) = DiGraph6::from_d6(repr) { + Ok(ParserResult::DiGraph6(dg)) + } else { Err(IOError::NonCanonicalEncoding) - }); - - match wrapped { - Ok(Ok(ParserResult::Graph(g))) => graph_to_pygraph(py, &g), - Ok(Ok(ParserResult::DiGraph(dg))) => digraph_to_pydigraph(py, &dg), - Ok(Err(io_err)) => Err(PyErr::from(io_err)), - Err(panic_payload) => { - let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() { - format!("Rust panic in graph6 parser: {}", s) - } else if let Some(s) = panic_payload.downcast_ref::() { - format!("Rust panic in graph6 parser: {}", s) - } else { - "Rust panic in graph6 parser (non-string payload)".to_string() - }; - Err(Graph6PanicError::new_err(msg)) - } + }; + + match result { + Ok(ParserResult::Graph6(g)) => graph_to_pygraph(py, &g), + Ok(ParserResult::DiGraph6(dg)) => digraph_to_pydigraph(py, &dg), + Err(io_err) => Err(PyErr::from(io_err)), } + } #[pyfunction] From 84f202c19e757b009b170f69e2cdde82a41ba5ac Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Tue, 23 Sep 2025 16:17:02 +0800 Subject: [PATCH 18/23] fix the namings --- rustworkx/__init__.py | 4 ++++ src/digraph6.rs | 2 +- src/graph6.rs | 13 ++++--------- src/lib.rs | 21 ++++++++++++--------- tests/test_digraph6.py | 4 ++-- tests/test_graph6.py | 28 +++++++++++++--------------- 6 files changed, 36 insertions(+), 36 deletions(-) diff --git a/rustworkx/__init__.py b/rustworkx/__init__.py index 23abcf0b77..de40ce7b4c 100644 --- a/rustworkx/__init__.py +++ b/rustworkx/__init__.py @@ -2312,3 +2312,7 @@ def write_graphml(graph, path, /, keys=None, compression=None): :raises RuntimeError: when an error is encountered while writing the GraphML file. """ raise TypeError(f"Invalid Input Type {type(graph)} for graph") + +@_rustworkx_dispatch +def write_graph6_file(graph, path): + raise TypeError(f"Invalid Input Type {type(graph)} for graph") \ No newline at end of file diff --git a/src/digraph6.rs b/src/digraph6.rs index 491cbdf854..09a178ccc6 100644 --- a/src/digraph6.rs +++ b/src/digraph6.rs @@ -112,7 +112,7 @@ pub fn write_graph6_from_pydigraph(pydigraph: Py) -> #[pyfunction] #[pyo3(signature=(digraph, path))] -pub fn digraph_write_graph6_file(digraph: Py, path: &str) -> PyResult<()> { +pub fn digraph_write_graph6(digraph: Py, path: &str) -> PyResult<()> { let s = write_graph6_from_pydigraph(digraph)?; crate::graph6::to_file(path, &s) .map_err(|e| pyo3::exceptions::PyIOError::new_err(format!("IO error: {}", e)))?; diff --git a/src/graph6.rs b/src/graph6.rs index a62e697b4e..4d9ae7ae6f 100644 --- a/src/graph6.rs +++ b/src/graph6.rs @@ -359,7 +359,7 @@ pub mod write { use crate::get_edge_iter_with_weights; use crate::{graph::PyGraph, StablePyGraph}; -use crate::{Graph6OverflowError, Graph6PanicError, Graph6ParseError}; +use crate::{Graph6OverflowError, Graph6ParseError}; use flate2::write::GzEncoder; use flate2::Compression; use petgraph::graph::NodeIndex; @@ -565,7 +565,7 @@ pub fn parse_graph6_size(data: &str, offset: usize) -> PyResult<(usize, usize)> /// Read a graph6 file from disk and return a PyGraph or PyDiGraph #[pyfunction] #[pyo3(signature=(path))] -pub fn read_graph6_file<'py>(py: Python<'py>, path: &str) -> PyResult> { +pub fn read_graph6<'py>(py: Python<'py>, path: &str) -> PyResult> { use std::fs; let data = fs::read_to_string(path) .map_err(|e| PyErr::new::(format!("IO error: {}", e)))?; @@ -577,14 +577,9 @@ pub fn read_graph6_file<'py>(py: Python<'py>, path: &str) -> PyResult, path: &str) -> PyResult<()> { - let s = write_graph6_from_pygraph(graph)?; +pub fn graph_write_graph6(py: Python<'_>, graph: Py, path: &str) -> PyResult<()> { + let s = write_graph6_from_pygraph(py, graph)?; to_file(path, &s) .map_err(|e| PyErr::new::(format!("IO error: {}", e)))?; Ok(()) } - -/// Write a PyDiGraph to a graph6 file -/// -/// Implemented in crate::digraph6 module (helpers are provided there). -pub(crate) fn _digraph_write_graph6_file_doc_placeholder() {} diff --git a/src/lib.rs b/src/lib.rs index a4e527540d..3e92f45a27 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,6 +52,9 @@ use coloring::*; use connectivity::*; use dag_algo::*; use dominance::*; +use graph6::*; +use digraph6::*; +use sparse6::*; use graphml::*; use isomorphism::*; use json::*; @@ -712,15 +715,15 @@ fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(read_graphml))?; m.add_wrapped(wrap_pyfunction!(graph_write_graphml))?; m.add_wrapped(wrap_pyfunction!(digraph_write_graphml))?; - m.add_wrapped(wrap_pyfunction!(crate::graph6::read_graph6_str))?; - m.add_wrapped(wrap_pyfunction!(crate::graph6::write_graph6_from_pygraph))?; - m.add_wrapped(wrap_pyfunction!(crate::digraph6::write_graph6_from_pydigraph))?; - m.add_wrapped(wrap_pyfunction!(crate::graph6::read_graph6_file))?; - m.add_wrapped(wrap_pyfunction!(crate::graph6::graph_write_graph6_file))?; - m.add_wrapped(wrap_pyfunction!(crate::digraph6::digraph_write_graph6_file))?; - m.add_wrapped(wrap_pyfunction!(crate::graph6::parse_graph6_size))?; - m.add_wrapped(wrap_pyfunction!(crate::sparse6::read_sparse6_str))?; - m.add_wrapped(wrap_pyfunction!(crate::sparse6::write_sparse6_from_pygraph))?; + m.add_wrapped(wrap_pyfunction!(read_graph6_str))?; + m.add_wrapped(wrap_pyfunction!(write_graph6_from_pygraph))?; + m.add_wrapped(wrap_pyfunction!(write_graph6_from_pydigraph))?; + m.add_wrapped(wrap_pyfunction!(read_graph6))?; + m.add_wrapped(wrap_pyfunction!(graph_write_graph6))?; + m.add_wrapped(wrap_pyfunction!(digraph_write_graph6))?; + m.add_wrapped(wrap_pyfunction!(parse_graph6_size))?; + m.add_wrapped(wrap_pyfunction!(read_sparse6_str))?; + m.add_wrapped(wrap_pyfunction!(write_sparse6_from_pygraph))?; m.add_wrapped(wrap_pyfunction!(digraph_node_link_json))?; m.add_wrapped(wrap_pyfunction!(graph_node_link_json))?; m.add_wrapped(wrap_pyfunction!(from_node_link_json_file))?; diff --git a/tests/test_digraph6.py b/tests/test_digraph6.py index cbb2659a04..9c445e530e 100644 --- a/tests/test_digraph6.py +++ b/tests/test_digraph6.py @@ -29,8 +29,8 @@ def test_file_roundtrip_directed(self): g.add_edges_from([(0, 1, None), (1, 2, None)]) with tempfile.TemporaryDirectory() as td: p = pathlib.Path(td) / 'd.d6' - rx.digraph_write_graph6_file(g, str(p)) - g2 = rx.read_graph6_file(str(p)) + rx.digraph_write_graph6(g, str(p)) + g2 = rx.read_graph6(str(p)) self.assertIsInstance(g2, rx.PyDiGraph) self.assertEqual(g2.num_nodes(), 3) self.assertEqual(g2.num_edges(), 2) diff --git a/tests/test_graph6.py b/tests/test_graph6.py index 95dfe20d84..e322cb6a50 100644 --- a/tests/test_graph6.py +++ b/tests/test_graph6.py @@ -6,7 +6,6 @@ class TestGraph6(unittest.TestCase): - # Merged helper from test_graph6_file_write.py def _build_two_node_graph(self): g = rx.PyGraph() g.add_nodes_from(range(2)) @@ -23,9 +22,9 @@ def test_graph6_roundtrip(self): with tempfile.NamedTemporaryFile(delete=False) as fd: path = fd.name try: - rx.graph_write_graph6_file(g, path) + rx.graph_write_graph6(g, path) - g2 = rx.read_graph6_file(path) + g2 = rx.read_graph6(path) self.assertIsInstance(g2, rx.PyGraph) # check nodes and edges count @@ -93,33 +92,33 @@ def test_roundtrip_directed(self): self.assertEqual(graph.num_edges(), new_graph.num_edges()) self.assertEqual(graph.edge_list(), new_graph.edge_list()) - def test_read_graph6_file(self): + def test_read_graph6(self): """Test reading a graph from a graph6 file.""" with tempfile.NamedTemporaryFile(mode="w", delete=False) as fd: fd.write("C~\\n") path = fd.name try: - graph = rx.read_graph6_file(path) + graph = rx.read_graph6(path) self.assertIsInstance(graph, rx.PyGraph) self.assertEqual(graph.num_nodes(), 4) self.assertEqual(graph.num_edges(), 6) # K4 finally: os.remove(path) - def test_graph_write_graph6_file(self): + def test_graph_write_graph6(self): """Test writing a PyGraph to a graph6 file.""" graph = rx.generators.complete_graph(4) with tempfile.NamedTemporaryFile(delete=False) as fd: path = fd.name try: - rx.graph_write_graph6_file(graph, path) + rx.graph_write_graph6(graph, path) with open(path, "r") as f: content = f.read() self.assertEqual(content, "C~") finally: os.remove(path) - def test_digraph_write_graph6_file(self): + def test_digraph_write_graph6(self): """Test writing a PyDiGraph to a graph6 file.""" graph = rx.PyDiGraph() graph.add_nodes_from(range(3)) @@ -127,8 +126,8 @@ def test_digraph_write_graph6_file(self): with tempfile.NamedTemporaryFile(delete=False) as fd: path = fd.name try: - rx.digraph_write_graph6_file(graph, path) - new_graph = rx.read_graph6_file(path) + rx.digraph_write_graph6(graph, path) + new_graph = rx.read_graph6(path) self.assertTrue( rx.is_isomorphic(graph, new_graph) ) @@ -157,14 +156,13 @@ def test_graph_with_no_edges(self): self.assertEqual(new_graph.num_nodes(), 5) self.assertEqual(new_graph.num_edges(), 0) - # ---- Merged file write tests from test_graph6_file_write.py ---- def test_write_plain_file(self): g = self._build_two_node_graph() expected = "A_" # known graph6 for 2-node single edge with tempfile.NamedTemporaryFile(suffix=".g6", delete=False) as fd: path = fd.name try: - rx.graph_write_graph6_file(g, path) + rx.graph_write_graph6(g, path) with open(path, "rt", encoding="ascii") as fh: content = fh.read().strip() self.assertEqual(expected, content) @@ -178,7 +176,7 @@ def test_write_gzip_file(self): with tempfile.NamedTemporaryFile(suffix=".g6.gz", delete=False) as fd: path = fd.name try: - rx.graph_write_graph6_file(g, path) + rx.graph_write_graph6(g, path) with gzip.open(path, "rt", encoding="ascii") as fh: content = fh.read().strip() self.assertEqual(expected, content) @@ -216,8 +214,8 @@ def test_file_roundtrip_format(self): s = rx.write_graph6_from_pygraph(g) with tempfile.TemporaryDirectory() as td: p = pathlib.Path(td) / 'u.g6' - rx.graph_write_graph6_file(g, str(p)) - g2 = rx.read_graph6_file(str(p)) + rx.graph_write_graph6(g, str(p)) + g2 = rx.read_graph6(str(p)) self.assertIsInstance(g2, rx.PyGraph) self.assertEqual(g2.num_nodes(), 4) self.assertEqual(g2.num_edges(), 2) From cd3d88f8c4b84840aa3c25e63c6144e64d6730b6 Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Tue, 23 Sep 2025 16:31:09 +0800 Subject: [PATCH 19/23] use tempfile.NamedTemporaryFile() --- tests/test_graph6.py | 76 ++++++++++++-------------------------------- 1 file changed, 21 insertions(+), 55 deletions(-) diff --git a/tests/test_graph6.py b/tests/test_graph6.py index e322cb6a50..8a4a3a89c6 100644 --- a/tests/test_graph6.py +++ b/tests/test_graph6.py @@ -18,27 +18,16 @@ def test_graph6_roundtrip(self): g.add_node({"label": "n1"}) g.add_edge(0, 1, {"weight": 3}) - # use NamedTemporaryFile so this method matches the other tests' style - with tempfile.NamedTemporaryFile(delete=False) as fd: - path = fd.name - try: - rx.graph_write_graph6(g, path) - - g2 = rx.read_graph6(path) + # Use NamedTemporaryFile with context-managed cleanup + with tempfile.NamedTemporaryFile() as fd: + rx.graph_write_graph6(g, fd.name) + g2 = rx.read_graph6(fd.name) self.assertIsInstance(g2, rx.PyGraph) - - # check nodes and edges count self.assertEqual(g2.num_nodes(), 2) self.assertEqual(g2.num_edges(), 1) - - # graph6 doesn't guarantee attributes; allow None or preserved dict n0 = g2[0] self.assertTrue(n0 is None or ("label" in n0 and n0["label"] == "n0")) - - # check edge exists self.assertTrue(list(g2.edge_list())) - finally: - os.remove(path) def test_read_graph6_str_undirected(self): """Test reading an undirected graph from a graph6 string.""" @@ -94,45 +83,32 @@ def test_roundtrip_directed(self): def test_read_graph6(self): """Test reading a graph from a graph6 file.""" - with tempfile.NamedTemporaryFile(mode="w", delete=False) as fd: - fd.write("C~\\n") - path = fd.name - try: - graph = rx.read_graph6(path) + with tempfile.NamedTemporaryFile(mode="w+") as fd: + fd.write("C~\n") + fd.flush() + graph = rx.read_graph6(fd.name) self.assertIsInstance(graph, rx.PyGraph) self.assertEqual(graph.num_nodes(), 4) self.assertEqual(graph.num_edges(), 6) # K4 - finally: - os.remove(path) def test_graph_write_graph6(self): """Test writing a PyGraph to a graph6 file.""" graph = rx.generators.complete_graph(4) - with tempfile.NamedTemporaryFile(delete=False) as fd: - path = fd.name - try: - rx.graph_write_graph6(graph, path) - with open(path, "r") as f: + with tempfile.NamedTemporaryFile() as fd: + rx.graph_write_graph6(graph, fd.name) + with open(fd.name, "r") as f: content = f.read() self.assertEqual(content, "C~") - finally: - os.remove(path) def test_digraph_write_graph6(self): """Test writing a PyDiGraph to a graph6 file.""" graph = rx.PyDiGraph() graph.add_nodes_from(range(3)) graph.add_edges_from([(0, 1, None), (1, 2, None), (2, 0, None)]) - with tempfile.NamedTemporaryFile(delete=False) as fd: - path = fd.name - try: - rx.digraph_write_graph6(graph, path) - new_graph = rx.read_graph6(path) - self.assertTrue( - rx.is_isomorphic(graph, new_graph) - ) - finally: - os.remove(path) + with tempfile.NamedTemporaryFile() as fd: + rx.digraph_write_graph6(graph, fd.name) + new_graph = rx.read_graph6(fd.name) + self.assertTrue(rx.is_isomorphic(graph, new_graph)) def test_invalid_graph6_string(self): """Test that an invalid graph6 string raises an error.""" @@ -159,30 +135,20 @@ def test_graph_with_no_edges(self): def test_write_plain_file(self): g = self._build_two_node_graph() expected = "A_" # known graph6 for 2-node single edge - with tempfile.NamedTemporaryFile(suffix=".g6", delete=False) as fd: - path = fd.name - try: - rx.graph_write_graph6(g, path) - with open(path, "rt", encoding="ascii") as fh: + with tempfile.NamedTemporaryFile(suffix=".g6") as fd: + rx.graph_write_graph6(g, fd.name) + with open(fd.name, "rt", encoding="ascii") as fh: content = fh.read().strip() self.assertEqual(expected, content) - finally: - if os.path.exists(path): - os.remove(path) def test_write_gzip_file(self): g = self._build_two_node_graph() expected = "A_" - with tempfile.NamedTemporaryFile(suffix=".g6.gz", delete=False) as fd: - path = fd.name - try: - rx.graph_write_graph6(g, path) - with gzip.open(path, "rt", encoding="ascii") as fh: + with tempfile.NamedTemporaryFile(suffix=".g6.gz") as fd: + rx.graph_write_graph6(g, fd.name) + with gzip.open(fd.name, "rt", encoding="ascii") as fh: content = fh.read().strip() self.assertEqual(expected, content) - finally: - if os.path.exists(path): - os.remove(path) class TestGraph6FormatExtras(unittest.TestCase): From 6ecd347148147ee4fb73336579513576d0b9dbc0 Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Tue, 23 Sep 2025 17:16:11 +0800 Subject: [PATCH 20/23] Use generator to help tests --- tests/test_digraph6.py | 3 --- tests/test_graph6.py | 15 +++++++-------- tests/test_sparse6.py | 3 --- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/tests/test_digraph6.py b/tests/test_digraph6.py index 9c445e530e..1ec96690cd 100644 --- a/tests/test_digraph6.py +++ b/tests/test_digraph6.py @@ -41,6 +41,3 @@ def test_invalid_string(self): with self.assertRaises(BaseException): rx.read_graph6_str('&invalid') - -if __name__ == '__main__': # pragma: no cover - unittest.main() diff --git a/tests/test_graph6.py b/tests/test_graph6.py index 8a4a3a89c6..7902195cda 100644 --- a/tests/test_graph6.py +++ b/tests/test_graph6.py @@ -7,10 +7,8 @@ class TestGraph6(unittest.TestCase): def _build_two_node_graph(self): - g = rx.PyGraph() - g.add_nodes_from(range(2)) - g.add_edge(0, 1, None) - return g + # Use path_graph(2) which yields a single edge between two nodes. + return rx.generators.path_graph(2) def test_graph6_roundtrip(self): # build a small graph with node/edge attrs g = rx.PyGraph() @@ -49,16 +47,17 @@ def test_read_graph6_str_directed(self): def test_write_graph6_from_pygraph(self): """Test writing a PyGraph to a graph6 string.""" - graph = rx.PyGraph() - graph.add_nodes_from(range(2)) - graph.add_edge(0, 1, None) + graph = rx.generators.path_graph(2) g6_str = rx.write_graph6_from_pygraph(graph) self.assertEqual(g6_str, "A_") def test_write_graph6_from_pydigraph(self): """Test writing a PyDiGraph to a graph6 string.""" + # directed_path_graph(2) yields edge 0->1; we need 1->0 so build via generators then reverse + base = rx.generators.directed_path_graph(2) graph = rx.PyDiGraph() - graph.add_nodes_from(range(2)) + graph.add_nodes_from(range(base.num_nodes())) + # Add reversed edge to match expected encoding &AG (edge 1->0) graph.add_edge(1, 0, None) g6_str = rx.write_graph6_from_pydigraph(graph) self.assertEqual(g6_str, "&AG") diff --git a/tests/test_sparse6.py b/tests/test_sparse6.py index b16f43bc81..da6aa174b8 100644 --- a/tests/test_sparse6.py +++ b/tests/test_sparse6.py @@ -36,6 +36,3 @@ def test_roundtrip_small_graph(self): self.assertEqual(g2.num_nodes(), g.num_nodes()) self.assertEqual(g2.num_edges(), g.num_edges()) - -if __name__ == '__main__': # pragma: no cover - unittest.main() From 886b8934a905b3b7fdec7d8032c9b54739abd727 Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Wed, 24 Sep 2025 13:45:56 +0800 Subject: [PATCH 21/23] centralize byte string checking in graph6.rs/ SizeCodec --- src/graph6.rs | 8 ++- src/sparse6.rs | 129 +++++++++---------------------------------- tests/test_graph6.py | 4 ++ 3 files changed, 36 insertions(+), 105 deletions(-) diff --git a/src/graph6.rs b/src/graph6.rs index 4d9ae7ae6f..cf9a85880a 100644 --- a/src/graph6.rs +++ b/src/graph6.rs @@ -410,8 +410,11 @@ impl Graph6 { /// Builds the bitvector from the graph6 representation fn build_bitvector(bytes: &[u8], n: usize, n_len: usize) -> Result, IOError> { + // For n < 2 we still materialize an n*n bitvector (0-length for n=0, length 1 for n=1) + // to avoid downstream index calculations (i * n + j) from panicking in utility + // functions (DOT conversion, PyGraph conversion, etc.). if n < 2 { - return Ok(Vec::new()); + return Ok(vec![0; n * n]); } let bv_len = n * (n - 1) / 2; let Some(bit_vec) = utils::fill_bitvector(bytes, bv_len, n_len) else { @@ -460,6 +463,9 @@ use crate::digraph6::{DiGraph6, digraph_to_pydigraph}; /// Convert internal Graph (undirected) to PyGraph fn graph_to_pygraph<'py>(py: Python<'py>, g: &Graph6) -> PyResult> { let mut graph = StablePyGraph::::with_capacity(g.size(), 0); + if g.bit_vec.len() < g.size().saturating_mul(g.size()) { + return Err(Graph6ParseError::new_err("Bitvector shorter than n*n; invalid internal state")); + } // add nodes for _ in 0..g.size() { graph.add_node(py.None()); diff --git a/src/sparse6.rs b/src/sparse6.rs index 8ed3efd8e0..e32684cd58 100644 --- a/src/sparse6.rs +++ b/src/sparse6.rs @@ -1,118 +1,28 @@ use pyo3::prelude::*; use pyo3::types::PyAny; -use crate::graph6::IOError; +use crate::graph6::{IOError, GraphNumberCodec, SizeCodec}; use crate::graph::PyGraph; use petgraph::graph::NodeIndex; use petgraph::prelude::Undirected; use crate::StablePyGraph; use std::iter; +// Unified size parser using GraphNumberCodec (shared with graph6/digraph6). +// Returns (n, absolute next position) to preserve original caller expectations. fn parse_n(bytes: &[u8], pos: usize) -> Result<(usize, usize), IOError> { - if pos >= bytes.len() { - return Err(IOError::NonCanonicalEncoding); - } - let first = bytes[pos]; - if first < 63 || first > 126 { - return Err(IOError::InvalidSizeChar); - } - if first != 126 { - return Ok(((first - 63) as usize, pos + 1)); - } - // first == 126 -> extended form. Look ahead without advancing permanently. - if pos + 1 >= bytes.len() { - return Err(IOError::NonCanonicalEncoding); - } - let second = bytes[pos + 1]; - if second == 126 { - // 8 byte form: 126 126 R(x) where R(x) is 36 bits -> 6 bytes - if bytes.len() < pos + 2 + 6 { - return Err(IOError::NonCanonicalEncoding); - } - let mut val: u64 = 0; - for i in 0..6 { - let b = bytes[pos + 2 + i]; - if b < 63 || b > 126 { - return Err(IOError::InvalidSizeChar); - } - val = (val << 6) | ((b - 63) as u64); - } - return Ok((val as usize, pos + 2 + 6)); - } else { - // 4 byte form: 126 R(x) where R(x) is 18 bits -> 3 bytes - if bytes.len() < pos + 1 + 3 { - return Err(IOError::NonCanonicalEncoding); - } - let mut val: usize = 0; - for i in 0..3 { - let b = bytes[pos + 1 + i]; - if b < 63 || b > 126 { - return Err(IOError::InvalidSizeChar); - } - val = (val << 6) | ((b - 63) as usize); - } - if val == 64032 { - eprintln!("DEBUG sparse6 parse_n anomaly: pos={} bytes_prefix={:?} triple={:?}", pos, &bytes[..std::cmp::min(bytes.len(),10)], [&bytes[pos+1], &bytes[pos+2], &bytes[pos+3]]); - } - return Ok((val, pos + 1 + 3)); - } -} - -fn bits_from_bytes(bytes: &[u8], start: usize) -> Result, IOError> { - let mut bits = Vec::new(); - for &b in bytes.iter().skip(start) { - if b < 63 || b > 126 { - return Err(IOError::InvalidSizeChar); - } - let val = b - 63; - for i in 0..6 { - let bit = (val >> (5 - i)) & 1; - bits.push(bit); - } - } - Ok(bits) + let (n, consumed) = GraphNumberCodec::decode_size(bytes, pos)?; + Ok((n, pos + consumed)) } // Encoder: produce sparse6 byte chars (63-based) from a graph's bit_vec fn to_sparse6_bytes(bit_vec: &[usize], n: usize, header: bool) -> Result, IOError> { - if n >= (1usize << 36) { - return Err(IOError::GraphTooLarge); - } + // Unified bound check occurs inside GraphNumberCodec::encode_size too, but keep for clarity. + if n >= (1usize << 36) { return Err(IOError::GraphTooLarge); } let mut out: Vec = Vec::new(); - if header { - out.extend_from_slice(b">>sparse6<<"); - } + if header { out.extend_from_slice(b">>sparse6<<"); } out.push(b':'); - - // write N(n) using same encoding as graph6 utils.get_size but extended - if n < 63 { - out.push((n as u8) + 63); - } else if n < (1 << 18) { - // 4-byte form: 126 then three 6-bit chars - out.push(126); - let mut val = n as usize; - let mut parts = [0u8; 3]; - parts[2] = (val & 0x3F) as u8; - val >>= 6; - parts[1] = (val & 0x3F) as u8; - val >>= 6; - parts[0] = (val & 0x3F) as u8; - for p in parts.iter() { - out.push(p + 63); - } - } else { - // 8-byte form: 126,126 then 6-byte 36-bit value - out.push(126); - out.push(126); - let mut val = n as u64; - let mut parts = [0u8; 6]; - for i in (0..6).rev() { - parts[i] = (val as u8) & 0x3F; - val >>= 6; - } - for p in parts.iter() { - out.push(p + 63); - } - } + let size_enc = GraphNumberCodec::encode_size(n)?; + out.extend_from_slice(&size_enc); // compute k let mut k = 1usize; @@ -215,16 +125,27 @@ pub fn read_sparse6_str<'py>(py: Python<'py>, repr: &str) -> PyResult= s.len() { return Ok::<(Vec<(usize, usize)>, usize), IOError>((Vec::new(), n)); } - let bits = bits_from_bytes(s, pos)?; + // let bits = bits_from_bytes(s, pos)?; + let mut bits = Vec::new(); + for &b in s.iter().skip(pos) { + if b < 63 || b > 126 { + return Err(IOError::InvalidSizeChar); + } + let val = b - 63; + for i in 0..6 { + let bit = (val >> (5 - i)) & 1; + bits.push(bit); + } + } let mut idx = 0usize; let mut v: usize = 0; let mut edges: Vec<(usize, usize)> = Vec::new(); diff --git a/tests/test_graph6.py b/tests/test_graph6.py index 7902195cda..14130ad201 100644 --- a/tests/test_graph6.py +++ b/tests/test_graph6.py @@ -247,9 +247,13 @@ def test_non_canonical_medium_for_short(self): with self.assertRaises(rx.Graph6ParseError): rx.parse_graph6_size(bad_hdr) + # Construct long-form size header for n = 2^36 (one above the allowed max 2^36 - 1). + # Spec requires n < 2^36, so this header must raise an overflow/parse error. def test_overflow(self): overflow_val = 1 << 36 parts = [0] * 6 + # Extract 6-bit chunks of val from least-significant to most (val & 0x3F), shifting right each loop. + # Fill parts right-to-left so the resulting list is big-endian (highest chunk ends up at parts[0]). val = overflow_val for i in range(5, -1, -1): parts[i] = val & 0x3F From aca2137f723b128ca2198b89998c62eac0c9afd1 Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Wed, 24 Sep 2025 14:33:43 +0800 Subject: [PATCH 22/23] i/o naming align to @_rustworkx_dispatch --- rustworkx/__init__.py | 2 +- src/digraph6.rs | 28 +++++++++++++--------------- src/graph6.rs | 18 +++++++++--------- src/lib.rs | 6 +++--- src/sparse6.rs | 28 +++++++++++++--------------- tests/test_digraph6.py | 4 ++-- tests/test_graph6.py | 24 ++++++++++++------------ tests/test_sparse6.py | 2 +- 8 files changed, 54 insertions(+), 58 deletions(-) diff --git a/rustworkx/__init__.py b/rustworkx/__init__.py index de40ce7b4c..3e1a25ff16 100644 --- a/rustworkx/__init__.py +++ b/rustworkx/__init__.py @@ -2314,5 +2314,5 @@ def write_graphml(graph, path, /, keys=None, compression=None): raise TypeError(f"Invalid Input Type {type(graph)} for graph") @_rustworkx_dispatch -def write_graph6_file(graph, path): +def write_graph6(graph, path): raise TypeError(f"Invalid Input Type {type(graph)} for graph") \ No newline at end of file diff --git a/src/digraph6.rs b/src/digraph6.rs index 09a178ccc6..39f22183d9 100644 --- a/src/digraph6.rs +++ b/src/digraph6.rs @@ -69,7 +69,7 @@ impl GraphConversion for DiGraph6 { } /// Convert internal DiGraph to PyDiGraph -pub fn digraph_to_pydigraph<'py>(py: Python<'py>, g: &DiGraph6) -> PyResult> { +pub fn digraph6_to_pydigraph<'py>(py: Python<'py>, g: &DiGraph6) -> PyResult> { use crate::graph6::GraphConversion as _; let mut graph = StablePyGraph::::with_capacity(g.size(), 0); for _ in 0..g.size() { @@ -97,26 +97,24 @@ pub fn digraph_to_pydigraph<'py>(py: Python<'py>, g: &DiGraph6) -> PyResult) -> PyResult { - Python::with_gil(|py| { - let g = pydigraph.borrow(py); - let n = g.graph.node_count(); - let mut bit_vec = vec![0usize; n * n]; - for (i, j, _w) in get_edge_iter_with_weights(&g.graph) { - bit_vec[i * n + j] = 1; - } - let graph6 = crate::graph6::write::write_graph6(bit_vec, n, true)?; - Ok(graph6) - }) +pub fn digraph_write_graph6_to_str<'py>(py: Python<'py>, pydigraph: Py) -> PyResult { + let g = pydigraph.borrow(py); + let n = g.graph.node_count(); + let mut bit_vec = vec![0usize; n * n]; + for (i, j, _w) in get_edge_iter_with_weights(&g.graph) { + bit_vec[i * n + j] = 1; + } + let graph6 = crate::graph6::write::to_file(bit_vec, n, true)?; + Ok(graph6) } #[pyfunction] #[pyo3(signature=(digraph, path))] -pub fn digraph_write_graph6(digraph: Py, path: &str) -> PyResult<()> { - let s = write_graph6_from_pydigraph(digraph)?; +pub fn digraph_write_graph6(py: Python<'_>, digraph: Py, path: &str) -> PyResult<()> { + let s = digraph_write_graph6_to_str(py, digraph)?; crate::graph6::to_file(path, &s) .map_err(|e| pyo3::exceptions::PyIOError::new_err(format!("IO error: {}", e)))?; Ok(()) } -impl crate::graph6::write::WriteGraph for DiGraph6 {} +impl crate::graph6::write::WriteGraph for DiGraph6 {} \ No newline at end of file diff --git a/src/graph6.rs b/src/graph6.rs index cf9a85880a..0bbb342808 100644 --- a/src/graph6.rs +++ b/src/graph6.rs @@ -295,7 +295,7 @@ pub mod write { /// Trait to write graphs into graph 6 formatted strings pub trait WriteGraph: GraphConversion { fn write_graph(&self) -> Result { - write_graph6(self.bit_vec().to_vec(), self.size(), self.is_directed()) + to_file(self.bit_vec().to_vec(), self.size(), self.is_directed()) } } @@ -330,7 +330,7 @@ pub mod write { Ok(()) } - pub fn write_graph6(bit_vec: Vec, n: usize, is_directed: bool) -> Result { + pub fn to_file(bit_vec: Vec, n: usize, is_directed: bool) -> Result { // enforce graph6 maximum (2^36 - 1) like sparse6 if n >= (1usize << 36) { return Err(IOError::GraphTooLarge); @@ -456,12 +456,12 @@ impl GraphConversion for Graph6 { } impl write::WriteGraph for Graph6 {} -use crate::digraph6::{DiGraph6, digraph_to_pydigraph}; +use crate::digraph6::{DiGraph6, digraph6_to_pydigraph}; // End of combined module /// Convert internal Graph (undirected) to PyGraph -fn graph_to_pygraph<'py>(py: Python<'py>, g: &Graph6) -> PyResult> { +fn graph6_to_pygraph<'py>(py: Python<'py>, g: &Graph6) -> PyResult> { let mut graph = StablePyGraph::::with_capacity(g.size(), 0); if g.bit_vec.len() < g.size().saturating_mul(g.size()) { return Err(Graph6ParseError::new_err("Bitvector shorter than n*n; invalid internal state")); @@ -528,8 +528,8 @@ pub fn read_graph6_str<'py>(py: Python<'py>, repr: &str) -> PyResult graph_to_pygraph(py, &g), - Ok(ParserResult::DiGraph6(dg)) => digraph_to_pydigraph(py, &dg), + Ok(ParserResult::Graph6(g)) => graph6_to_pygraph(py, &g), + Ok(ParserResult::DiGraph6(dg)) => digraph6_to_pydigraph(py, &dg), Err(io_err) => Err(PyErr::from(io_err)), } @@ -537,7 +537,7 @@ pub fn read_graph6_str<'py>(py: Python<'py>, repr: &str) -> PyResult(py: Python<'py>, pygraph: Py) -> PyResult { +pub fn graph_write_graph6_to_str<'py>(py: Python<'py>, pygraph: Py) -> PyResult { let g = pygraph.borrow(py); let n = g.graph.node_count(); if n >= (1usize << 36) { @@ -549,7 +549,7 @@ pub fn write_graph6_from_pygraph<'py>(py: Python<'py>, pygraph: Py) -> bit_vec[i * n + j] = 1; bit_vec[j * n + i] = 1; } - let graph6 = write::write_graph6(bit_vec, n, false)?; + let graph6 = write::to_file(bit_vec, n, false)?; Ok(graph6) } @@ -584,7 +584,7 @@ pub fn read_graph6<'py>(py: Python<'py>, path: &str) -> PyResult, graph: Py, path: &str) -> PyResult<()> { - let s = write_graph6_from_pygraph(py, graph)?; + let s = graph_write_graph6_to_str(py, graph)?; to_file(path, &s) .map_err(|e| PyErr::new::(format!("IO error: {}", e)))?; Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 3e92f45a27..f7a483eeaf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -716,14 +716,14 @@ fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(graph_write_graphml))?; m.add_wrapped(wrap_pyfunction!(digraph_write_graphml))?; m.add_wrapped(wrap_pyfunction!(read_graph6_str))?; - m.add_wrapped(wrap_pyfunction!(write_graph6_from_pygraph))?; - m.add_wrapped(wrap_pyfunction!(write_graph6_from_pydigraph))?; + m.add_wrapped(wrap_pyfunction!(graph_write_graph6_to_str))?; + m.add_wrapped(wrap_pyfunction!(digraph_write_graph6_to_str))?; m.add_wrapped(wrap_pyfunction!(read_graph6))?; m.add_wrapped(wrap_pyfunction!(graph_write_graph6))?; m.add_wrapped(wrap_pyfunction!(digraph_write_graph6))?; m.add_wrapped(wrap_pyfunction!(parse_graph6_size))?; m.add_wrapped(wrap_pyfunction!(read_sparse6_str))?; - m.add_wrapped(wrap_pyfunction!(write_sparse6_from_pygraph))?; + m.add_wrapped(wrap_pyfunction!(graph_write_sparse6_to_str))?; m.add_wrapped(wrap_pyfunction!(digraph_node_link_json))?; m.add_wrapped(wrap_pyfunction!(graph_node_link_json))?; m.add_wrapped(wrap_pyfunction!(from_node_link_json_file))?; diff --git a/src/sparse6.rs b/src/sparse6.rs index e32684cd58..5e10e78d5c 100644 --- a/src/sparse6.rs +++ b/src/sparse6.rs @@ -93,21 +93,19 @@ fn to_sparse6_bytes(bit_vec: &[usize], n: usize, header: bool) -> Result #[pyfunction] #[pyo3(signature=(pygraph, header=true))] -pub fn write_sparse6_from_pygraph(pygraph: Py, header: bool) -> PyResult { - Python::with_gil(|py| { - let g = pygraph.borrow(py); - let n = g.graph.node_count(); - let mut bit_vec = vec![0usize; n * n]; - for (i, j, _w) in crate::get_edge_iter_with_weights(&g.graph) { - bit_vec[i * n + j] = 1; - bit_vec[j * n + i] = 1; - } - let bytes = to_sparse6_bytes(&bit_vec, n, header) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("sparse6 encode error: {:?}", e)))?; - // convert bytes to string - let s = String::from_utf8(bytes).map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("utf8: {}", e)))?; - Ok(s) - }) +pub fn graph_write_sparse6_to_str<'py>(py: Python<'py>, pygraph: Py, header: bool) -> PyResult { + let g = pygraph.borrow(py); + let n = g.graph.node_count(); + let mut bit_vec = vec![0usize; n * n]; + for (i, j, _w) in crate::get_edge_iter_with_weights(&g.graph) { + bit_vec[i * n + j] = 1; + bit_vec[j * n + i] = 1; + } + let bytes = to_sparse6_bytes(&bit_vec, n, header) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("sparse6 encode error: {:?}", e)))?; + // convert bytes to string + let s = String::from_utf8(bytes).map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("utf8: {}", e)))?; + Ok(s) } #[pyfunction] diff --git a/tests/test_digraph6.py b/tests/test_digraph6.py index 1ec96690cd..f443dab4cb 100644 --- a/tests/test_digraph6.py +++ b/tests/test_digraph6.py @@ -7,7 +7,7 @@ def test_roundtrip_small_directed(self): g = rx.PyDiGraph() g.add_nodes_from([None, None]) g.add_edge(0, 1, None) - s = rx.write_graph6_from_pydigraph(g) + s = rx.digraph_write_graph6_to_str(g) new_g = rx.read_graph6_str(s) self.assertIsInstance(new_g, rx.PyDiGraph) self.assertEqual(new_g.num_nodes(), 2) @@ -17,7 +17,7 @@ def test_asymmetric_two_edge(self): g = rx.PyDiGraph() g.add_nodes_from([None, None]) g.add_edges_from([(0, 1, None), (1, 0, None)]) - s = rx.write_graph6_from_pydigraph(g) + s = rx.digraph_write_graph6_to_str(g) new_g = rx.read_graph6_str(s) self.assertIsInstance(new_g, rx.PyDiGraph) self.assertEqual(new_g.num_edges(), 2) diff --git a/tests/test_graph6.py b/tests/test_graph6.py index 14130ad201..f444661a0a 100644 --- a/tests/test_graph6.py +++ b/tests/test_graph6.py @@ -45,13 +45,13 @@ def test_read_graph6_str_directed(self): self.assertEqual(graph.num_edges(), 1) self.assertTrue(graph.has_edge(1, 0)) - def test_write_graph6_from_pygraph(self): + def test_graph_write_graph6_to_str(self): """Test writing a PyGraph to a graph6 string.""" graph = rx.generators.path_graph(2) - g6_str = rx.write_graph6_from_pygraph(graph) + g6_str = rx.graph_write_graph6_to_str(graph) self.assertEqual(g6_str, "A_") - def test_write_graph6_from_pydigraph(self): + def test_digraph_write_graph6_to_str(self): """Test writing a PyDiGraph to a graph6 string.""" # directed_path_graph(2) yields edge 0->1; we need 1->0 so build via generators then reverse base = rx.generators.directed_path_graph(2) @@ -59,13 +59,13 @@ def test_write_graph6_from_pydigraph(self): graph.add_nodes_from(range(base.num_nodes())) # Add reversed edge to match expected encoding &AG (edge 1->0) graph.add_edge(1, 0, None) - g6_str = rx.write_graph6_from_pydigraph(graph) + g6_str = rx.digraph_write_graph6_to_str(graph) self.assertEqual(g6_str, "&AG") def test_roundtrip_undirected(self): """Test roundtrip for an undirected graph.""" graph = rx.generators.path_graph(4) - g6_str = rx.write_graph6_from_pygraph(graph) + g6_str = rx.graph_write_graph6_to_str(graph) new_graph = rx.read_graph6_str(g6_str) self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) self.assertEqual(graph.num_edges(), new_graph.num_edges()) @@ -74,7 +74,7 @@ def test_roundtrip_undirected(self): def test_roundtrip_directed(self): """Test roundtrip for a directed graph.""" graph = rx.generators.directed_path_graph(4) - g6_str = rx.write_graph6_from_pydigraph(graph) + g6_str = rx.digraph_write_graph6_to_str(graph) new_graph = rx.read_graph6_str(g6_str) self.assertEqual(graph.num_nodes(), new_graph.num_nodes()) self.assertEqual(graph.num_edges(), new_graph.num_edges()) @@ -117,7 +117,7 @@ def test_invalid_graph6_string(self): def test_empty_graph(self): """Test writing and reading an empty graph.""" graph = rx.PyGraph() - g6_str = rx.write_graph6_from_pygraph(graph) + g6_str = rx.graph_write_graph6_to_str(graph) new_graph = rx.read_graph6_str(g6_str) self.assertEqual(new_graph.num_nodes(), 0) self.assertEqual(new_graph.num_edges(), 0) @@ -126,7 +126,7 @@ def test_graph_with_no_edges(self): """Test a graph with nodes but no edges.""" graph = rx.PyGraph() graph.add_nodes_from(range(5)) - g6_str = rx.write_graph6_from_pygraph(graph) + g6_str = rx.graph_write_graph6_to_str(graph) new_graph = rx.read_graph6_str(g6_str) self.assertEqual(new_graph.num_nodes(), 5) self.assertEqual(new_graph.num_edges(), 0) @@ -155,7 +155,7 @@ def test_roundtrip_small_undirected(self): g = rx.PyGraph() g.add_nodes_from([None, None]) g.add_edge(0, 1, None) - s = rx.write_graph6_from_pygraph(g) + s = rx.graph_write_graph6_to_str(g) new_g = rx.read_graph6_str(s) self.assertIsInstance(new_g, rx.PyGraph) self.assertEqual(new_g.num_nodes(), 2) @@ -165,7 +165,7 @@ def test_write_and_read_triangle(self): g = rx.PyGraph() g.add_nodes_from([None, None, None]) g.add_edges_from([(0, 1, None), (1, 2, None), (0, 2, None)]) - s = rx.write_graph6_from_pygraph(g) + s = rx.graph_write_graph6_to_str(g) new_g = rx.read_graph6_str(s) self.assertIsInstance(new_g, rx.PyGraph) self.assertEqual(new_g.num_nodes(), 3) @@ -176,7 +176,7 @@ def test_file_roundtrip_format(self): g = rx.PyGraph() g.add_nodes_from([None, None, None, None]) g.add_edges_from([(0, 1, None), (2, 3, None)]) - s = rx.write_graph6_from_pygraph(g) + s = rx.graph_write_graph6_to_str(g) with tempfile.TemporaryDirectory() as td: p = pathlib.Path(td) / 'u.g6' rx.graph_write_graph6(g, str(p)) @@ -184,7 +184,7 @@ def test_file_roundtrip_format(self): self.assertIsInstance(g2, rx.PyGraph) self.assertEqual(g2.num_nodes(), 4) self.assertEqual(g2.num_edges(), 2) - self.assertEqual(rx.write_graph6_from_pygraph(g2), s) + self.assertEqual(rx.graph_write_graph6_to_str(g2), s) def test_invalid_string_format(self): with self.assertRaises(Exception): diff --git a/tests/test_sparse6.py b/tests/test_sparse6.py index da6aa174b8..d356be3161 100644 --- a/tests/test_sparse6.py +++ b/tests/test_sparse6.py @@ -31,7 +31,7 @@ def test_roundtrip_small_graph(self): g.add_node(None) g.add_edge(0,1,None) g.add_edge(2,3,None) - s = rx.write_sparse6_from_pygraph(g, header=False) + s = rx.graph_write_sparse6_to_str(g, header=False) g2 = rx.read_sparse6_str(s) self.assertEqual(g2.num_nodes(), g.num_nodes()) self.assertEqual(g2.num_edges(), g.num_edges()) From d8b13d137a3871b4c3038be767b4127d75100ea9 Mon Sep 17 00:00:00 2001 From: HsunWenFang Date: Wed, 24 Sep 2025 17:10:36 +0800 Subject: [PATCH 23/23] cargo fmt, nox test, nox -e docs --- releasenotes/notes/add-graph6-support.yaml | 48 ---------- ...-style-graph-digraph-1eb41a0729b8cddd.yaml | 59 ++++++++++++ src/digraph6.rs | 30 ++++-- src/graph6.rs | 92 ++++++++++++++----- src/lib.rs | 8 +- src/sparse6.rs | 92 ++++++++++++++----- 6 files changed, 220 insertions(+), 109 deletions(-) delete mode 100644 releasenotes/notes/add-graph6-support.yaml create mode 100644 releasenotes/notes/graph6-style-graph-digraph-1eb41a0729b8cddd.yaml diff --git a/releasenotes/notes/add-graph6-support.yaml b/releasenotes/notes/add-graph6-support.yaml deleted file mode 100644 index c91b97dbc4..0000000000 --- a/releasenotes/notes/add-graph6-support.yaml +++ /dev/null @@ -1,48 +0,0 @@ -features: - - | - This note documents the graph6 family of ASCII formats and the helpers - added to the codebase. The summary below is a concise description of the - formats (based on the canonical formats document) and a few developer - notes to help maintainers and tests. - - references: - - https://users.cecs.anu.edu.au/~bdm/data/formats.txt - issue: - - https://github.com/Qiskit/rustworkx/issues/1496 - - graph6 - - A compact ASCII-based encoding for simple undirected graphs. - - Encodes the graph order (n) using a variable-length size field and packs - the upper-triangular adjacency bits into 6-bit chunks. Each 6-bit value - is mapped into a printable ASCII character by adding an offset (so the - encoded bytes are printable ASCII). - - Typical uses: small-to-moderately-dense graphs where the adjacency - matrix can be packed efficiently. - - digraph6 - - A variant of the graph6 scheme adapted for directed graphs. It encodes - a representation of the full adjacency matrix (row-major) using the - same 6-bit packing and ASCII mapping as graph6. - - Useful for representing directed graphs where directionality matters. - - sparse6 - - An alternate encoding designed for very sparse graphs. Instead of - packing the full adjacency matrix, sparse6 records adjacency in a more - compact variable-length integer form (adjacency lists / runs), which - yields much smaller files for low edge-density graphs. - - Developer notes - - Parsers must correctly handle variable-length size fields, 6-bit packing - and padding to 6-bit boundaries. Edge cases around very small and very - large n should be handled robustly. - - Tests should prefer roundtrip and structural checks (parse -> graph -> - serialize -> parse) and verify canonical encodings where the format - features: - - title: Add graph6/digraph6/sparse6 support and format summary - release: unreleased - notes: | - This note documents the graph6 family of ASCII formats and the helpers - added to the codebase. The summary below is a concise description of the - formats (based on the canonical formats document) and a few developer - notes to help maintainers and tests. - diff --git a/releasenotes/notes/graph6-style-graph-digraph-1eb41a0729b8cddd.yaml b/releasenotes/notes/graph6-style-graph-digraph-1eb41a0729b8cddd.yaml new file mode 100644 index 0000000000..8930bdccd3 --- /dev/null +++ b/releasenotes/notes/graph6-style-graph-digraph-1eb41a0729b8cddd.yaml @@ -0,0 +1,59 @@ +--- +prelude: > + Definition : https://users.cecs.anu.edu.au/~bdm/data/formats.txt + Added native read/write support for the ASCII graph encoding formats + graph6, digraph6, and sparse6. These enable compact text (and optionally + gzip-compressed) serialization of simple graphs and directed graphs with + canonical size-field validation and clearer error reporting. + +features: + - | + Introduced built‑in helpers for three related ASCII graph formats: + + graph6 + - Undirected simple graph encoding using a variable-length size field and + 6-bit packing of the upper triangular adjacency matrix. + + digraph6 + - Directed extension of graph6 (string begins with '&'), encoding the full + n × n adjacency (row‑major) with the same 6‑bit packing. + + sparse6 + - Space‑efficient encoding for very sparse undirected graphs using runs of + variable-length integers instead of a dense upper triangle. + + Unified size codec + - A shared GraphNumberCodec enforces canonical size (N(n)) encoding across + graph6, digraph6, and sparse6, preventing divergence in edge cases. + Exposed indirectly via parse_graph6_size() for testing and tooling. + + Error handling + - Added/used Python exceptions: Graph6ParseError, Graph6OverflowError, + Graph6PanicError (panic guard for sparse6 parser). + - Non‑canonical encodings, invalid characters, and oversize graphs fail + fast with deterministic error types instead of panics. + + Gzip support + - graph_write_graph6 / digraph_write_graph6 transparently gzip output + when the destination filename ends with ".gz". + + Testing & validation + - Round‑trip tests for undirected, directed, and sparse variants. + - Boundary tests for size field forms (short, medium, long) and overflow. + - Sparse6 round‑trip on small disconnected components. + + Developer notes + - Only simple (0/1) adjacency is serialized; parallel edges and weights + are collapsed. + - parse_graph6_size() enforces minimal encoding; use offset=1 for + directed strings that start with '&'. + +issues: + - | + Original feature request / discussion: https://github.com/Qiskit/rustworkx/issues/1496 + Implemented in PR: https://github.com/Qiskit/rustworkx/pull/1500 +upgrade: + - | + Parsing now rejects non‑canonical size encodings and any n >= 2^36 with + explicit typed errors. If prior ad‑hoc tooling accepted such inputs, they + may now raise Graph6ParseError or Graph6OverflowError. \ No newline at end of file diff --git a/src/digraph6.rs b/src/digraph6.rs index 39f22183d9..9dc4a9ca88 100644 --- a/src/digraph6.rs +++ b/src/digraph6.rs @@ -1,9 +1,9 @@ +use crate::graph6::{utils, GraphConversion, IOError}; use crate::{get_edge_iter_with_weights, StablePyGraph}; -use crate::graph6::{utils, IOError, GraphConversion}; +use petgraph::algo; +use petgraph::graph::NodeIndex; use pyo3::prelude::*; use pyo3::types::PyAny; -use petgraph::graph::NodeIndex; -use petgraph::algo; /// Directed graph implementation (extracted from graph6.rs) #[derive(Debug)] @@ -16,8 +16,8 @@ impl DiGraph6 { pub fn from_d6(repr: &str) -> Result { let bytes = repr.as_bytes(); Self::valid_digraph(bytes)?; - let (n, n_len) = utils::parse_size(bytes, 1)?; - let Some(bit_vec) = Self::build_bitvector(bytes, n, 1 + n_len) else { + let (n, n_len) = utils::parse_size(bytes, 1)?; + let Some(bit_vec) = Self::build_bitvector(bytes, n, 1 + n_len) else { return Err(IOError::NonCanonicalEncoding); }; Ok(Self { bit_vec, n }) @@ -97,7 +97,14 @@ pub fn digraph6_to_pydigraph<'py>(py: Python<'py>, g: &DiGraph6) -> PyResult(py: Python<'py>, pydigraph: Py) -> PyResult { +/// Encode a directed `PyDiGraph` into the graph6 digraph extension form. +/// The returned string starts with '&' followed by the size field and data. +/// Multi edges are collapsed; only 0/1 adjacency is represented. Fails if +/// n >= 2^36 or encoding would overflow. +pub fn digraph_write_graph6_to_str<'py>( + py: Python<'py>, + pydigraph: Py, +) -> PyResult { let g = pydigraph.borrow(py); let n = g.graph.node_count(); let mut bit_vec = vec![0usize; n * n]; @@ -110,11 +117,18 @@ pub fn digraph_write_graph6_to_str<'py>(py: Python<'py>, pydigraph: Py, digraph: Py, path: &str) -> PyResult<()> { +/// Write a `PyDiGraph` to a file in digraph6 (graph6 with '&' prefix) format. +/// Supports gzip when the filename ends with `.gz`. Overwrites existing file. +/// Returns IOError for filesystem failures. +pub fn digraph_write_graph6( + py: Python<'_>, + digraph: Py, + path: &str, +) -> PyResult<()> { let s = digraph_write_graph6_to_str(py, digraph)?; crate::graph6::to_file(path, &s) .map_err(|e| pyo3::exceptions::PyIOError::new_err(format!("IO error: {}", e)))?; Ok(()) } -impl crate::graph6::write::WriteGraph for DiGraph6 {} \ No newline at end of file +impl crate::graph6::write::WriteGraph for DiGraph6 {} diff --git a/src/graph6.rs b/src/graph6.rs index 0bbb342808..67c1f0b347 100644 --- a/src/graph6.rs +++ b/src/graph6.rs @@ -146,7 +146,11 @@ pub trait SizeCodec { fn decode_size(bytes: &[u8], pos: usize) -> Result<(usize, usize), IOError>; /// Compute number of bits needed to represent integers in [0, n-1]. (R(x) in spec) fn needed_bits(n: usize) -> usize { - if n <= 1 { 0 } else { (usize::BITS - (n - 1).leading_zeros()) as usize } + if n <= 1 { + 0 + } else { + (usize::BITS - (n - 1).leading_zeros()) as usize + } } } @@ -156,7 +160,9 @@ pub struct GraphNumberCodec; impl GraphNumberCodec { #[inline] fn validate(n: usize) -> Result<(), IOError> { - if n >= (1usize << 36) { return Err(IOError::GraphTooLarge); } + if n >= (1usize << 36) { + return Err(IOError::GraphTooLarge); + } Ok(()) } } @@ -171,13 +177,20 @@ impl SizeCodec for GraphNumberCodec { out.push(b'~'); let mut v = n as u32; let mut parts = [0u8; 3]; - for i in (0..3).rev() { parts[i] = (v & 0x3F) as u8; v >>= 6; } + for i in (0..3).rev() { + parts[i] = (v & 0x3F) as u8; + v >>= 6; + } out.extend(parts.iter().map(|p| p + 63)); } else { - out.push(b'~'); out.push(b'~'); + out.push(b'~'); + out.push(b'~'); let mut v = n as u64; let mut parts = [0u8; 6]; - for i in (0..6).rev() { parts[i] = (v & 0x3F) as u8; v >>= 6; } + for i in (0..6).rev() { + parts[i] = (v & 0x3F) as u8; + v >>= 6; + } out.extend(parts.iter().map(|p| p + 63)); } Ok(out) @@ -192,27 +205,41 @@ impl SizeCodec for GraphNumberCodec { let mut val: u64 = 0; for i in 0..6 { let c = *bytes.get(pos + 2 + i).ok_or(IOError::InvalidSizeChar)?; - if c < 63 { return Err(IOError::InvalidSizeChar); } + if c < 63 { + return Err(IOError::InvalidSizeChar); + } val = (val << 6) | ((c - 63) as u64); } - if val >= (1u64 << 36) { return Err(IOError::GraphTooLarge); } - if val < (1 << 18) { return Err(IOError::NonCanonicalEncoding); } + if val >= (1u64 << 36) { + return Err(IOError::GraphTooLarge); + } + if val < (1 << 18) { + return Err(IOError::NonCanonicalEncoding); + } Ok((val as usize, 8)) } else { // medium form: '~' + 3 chars let mut val: u32 = 0; for i in 0..3 { let c = *bytes.get(pos + 1 + i).ok_or(IOError::InvalidSizeChar)?; - if c < 63 { return Err(IOError::InvalidSizeChar); } + if c < 63 { + return Err(IOError::InvalidSizeChar); + } val = (val << 6) | ((c - 63) as u32); } - if val < 63 { return Err(IOError::NonCanonicalEncoding); } + if val < 63 { + return Err(IOError::NonCanonicalEncoding); + } Ok((val as usize, 4)) } } else { - if first < 63 { return Err(IOError::InvalidSizeChar); } + if first < 63 { + return Err(IOError::InvalidSizeChar); + } let n = (first - 63) as usize; - if n >= 63 { return Err(IOError::NonCanonicalEncoding); } + if n >= 63 { + return Err(IOError::NonCanonicalEncoding); + } Ok((n, 1)) } } @@ -307,7 +334,9 @@ pub mod write { fn write_size(repr: &mut String, size: usize) -> Result<(), IOError> { let enc = GraphNumberCodec::encode_size(size)?; - for b in enc { repr.push(b as char); } + for b in enc { + repr.push(b as char); + } Ok(()) } @@ -456,7 +485,7 @@ impl GraphConversion for Graph6 { } impl write::WriteGraph for Graph6 {} -use crate::digraph6::{DiGraph6, digraph6_to_pydigraph}; +use crate::digraph6::{digraph6_to_pydigraph, DiGraph6}; // End of combined module @@ -464,7 +493,9 @@ use crate::digraph6::{DiGraph6, digraph6_to_pydigraph}; fn graph6_to_pygraph<'py>(py: Python<'py>, g: &Graph6) -> PyResult> { let mut graph = StablePyGraph::::with_capacity(g.size(), 0); if g.bit_vec.len() < g.size().saturating_mul(g.size()) { - return Err(Graph6ParseError::new_err("Bitvector shorter than n*n; invalid internal state")); + return Err(Graph6ParseError::new_err( + "Bitvector shorter than n*n; invalid internal state", + )); } // add nodes for _ in 0..g.size() { @@ -512,9 +543,12 @@ pub(crate) fn to_file(path: impl AsRef, content: &str) -> std::io::Result< #[pyfunction] #[pyo3(signature=(repr))] +/// Parse a single graph6 or digraph6 text line and return a PyGraph (undirected) +/// or PyDiGraph (directed). Automatically detects directed form starting with +/// '&'. Raises Graph6ParseError / Graph6OverflowError derived python errors for +/// malformed or non‑canonical encodings. pub fn read_graph6_str<'py>(py: Python<'py>, repr: &str) -> PyResult> { - - enum ParserResult{ + enum ParserResult { Graph6(Graph6), DiGraph6(DiGraph6), } @@ -532,16 +566,20 @@ pub fn read_graph6_str<'py>(py: Python<'py>, repr: &str) -> PyResult digraph6_to_pydigraph(py, &dg), Err(io_err) => Err(PyErr::from(io_err)), } - } #[pyfunction] #[pyo3(signature=(pygraph))] +/// Encode an undirected `PyGraph` into a graph6 ASCII string (no trailing +/// newline). Fails with Graph6OverflowError if the graph has >= 2^36 nodes. +/// Self‑loops and multi edges are ignored; only simple adjacency is encoded. pub fn graph_write_graph6_to_str<'py>(py: Python<'py>, pygraph: Py) -> PyResult { let g = pygraph.borrow(py); let n = g.graph.node_count(); if n >= (1usize << 36) { - return Err(Graph6OverflowError::new_err("Graph too large for graph6 encoding")); + return Err(Graph6OverflowError::new_err( + "Graph too large for graph6 encoding", + )); } // build bit_vec let mut bit_vec = vec![0usize; n * n]; @@ -553,12 +591,11 @@ pub fn graph_write_graph6_to_str<'py>(py: Python<'py>, pygraph: Py) -> Ok(graph6) } -/// Parse the size header of a graph6 or digraph6 string. +/// Parse the size field of a graph6/digraph6 string. /// -/// Returns a tuple (n, size_field_length). For a directed (digraph6) string -/// starting with '&', pass offset=1. This function enforces canonical -/// encoding (shortest valid length) per the specification in: -/// https://users.cecs.anu.edu.au/~bdm/data/formats.txt +/// Returns (n, length_of_size_field). For digraph6 pass offset=1 to skip '&'. +/// Enforces canonical (shortest) encoding per the official specification. +/// Errors if n >= 2^36 or the size field uses a non‑minimal form. #[pyfunction] #[pyo3(signature=(data, offset=0))] pub fn parse_graph6_size(data: &str, offset: usize) -> PyResult<(usize, usize)> { @@ -567,10 +604,12 @@ pub fn parse_graph6_size(data: &str, offset: usize) -> PyResult<(usize, usize)> Ok((n, consumed)) } - /// Read a graph6 file from disk and return a PyGraph or PyDiGraph #[pyfunction] #[pyo3(signature=(path))] +/// Read the first non‑empty line from a file (optionally gzip if handled +/// externally) and parse it as graph6 or digraph6, returning a PyGraph or +/// PyDiGraph. Ignores additional lines. Designed for single‑graph files. pub fn read_graph6<'py>(py: Python<'py>, path: &str) -> PyResult> { use std::fs; let data = fs::read_to_string(path) @@ -583,6 +622,9 @@ pub fn read_graph6<'py>(py: Python<'py>, path: &str) -> PyResult= 2^36. pub fn graph_write_graph6(py: Python<'_>, graph: Py, path: &str) -> PyResult<()> { let s = graph_write_graph6_to_str(py, graph)?; to_file(path, &s) diff --git a/src/lib.rs b/src/lib.rs index f7a483eeaf..8a09877921 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,13 +17,12 @@ mod coloring; mod connectivity; mod dag_algo; mod digraph; +mod digraph6; mod dominance; mod dot_utils; mod generators; mod graph; mod graph6; -mod digraph6; -mod sparse6; mod graphml; mod isomorphism; mod iterators; @@ -36,6 +35,7 @@ mod planar; mod random_graph; mod score; mod shortest_path; +mod sparse6; mod steiner_tree; mod tensor_product; mod token_swapper; @@ -51,16 +51,16 @@ use centrality::*; use coloring::*; use connectivity::*; use dag_algo::*; +use digraph6::*; use dominance::*; use graph6::*; -use digraph6::*; -use sparse6::*; use graphml::*; use isomorphism::*; use json::*; use layout::*; use line_graph::*; use link_analysis::*; +use sparse6::*; use matching::*; use planar::*; diff --git a/src/sparse6.rs b/src/sparse6.rs index 5e10e78d5c..1623bebd89 100644 --- a/src/sparse6.rs +++ b/src/sparse6.rs @@ -1,25 +1,32 @@ -use pyo3::prelude::*; -use pyo3::types::PyAny; -use crate::graph6::{IOError, GraphNumberCodec, SizeCodec}; use crate::graph::PyGraph; +use crate::graph6::{GraphNumberCodec, IOError, SizeCodec}; +use crate::StablePyGraph; use petgraph::graph::NodeIndex; use petgraph::prelude::Undirected; -use crate::StablePyGraph; +use pyo3::prelude::*; +use pyo3::types::PyAny; use std::iter; -// Unified size parser using GraphNumberCodec (shared with graph6/digraph6). -// Returns (n, absolute next position) to preserve original caller expectations. +/// Parse an n value from a sparse6 stream using the shared GraphNumberCodec. +/// Returns (n, absolute next byte position). Enforces canonical size encoding +/// and raises GraphTooLarge if n >= 2^36. fn parse_n(bytes: &[u8], pos: usize) -> Result<(usize, usize), IOError> { let (n, consumed) = GraphNumberCodec::decode_size(bytes, pos)?; Ok((n, pos + consumed)) } -// Encoder: produce sparse6 byte chars (63-based) from a graph's bit_vec +/// Encode an undirected graph adjacency matrix (bit_vec of length n*n) into +/// sparse6 bytes. If `header` is true the ">>sparse6<<" marker is prepended. +/// Applies canonical padding rules and returns a newline terminated buffer. fn to_sparse6_bytes(bit_vec: &[usize], n: usize, header: bool) -> Result, IOError> { // Unified bound check occurs inside GraphNumberCodec::encode_size too, but keep for clarity. - if n >= (1usize << 36) { return Err(IOError::GraphTooLarge); } + if n >= (1usize << 36) { + return Err(IOError::GraphTooLarge); + } let mut out: Vec = Vec::new(); - if header { out.extend_from_slice(b">>sparse6<<"); } + if header { + out.extend_from_slice(b">>sparse6<<"); + } out.push(b':'); let size_enc = GraphNumberCodec::encode_size(n)?; out.extend_from_slice(&size_enc); @@ -93,7 +100,14 @@ fn to_sparse6_bytes(bit_vec: &[usize], n: usize, header: bool) -> Result #[pyfunction] #[pyo3(signature=(pygraph, header=true))] -pub fn graph_write_sparse6_to_str<'py>(py: Python<'py>, pygraph: Py, header: bool) -> PyResult { +/// Encode a `PyGraph` to sparse6 format and return the ASCII string. When +/// `header` is true the standard ">>sparse6<<:" header is included. Fails on +/// non‑canonical or oversized graphs (n >= 2^36). Ignores parallel edges. +pub fn graph_write_sparse6_to_str<'py>( + py: Python<'py>, + pygraph: Py, + header: bool, +) -> PyResult { let g = pygraph.borrow(py); let n = g.graph.node_count(); let mut bit_vec = vec![0usize; n * n]; @@ -101,23 +115,32 @@ pub fn graph_write_sparse6_to_str<'py>(py: Python<'py>, pygraph: Py, he bit_vec[i * n + j] = 1; bit_vec[j * n + i] = 1; } - let bytes = to_sparse6_bytes(&bit_vec, n, header) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("sparse6 encode error: {:?}", e)))?; + let bytes = to_sparse6_bytes(&bit_vec, n, header).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("sparse6 encode error: {:?}", e)) + })?; // convert bytes to string - let s = String::from_utf8(bytes).map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("utf8: {}", e)))?; + let s = String::from_utf8(bytes) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("utf8: {}", e)))?; Ok(s) } #[pyfunction] #[pyo3(signature=(repr))] +/// Parse a sparse6 string (optionally containing the standard header) into a +/// `PyGraph`. Accepts trailing newline, tolerates leading ':' or ';'. Performs +/// bounds and character validation and converts Rust panics into Python errors. pub fn read_sparse6_str<'py>(py: Python<'py>, repr: &str) -> PyResult> { let s_trim = repr.trim_end_matches('\n'); - if s_trim.is_empty() { return Err(PyErr::from(IOError::NonCanonicalEncoding)); } + if s_trim.is_empty() { + return Err(PyErr::from(IOError::NonCanonicalEncoding)); + } let wrapped = std::panic::catch_unwind(|| { // Accept optional leading ':' or ';' for incremental form let mut s = s_trim.as_bytes(); - if s.starts_with(b">>sparse6<<:") { s = &s[12..]; } + if s.starts_with(b">>sparse6<<:") { + s = &s[12..]; + } let mut pos = 0usize; if s.len() > 0 && (s[0] == b';' || s[0] == b':') { pos = 1; @@ -128,7 +151,11 @@ pub fn read_sparse6_str<'py>(py: Python<'py>, repr: &str) -> PyResult= s.len() { return Ok::<(Vec<(usize, usize)>, usize), IOError>((Vec::new(), n)); } @@ -151,24 +178,41 @@ pub fn read_sparse6_str<'py>(py: Python<'py>, repr: &str) -> PyResult v { v = x; } - else if x < v && x < n && v < n { edges.push((x, v)); } - if idx < bits.len() && bits[idx..].iter().all(|&b| b == 1) { break; } + for _ in 0..k { + x = (x << 1) | (bits[idx] as usize); + idx += 1; + } + if b == 1 { + v = v.saturating_add(1); + } + if x > v { + v = x; + } else if x < v && x < n && v < n { + edges.push((x, v)); + } + if idx < bits.len() && bits[idx..].iter().all(|&b| b == 1) { + break; + } } Ok((edges, n)) }); match wrapped { - Ok(Ok((edges, n))) => { + Ok(Ok((edges, n))) => { // convert to PyGraph let mut graph = StablePyGraph::::with_capacity(n, 0); for _ in 0..n { graph.add_node(py.None()); } - for (u, v) in edges { graph.add_edge(NodeIndex::new(u), NodeIndex::new(v), py.None()); } - let out = PyGraph { graph, node_removed: false, multigraph: true, attrs: py.None() }; + for (u, v) in edges { + graph.add_edge(NodeIndex::new(u), NodeIndex::new(v), py.None()); + } + let out = PyGraph { + graph, + node_removed: false, + multigraph: true, + attrs: py.None(), + }; Ok(out.into_pyobject(py)?.into_any()) } Ok(Err(io_err)) => Err(PyErr::from(io_err)),