From 8c2871373524a50a5f1eb21bbd323152e0f20f45 Mon Sep 17 00:00:00 2001 From: georgios-ts <45130028+georgios-ts@users.noreply.github.com> Date: Tue, 4 Oct 2022 02:24:37 +0300 Subject: [PATCH 1/3] Add Stoer Wagner min cut algorithm. (#431) * Add Stoer Wagner min cut algorithm. This commits adds a new function `stoer_wagner_min_cut` that computes a minimum cut for weighted undirected graphs. * define zip for Option to support msrv * fix docs * use ahash * This commits re-implements `stoer_wagner_min_cut` algorithm to work with generic petgraph graph traits. At the same time, it makes use of a disjoint-set data structure to keep track of the members of supernodes for improved performance in most cases. * update release-note * move stoer-wagner to retowkrx-core + add docs * add tets for DSU * Run cargo fmt * Fix rebase comment * Move test file to rustworkx-core * Update module name in rustworkx-core tests * Fix MSRV version for priority-queue * fix release note * move test file under rustworkx tests * don't use a DSU. create a `StableGraph` from the input graph where each node is a super-node (i.e it represents a list of nodes of the original graph) and in each phase we contract two nodes. * don't add parallel edges but sum up the edge costs instead. * init hash map with required capacity. Co-authored-by: Ivan Carvalho <8753214+IvanIsCoding@users.noreply.github.com> * Fix NodeCount trait error * Apply suggestions from code review * Update rustworkx-core/src/connectivity/min_cut.rs * Update rustworkx-core/src/connectivity/min_cut.rs Co-authored-by: Matthew Treinish Co-authored-by: Ivan Carvalho <8753214+IvanIsCoding@users.noreply.github.com> --- Cargo.lock | 13 ++ docs/source/api.rst | 1 + ...-wagner-min-cut-algo-2211d33ba7d7f15f.yaml | 21 ++ rustworkx-core/Cargo.toml | 5 + rustworkx-core/src/connectivity/min_cut.rs | 198 ++++++++++++++++++ rustworkx-core/src/connectivity/mod.rs | 2 + src/connectivity/mod.rs | 55 ++++- src/lib.rs | 2 + src/score.rs | 64 ++++++ tests/rustworkx_tests/graph/test_min_cut.py | 114 ++++++++++ 10 files changed, 472 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/add-stoer-wagner-min-cut-algo-2211d33ba7d7f15f.yaml create mode 100644 rustworkx-core/src/connectivity/min_cut.rs create mode 100644 src/score.rs create mode 100644 tests/rustworkx_tests/graph/test_min_cut.py diff --git a/Cargo.lock b/Cargo.lock index a14063d9e..b3ad3716b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,6 +329,16 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" +[[package]] +name = "priority-queue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf40e51ccefb72d42720609e1d3c518de8b5800d723a09358d4a6d6245e1f8ca" +dependencies = [ + "autocfg", + "indexmap", +] + [[package]] name = "proc-macro2" version = "1.0.39" @@ -529,7 +539,10 @@ dependencies = [ "fixedbitset", "hashbrown", "indexmap", + "num-traits", "petgraph", + "priority-queue", + "rand", "rayon", ] diff --git a/docs/source/api.rst b/docs/source/api.rst index 87f041389..7ef3554a6 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -156,6 +156,7 @@ Connectivity and Cycles rustworkx.chain_decomposition rustworkx.all_simple_paths rustworkx.all_pairs_all_simple_paths + rustworkx.stoer_wagner_min_cut .. _graph-ops: diff --git a/releasenotes/notes/add-stoer-wagner-min-cut-algo-2211d33ba7d7f15f.yaml b/releasenotes/notes/add-stoer-wagner-min-cut-algo-2211d33ba7d7f15f.yaml new file mode 100644 index 000000000..a10eacbc6 --- /dev/null +++ b/releasenotes/notes/add-stoer-wagner-min-cut-algo-2211d33ba7d7f15f.yaml @@ -0,0 +1,21 @@ +--- +features: + - | + Added a new algorithm function, + :func:`rustworkx.stoer_wagner_min_cut` that uses the + Stoer Wagner algorithm for computing a weighted minimum cut + in an undirected :class:`~rustworkx.PyGraph`. + For example: + + .. jupyter-execute:: + + import rustworkx + from rustworkx.visualization import mpl_draw + + graph = rustworkx.generators.grid_graph(2, 2) + cut_val, partition = rustworkx.stoer_wagner_min_cut(graph) + + colors = [ + 'orange' if node in partition else 'blue' for node in graph.node_indexes() + ] + mpl_draw(graph, node_color=colors) diff --git a/rustworkx-core/Cargo.toml b/rustworkx-core/Cargo.toml index f7ada3af6..e81893e76 100644 --- a/rustworkx-core/Cargo.toml +++ b/rustworkx-core/Cargo.toml @@ -15,6 +15,8 @@ ahash = { version = "0.7.6", default-features = false } fixedbitset = "0.4.2" petgraph = "0.6.2" rayon = "1.5" +num-traits = "0.2" +priority-queue = "1.2.0" [dependencies.hashbrown] version = "0.11" @@ -23,3 +25,6 @@ features = ["rayon"] [dependencies.indexmap] version = "1.7" features = ["rayon"] + +[dev-dependencies] +rand = "0.8" \ No newline at end of file diff --git a/rustworkx-core/src/connectivity/min_cut.rs b/rustworkx-core/src/connectivity/min_cut.rs new file mode 100644 index 000000000..89fa1b556 --- /dev/null +++ b/rustworkx-core/src/connectivity/min_cut.rs @@ -0,0 +1,198 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +use hashbrown::HashMap; +use num_traits::Zero; +use std::{hash::Hash, ops::AddAssign}; + +use priority_queue::PriorityQueue; + +use petgraph::{ + stable_graph::StableUnGraph, + visit::{Bfs, EdgeCount, EdgeRef, GraphProp, IntoEdges, IntoNodeIdentifiers, NodeCount}, + Undirected, +}; + +type StCut = Option<((T, T), K)>; +type MinCut = Result)>, E>; + +fn zip(a: Option, b: Option) -> Option<(T, U)> { + match (a, b) { + (Some(a), Some(b)) => Some((a, b)), + _ => None, + } +} + +fn stoer_wagner_phase(graph: G, mut edge_cost: F) -> StCut +where + G: GraphProp + IntoEdges + IntoNodeIdentifiers, + G::NodeId: Hash + Eq, + F: FnMut(G::EdgeRef) -> K, + K: Copy + Ord + Zero + AddAssign, +{ + let mut pq = PriorityQueue::::from( + graph + .node_identifiers() + .map(|nx| (nx, K::zero())) + .collect::>(), + ); + + let mut cut_w = None; + let (mut s, mut t) = (None, None); + while let Some((nx, nx_val)) = pq.pop() { + s = t; + t = Some(nx); + cut_w = Some(nx_val); + for edge in graph.edges(nx) { + pq.change_priority_by(&edge.target(), |x| { + *x += edge_cost(edge); + }) + } + } + + zip(zip(s, t), cut_w) +} + +/// Stoer-Wagner's min cut algorithm. +/// +/// Compute a weighted minimum cut using the Stoer-Wagner algorithm [`stoer_simple_1997`](https://dl.acm.org/doi/10.1145/263867.263872). +/// +/// The graph should be undirected. If the input graph is disconnected, +/// a cut with zero value will be returned. For graphs with less than +/// two nodes, this function returns [`None`]. The function `edge_cost` +/// should return the cost for a particular edge. Edge costs must be non-negative. +/// +/// Returns a tuple containing the value of a minimum cut and a vector +/// of all `NodeId`s contained in one part of the partition that defines a minimum cut. +/// +/// # Example +/// ```rust +/// use std::collections::HashSet; +/// use std::iter::FromIterator; +/// +/// use rustworkx_core::connectivity::stoer_wagner_min_cut; +/// use rustworkx_core::petgraph::graph::{NodeIndex, UnGraph}; +/// use rustworkx_core::Result; +/// +/// let mut graph : UnGraph<(), ()> = UnGraph::new_undirected(); +/// let a = graph.add_node(()); // node with no weight +/// let b = graph.add_node(()); +/// let c = graph.add_node(()); +/// let d = graph.add_node(()); +/// let e = graph.add_node(()); +/// let f = graph.add_node(()); +/// let g = graph.add_node(()); +/// let h = graph.add_node(()); +/// +/// graph.extend_with_edges(&[ +/// (a, b), +/// (b, c), +/// (c, d), +/// (d, a), +/// (e, f), +/// (b, e), +/// (f, g), +/// (g, h), +/// (h, e) +/// ]); +/// // a ---- b ---- e ---- f +/// // | | | | +/// // d ---- c h ---- g +/// +/// let min_cut_res: Result)>> = +/// stoer_wagner_min_cut(&graph, |_| Ok(1)); +/// +/// let (min_cut, partition) = min_cut_res.unwrap().unwrap(); +/// assert_eq!(min_cut, 1); +/// assert_eq!( +/// HashSet::::from_iter(partition), +/// HashSet::from_iter([e, f, g, h]) +/// ); +/// ``` +pub fn stoer_wagner_min_cut(graph: G, mut edge_cost: F) -> MinCut +where + G: GraphProp + IntoEdges + IntoNodeIdentifiers + NodeCount + EdgeCount, + G::NodeId: Hash + Eq, + F: FnMut(G::EdgeRef) -> Result, + K: Copy + Ord + Zero + AddAssign, +{ + let mut graph_with_super_nodes = + StableUnGraph::with_capacity(graph.node_count(), graph.edge_count()); + + let mut node_map = HashMap::with_capacity(graph.node_count()); + let mut rev_node_map = HashMap::with_capacity(graph.node_count()); + + for node in graph.node_identifiers() { + let index = graph_with_super_nodes.add_node(()); + node_map.insert(node, index); + rev_node_map.insert(index, node); + } + + for edge in graph.edge_references() { + let cost = edge_cost(edge)?; + let source = node_map[&edge.source()]; + let target = node_map[&edge.target()]; + graph_with_super_nodes.add_edge(source, target, cost); + } + + if graph_with_super_nodes.node_count() == 0 { + return Ok(None); + } + + let (mut best_phase, mut min_cut_val) = (None, None); + + let mut contractions = Vec::new(); + for phase in 0..(graph_with_super_nodes.node_count() - 1) { + if let Some(((s, t), cut_w)) = + stoer_wagner_phase(&graph_with_super_nodes, |edge| *edge.weight()) + { + if min_cut_val.is_none() || Some(cut_w) < min_cut_val { + best_phase = Some(phase); + min_cut_val = Some(cut_w); + } + // now merge nodes ``s`` and ``t``. + contractions.push((s, t)); + let edges = graph_with_super_nodes + .edges(t) + .map(|edge| (s, edge.target(), *edge.weight())) + .collect::>(); + for (source, target, cost) in edges { + if let Some(edge_index) = graph_with_super_nodes.find_edge(source, target) { + graph_with_super_nodes[edge_index] += cost; + } else { + graph_with_super_nodes.add_edge(source, target, cost); + } + } + graph_with_super_nodes.remove_node(t); + } + } + + // Recover the optimal partitioning from the contractions + let min_cut = best_phase.map(|phase| { + let mut clustered_graph = StableUnGraph::<(), ()>::default(); + clustered_graph.extend_with_edges(&contractions[..phase]); + + let node = contractions[phase].1; + if clustered_graph.contains_node(node) { + let mut cluster = Vec::new(); + let mut bfs = Bfs::new(&clustered_graph, node); + while let Some(nx) = bfs.next(&clustered_graph) { + cluster.push(rev_node_map[&nx]) + } + cluster + } else { + vec![rev_node_map[&node]] + } + }); + + Ok(zip(min_cut_val, min_cut)) +} diff --git a/rustworkx-core/src/connectivity/mod.rs b/rustworkx-core/src/connectivity/mod.rs index a86e97841..65e8d44d4 100644 --- a/rustworkx-core/src/connectivity/mod.rs +++ b/rustworkx-core/src/connectivity/mod.rs @@ -16,6 +16,7 @@ mod all_simple_paths; mod biconnected; mod chain; mod conn_components; +mod min_cut; pub use all_simple_paths::all_simple_paths_multiple_targets; pub use biconnected::articulation_points; @@ -23,3 +24,4 @@ pub use chain::chain_decomposition; pub use conn_components::bfs_undirected; pub use conn_components::connected_components; pub use conn_components::number_connected_components; +pub use min_cut::stoer_wagner_min_cut; diff --git a/src/connectivity/mod.rs b/src/connectivity/mod.rs index 8b2bb5e61..ddc08eb3d 100644 --- a/src/connectivity/mod.rs +++ b/src/connectivity/mod.rs @@ -16,7 +16,10 @@ mod all_pairs_all_simple_paths; mod core_number; mod johnson_simple_cycles; -use super::{digraph, get_edge_iter_with_weights, graph, weight_callable, InvalidNode, NullGraph}; +use super::{ + digraph, get_edge_iter_with_weights, graph, iterators::NodeIndices, score, weight_callable, + InvalidNode, NullGraph, +}; use hashbrown::{HashMap, HashSet}; @@ -25,7 +28,7 @@ use pyo3::prelude::*; use pyo3::Python; use petgraph::algo; -use petgraph::graph::NodeIndex; +use petgraph::stable_graph::NodeIndex; use petgraph::unionfind::UnionFind; use petgraph::visit::{EdgeRef, IntoEdgeReferences, NodeCount, NodeIndexable, Visitable}; @@ -330,7 +333,7 @@ pub fn number_weakly_connected_components(graph: &digraph::PyDiGraph) -> usize { for edge in graph.graph.edge_references() { let (a, b) = (edge.source(), edge.target()); // union the two vertices of the edge - if vertex_sets.union(graph.graph.to_index(a), graph.graph.to_index(b)) { + if vertex_sets.union(a.index(), b.index()) { weak_components -= 1 }; } @@ -774,6 +777,52 @@ pub fn digraph_core_number(py: Python, graph: &digraph::PyDiGraph) -> PyResult

, +) -> PyResult> { + let cut = connectivity::stoer_wagner_min_cut(&graph.graph, |edge| -> PyResult<_> { + let val: f64 = weight_callable(py, &weight_fn, edge.weight(), 1.0)?; + if val.is_nan() { + Ok(score::Score(0.0)) + } else { + Ok(score::Score(val)) + } + })?; + + Ok(cut.map(|(value, partition)| { + ( + value.0, + NodeIndices { + nodes: partition.iter().map(|&nx| nx.index()).collect(), + }, + ) + })) +} + /// Return the articulation points of an undirected graph. /// /// An articulation point or cut vertex is any node whose removal (along with diff --git a/src/lib.rs b/src/lib.rs index 062122db4..5f69f4e77 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ mod layout; mod matching; mod planar; mod random_graph; +mod score; mod shortest_path; mod steiner_tree; mod tensor_product; @@ -465,6 +466,7 @@ fn rustworkx(py: Python<'_>, m: &PyModule) -> PyResult<()> { graph_unweighted_average_shortest_path_length ))?; m.add_wrapped(wrap_pyfunction!(metric_closure))?; + m.add_wrapped(wrap_pyfunction!(stoer_wagner_min_cut))?; m.add_wrapped(wrap_pyfunction!(steiner_tree::steiner_tree))?; m.add_wrapped(wrap_pyfunction!(digraph_dfs_search))?; m.add_wrapped(wrap_pyfunction!(graph_dfs_search))?; diff --git a/src/score.rs b/src/score.rs new file mode 100644 index 000000000..4dc888c5b --- /dev/null +++ b/src/score.rs @@ -0,0 +1,64 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +#![allow(clippy::derive_partial_eq_without_eq)] + +use std::cmp::Ordering; +use std::ops::{Add, AddAssign}; + +use num_traits::Zero; + +/// `Score` holds a score `K` for use with a `PriorityHeap`. +/// +/// **Note:** `Score` implements a total order (`Ord`), so that it is +/// possible to use float types as scores. +#[derive(Clone, Copy, PartialEq)] +pub struct Score(pub K); + +impl> Add for Score { + type Output = Self; + fn add(self, rhs: Self) -> Self::Output { + Score(self.0 + rhs.0) + } +} + +impl AddAssign for Score { + #[inline] + fn add_assign(&mut self, rhs: Self) { + self.0 += rhs.0 + } +} + +impl Zero for Score { + fn zero() -> Self { + Score(K::zero()) + } + + fn is_zero(&self) -> bool { + self.0.is_zero() + } +} + +impl PartialOrd for Score { + #[inline] + fn partial_cmp(&self, other: &Self) -> Option { + self.0.partial_cmp(&other.0) + } +} + +impl Eq for Score {} +impl Ord for Score { + #[inline] + fn cmp(&self, other: &Self) -> Ordering { + // Order NaN less, so that it is last in the Score. + self.partial_cmp(other).unwrap_or(Ordering::Less) + } +} diff --git a/tests/rustworkx_tests/graph/test_min_cut.py b/tests/rustworkx_tests/graph/test_min_cut.py new file mode 100644 index 000000000..d89803c9c --- /dev/null +++ b/tests/rustworkx_tests/graph/test_min_cut.py @@ -0,0 +1,114 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import unittest + +import rustworkx +import numpy + + +class TestMinCut(unittest.TestCase): + def test_min_cut_empty_graph(self): + graph = rustworkx.PyGraph() + res = rustworkx.stoer_wagner_min_cut(graph) + self.assertEqual(res, None) + + def test_min_cut_graph_single_node(self): + graph = rustworkx.PyGraph() + graph.add_node(None) + res = rustworkx.stoer_wagner_min_cut(graph) + self.assertEqual(res, None) + + def test_min_cut_graph_single_edge(self): + graph = rustworkx.PyGraph() + graph.extend_from_weighted_edge_list([(0, 1, 10)]) + value, partition = rustworkx.stoer_wagner_min_cut(graph, lambda x: x) + self.assertEqual(value, 10.0) + self.assertEqual(partition, [1]) + + def test_min_cut_graph_parallel_edge(self): + graph = rustworkx.PyGraph() + graph.extend_from_weighted_edge_list([(0, 1, 4), (0, 1, 6)]) + value, partition = rustworkx.stoer_wagner_min_cut(graph, lambda x: x) + self.assertEqual(value, 10.0) + self.assertEqual(partition, [1]) + + def test_min_cut_path_graph(self): + graph = rustworkx.generators.path_graph(4) + value, _ = rustworkx.stoer_wagner_min_cut(graph) + self.assertEqual(value, 1.0) + + def test_min_cut_grid_graph(self): + graph = rustworkx.generators.grid_graph(4, 4) + value, _ = rustworkx.stoer_wagner_min_cut(graph) + self.assertEqual(value, 2.0) + + def test_min_cut_example_graph(self): + graph = rustworkx.PyGraph() + graph.extend_from_weighted_edge_list( + [ + (0, 1, 2), + (1, 2, 3), + (2, 3, 4), + (4, 5, 3), + (5, 6, 1), + (6, 7, 3), + (0, 4, 3), + (1, 5, 2), + (2, 6, 2), + (3, 7, 2), + (1, 4, 2), + (3, 6, 2), + ] + ) + value, _ = rustworkx.stoer_wagner_min_cut(graph, lambda x: x) + self.assertEqual(value, 4.0) + + def test_min_cut_example_graph_node_hole(self): + graph = rustworkx.PyGraph() + graph.extend_from_weighted_edge_list( + [ + (0, 1, 2), + (1, 2, 3), + (2, 3, 4), + (4, 5, 3), + (5, 6, 1), + (6, 7, 3), + (0, 4, 3), + (1, 5, 2), + (2, 6, 2), + (3, 7, 2), + (1, 4, 2), + (3, 6, 2), + ] + ) + graph.remove_node(5) + value, _ = rustworkx.stoer_wagner_min_cut(graph, lambda x: x) + self.assertEqual(value, 3.0) + + def test_min_cut_disconnected_graph(self): + graph = rustworkx.PyGraph() + graph.extend_from_weighted_edge_list([(0, 1, 1), (2, 3, 1)]) + value, _ = rustworkx.stoer_wagner_min_cut(graph, lambda x: x) + self.assertEqual(value, 0.0) + + def test_min_cut_graph_nan_edge_weight(self): + graph = rustworkx.PyGraph() + graph.extend_from_weighted_edge_list([(0, 1, 4), (0, 1, numpy.nan)]) + value, partition = rustworkx.stoer_wagner_min_cut(graph, lambda x: x) + self.assertEqual(value, 4.0) + self.assertEqual(partition, [1]) + + def test_min_cut_invalid_edge_weight(self): + graph = rustworkx.generators.path_graph(3) + with self.assertRaises(TypeError): + rustworkx.stoer_wagner_min_cut(graph, lambda x: x) From 51637afbe64353f631834a0330af04baf9b1be1e Mon Sep 17 00:00:00 2001 From: Ivan Carvalho <8753214+IvanIsCoding@users.noreply.github.com> Date: Tue, 4 Oct 2022 09:49:52 -0700 Subject: [PATCH 2/3] Avoid using temp files when generating Pillow images (#682) * Avoid using temp file to generate Pillow images * Avoid using temp files for generating dot images * Apply code review suggestion Co-authored-by: Matthew Treinish Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- rustworkx/visualization/graphviz.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/rustworkx/visualization/graphviz.py b/rustworkx/visualization/graphviz.py index 96e84cc6d..4d5198814 100644 --- a/rustworkx/visualization/graphviz.py +++ b/rustworkx/visualization/graphviz.py @@ -6,10 +6,9 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -import os import subprocess import tempfile -import uuid +import io try: from PIL import Image @@ -185,20 +184,17 @@ def node_attr(node): prog = method if not filename: - with tempfile.TemporaryDirectory() as tmpdirname: - filename = f"graphviz_draw_{str(uuid.uuid4())}.{output_format}" - tmp_path = os.path.join(tmpdirname, filename) - subprocess.run( - [prog, "-T", output_format, "-o", tmp_path], - input=dot_str, - check=True, - encoding="utf8", - text=True, - ) - with Image.open(tmp_path) as temp_image: - image = temp_image.copy() - os.remove(tmp_path) - return image + dot_result = subprocess.run( + [prog, "-T", output_format], + input=dot_str.encode("utf-8"), + capture_output=True, + encoding=None, + check=True, + text=False, + ) + dot_bytes_image = io.BytesIO(dot_result.stdout) + image = Image.open(dot_bytes_image) + return image else: subprocess.run( [prog, "-T", output_format, "-o", filename], From 63de4cbd7b8d0dba2436b46506ac023ff098c0c6 Mon Sep 17 00:00:00 2001 From: Prakhar Bhatnagar <42675093+prakharb10@users.noreply.github.com> Date: Wed, 5 Oct 2022 01:53:27 +0530 Subject: [PATCH 3/3] Fixed stray `reno` and added script to check the same (#691) * fixed stray reno * added script to block stray renos * header updated * Apply suggestions from code review Co-authored-by: Ivan Carvalho <8753214+IvanIsCoding@users.noreply.github.com> --- .github/workflows/main.yml | 2 + .../0.11/prepare-0.11-af688e532712c830.yaml | 11 ++++ .../notes/d-1-heavy-hex-e2e44861dc75009a.yaml | 11 ---- tools/find_stray_release_notes.py | 53 +++++++++++++++++++ tox.ini | 1 + 5 files changed, 67 insertions(+), 11 deletions(-) delete mode 100644 tests/rustworkx_tests/generators/releasenotes/notes/d-1-heavy-hex-e2e44861dc75009a.yaml create mode 100644 tools/find_stray_release_notes.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b73ede618..91b66d928 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,6 +40,8 @@ jobs: run: black --check --diff retworkx rustworkx retworkx tests - name: Python Lint run: flake8 --per-file-ignores='retworkx/__init__.py:F405,F403' setup.py retworkx tests rustworkx + - name: Check stray release notes + run: python tools/find_stray_release_notes.py - name: rustworkx-core Rust Tests run: pushd rustworkx-core && cargo test && popd - name: rustworkx-core Docs diff --git a/releasenotes/notes/0.11/prepare-0.11-af688e532712c830.yaml b/releasenotes/notes/0.11/prepare-0.11-af688e532712c830.yaml index 57bb0acad..3d07b3068 100644 --- a/releasenotes/notes/0.11/prepare-0.11-af688e532712c830.yaml +++ b/releasenotes/notes/0.11/prepare-0.11-af688e532712c830.yaml @@ -67,3 +67,14 @@ features: [{'color': 'turquoise', 'size': 'extra large', '__networkx_node__': 'A'}, {'color': 'fuschia', 'size': 'tiny', '__networkx_node__': 'B'}] WeightedEdgeList[(0, 1, {})] + +fixes: + - | + Fixed an issue with the :func:`~retworkx.generators.heavy_hex_graph`, + :func:`~retworkx.generators.directed_heavy_hex_graph`, + :func:`~retworkx.generators.heavy_square_graph`, + and :func:`~retworkx.generators.directed_heavy_square_graph` generator + functions. When the input parameter ``d`` was set to 1 these functions + would previously raise a ``pyo3_runtime.PanicException`` instead of + returning the expected graph (a single node). + Fixed `#452 `__ diff --git a/tests/rustworkx_tests/generators/releasenotes/notes/d-1-heavy-hex-e2e44861dc75009a.yaml b/tests/rustworkx_tests/generators/releasenotes/notes/d-1-heavy-hex-e2e44861dc75009a.yaml deleted file mode 100644 index 6b89e7593..000000000 --- a/tests/rustworkx_tests/generators/releasenotes/notes/d-1-heavy-hex-e2e44861dc75009a.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -fixes: - - | - Fixed an issue with the :func:`~retworkx.generators.heavy_hex_graph`, - :func:`~retworkx.generators.directed_heavy_hex_graph`, - :func:`~retworkx.generators.heavy_square_graph`, - and :func:`~retworkx.generators.directed_heavy_square_graph` generator - functions. When the input parameter ``d`` was set to 1 these functions - would previously raise a ``pyo3_runtime.PanicException`` instead of - returning the expected graph (a single node). - Fixed `#452 `__ diff --git a/tools/find_stray_release_notes.py b/tools/find_stray_release_notes.py new file mode 100644 index 000000000..08000c399 --- /dev/null +++ b/tools/find_stray_release_notes.py @@ -0,0 +1,53 @@ +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Utility script to find any stray release notes.""" + +import argparse +import multiprocessing +import subprocess +import sys +import re + +# release notes regex +reno = re.compile(r"releasenotes\/notes") +# exact release note regex +exact_reno = re.compile(r"^releasenotes\/notes") + + +def discover_files(): + """Find all .py, .pyx, .pxd files in a list of trees""" + cmd = ["git", "ls-tree", "-r", "--name-only", "HEAD"] + res = subprocess.run(cmd, capture_output=True, check=True, encoding="UTF8") + files = res.stdout.split("\n") + return files + + +def validate_path(file_path): + """Validate a path in the git tree.""" + if reno.search(file_path) and not exact_reno.search(file_path): + return file_path + return None + + +def _main(): + parser = argparse.ArgumentParser(description="Find any stray release notes.") + _args = parser.parse_args() + files = discover_files() + with multiprocessing.Pool() as pool: + res = pool.map(validate_path, files) + failed_files = [x for x in res if x is not None] + if len(failed_files) > 0: + for failed_file in failed_files: + sys.stderr.write("%s is not in the correct location.\n" % failed_file) + sys.exit(1) + sys.exit(0) + + +if __name__ == "__main__": + _main() diff --git a/tox.ini b/tox.ini index 3d2c51ecd..097473a00 100644 --- a/tox.ini +++ b/tox.ini @@ -53,6 +53,7 @@ commands = black --check --diff {posargs} '../rustworkx' '../tests' '../retworkx' flake8 --per-file-ignores='../rustworkx/__init__.py:F405,F403' ../setup.py ../rustworkx ../retworkx . cargo fmt --all -- --check + python {toxinidir}/tools/find_stray_release_notes.py [testenv:docs] basepython = python3