From 57d38a92bc3299d9b106e8bd2938aec3be7aee13 Mon Sep 17 00:00:00 2001 From: Ivan Carvalho <8753214+IvanIsCoding@users.noreply.github.com> Date: Fri, 5 Aug 2022 05:03:21 -0700 Subject: [PATCH 1/2] Update name (#649) --- .zenodo.json | 6 +++--- CITATION.bib | 23 ++++++++++++++++------- CONTRIBUTING.md | 2 +- docs/source/api.rst | 8 ++++---- docs/source/index.rst | 4 ++-- docs/source/tutorial/dags.rst | 2 +- 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/.zenodo.json b/.zenodo.json index f4dc12767..09c9afe11 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -1,6 +1,6 @@ { - "title":"retworkx", - "description":"retworkx is a general-purpose graph theory library focused on performance. It wraps low-level Rust code into a flexible Python API, providing fast implementations for popular graph algorithms.", + "title":"rustworkx", + "description":"rustworkx is a general-purpose graph theory library focused on performance. It wraps low-level Rust code into a flexible Python API, providing fast implementations for popular graph algorithms.", "license":"Apache-2.0", "upload_type":"software", "access_right":"open", @@ -22,7 +22,7 @@ "orcid":"0000-0002-3234-8154" } ], - "notes":"retworkx is the work of many people who contribute to the project at different levels. See the full list of contributors on Github: https://github.com/Qiskit/retworkx/graphs/contributors", + "notes":"rustworkx is the work of many people who contribute to the project at different levels. See the full list of contributors on Github: https://github.com/Qiskit/rustworkx/graphs/contributors", "keywords":[ "graph-theory", "rust", diff --git a/CITATION.bib b/CITATION.bib index 76a4839d8..f005f9d55 100644 --- a/CITATION.bib +++ b/CITATION.bib @@ -1,8 +1,17 @@ -@misc{treinish2021retworkx, - title={retworkx: A High-Performance Graph Library for Python}, - author={Matthew Treinish and Ivan Carvalho and Georgios Tsilimigkounakis and Nahum Sá}, - year={2021}, - eprint={2110.15221}, - archivePrefix={arXiv}, - primaryClass={cs.DS} +@misc{treinish2021rustworkx, + doi = {10.48550/ARXIV.2110.15221}, + + url = {https://arxiv.org/abs/2110.15221}, + + author = {Treinish, Matthew and Carvalho, Ivan and Tsilimigkounakis, Georgios and Sá, Nahum}, + + keywords = {Data Structures and Algorithms (cs.DS), FOS: Computer and information sciences, FOS: Computer and information sciences, E.1}, + + title = {rustworkx: A High-Performance Graph Library for Python}, + + publisher = {arXiv}, + + year = {2021}, + + copyright = {Creative Commons Attribution 4.0 International} } \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85e6d7c50..821b24929 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ contributing to rustworkx, these are documented below. ### Making changes to the code -Retworkx is implemented primarily in Rust with a thin layer of Python. +Rustworkx is implemented primarily in Rust with a thin layer of Python. Because of that, most of your code changes will involve modifications to Rust files in `src`. To understand which files you need to change, we invite you for an overview of our simplified source tree: diff --git a/docs/source/api.rst b/docs/source/api.rst index ecc6f1237..edb29d0be 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -1,8 +1,8 @@ .. _rustworkx: -###################### -Retworkx API Reference -###################### +####################### +Rustworkx API Reference +####################### Graph Classes ============= @@ -323,7 +323,7 @@ API functions for PyGraph ========================= These functions are algorithm functions that are type specific for -:class:`~rustworkx.PyGraph` objects. Universal functions from Retworkx API that +:class:`~rustworkx.PyGraph` objects. Universal functions from Rustworkx API that work for both graph types internally call the functions from the explicitly typed API based on the data type. diff --git a/docs/source/index.rst b/docs/source/index.rst index 1d661832d..265376465 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -53,8 +53,8 @@ Contents: :maxdepth: 2 Overview and Installation - Retworkx Tutorials and Guides - Retworkx API + Rustworkx Tutorials and Guides + Rustworkx API Visualization Release Notes Contributing Guide diff --git a/docs/source/tutorial/dags.rst b/docs/source/tutorial/dags.rst index 5393ebab8..7a184f2de 100644 --- a/docs/source/tutorial/dags.rst +++ b/docs/source/tutorial/dags.rst @@ -130,7 +130,7 @@ quantum computing. Qiskit's `compiler `__ internally represents a quantum circuit as a `directed acyclic graph `__. -Retworkx was originally started to accelerate the performance of the Qiskit +Rustworkx was originally started to accelerate the performance of the Qiskit compiler's use of directed acyclic graphs. To examine how Qiskit uses DAGs, we first need to look at a quantum circuit. A From aa5687eda7fa96d08ba39ca416ddfc49cd37bd31 Mon Sep 17 00:00:00 2001 From: Matthew Treinish Date: Fri, 5 Aug 2022 08:55:31 -0400 Subject: [PATCH 2/2] Add JSON node_link_data serializer (#626) * Add JSON node_link_data serializer This commit adds a new serialization output for a graph to generate a JSON node-link format output. * Downgrade parking lot to fix MSRV builds * Add docs * Add release note * Fix api toc typo * Add tests * Fix docs typos * Apply suggestions from code review Co-authored-by: georgios-ts <45130028+georgios-ts@users.noreply.github.com> * Update docs/source/api.rst Co-authored-by: georgios-ts <45130028+georgios-ts@users.noreply.github.com> Co-authored-by: georgios-ts <45130028+georgios-ts@users.noreply.github.com> --- Cargo.lock | 115 +++++++++----- Cargo.toml | 2 + docs/source/api.rst | 10 +- .../add-json-dump-func-91c43cb4a3cd6951.yaml | 55 +++++++ rustworkx/__init__.py | 44 ++++++ src/json/mod.rs | 112 ++++++++++++++ src/json/node_link_data.rs | 122 +++++++++++++++ src/lib.rs | 10 ++ .../digraph/test_node_link_json.py | 142 ++++++++++++++++++ .../graph/test_node_link_json.py | 142 ++++++++++++++++++ 10 files changed, 715 insertions(+), 39 deletions(-) create mode 100644 releasenotes/notes/add-json-dump-func-91c43cb4a3cd6951.yaml create mode 100644 src/json/mod.rs create mode 100644 src/json/node_link_data.rs create mode 100644 tests/rustworkx_tests/digraph/test_node_link_json.py create mode 100644 tests/rustworkx_tests/graph/test_node_link_json.py diff --git a/Cargo.lock b/Cargo.lock index 85ea7f9a8..f352a8c91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,9 +90,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "getrandom" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39cd93900197114fa1fcb7ae84ca742095eed9442088988ae74fa744e930e77" +checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" dependencies = [ "cfg-if", "libc", @@ -131,12 +131,9 @@ dependencies = [ [[package]] name = "indoc" -version = "1.0.4" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7906a9fababaeacb774f72410e497a1d18de916322e33797bb2cd29baa23c9e" -dependencies = [ - "unindent", -] +checksum = "05a0bd019339e5d968b37855180087b7b9d512c5046fbd244cf8c95687927d6e" [[package]] name = "instant" @@ -147,6 +144,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "itoa" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" + [[package]] name = "lazy_static" version = "1.4.0" @@ -155,16 +158,17 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efaa7b300f3b5fe8eb6bf21ce3895e1751d9665086af2d64b42f19701015ff4f" +checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" [[package]] name = "lock_api" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b" +checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" dependencies = [ + "autocfg", "scopeguard", ] @@ -179,9 +183,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] name = "memoffset" @@ -238,9 +242,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.44" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" dependencies = [ "autocfg", "num-traits", @@ -280,9 +284,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9" +checksum = "7709cef83f0c1f58f666e746a08b21e0085f7440fa6a29cc194d68aac97a4225" [[package]] name = "parking_lot" @@ -327,11 +331,11 @@ checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" [[package]] name = "proc-macro2" -version = "1.0.36" +version = "1.0.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" +checksum = "c54b25569025b7fc9651de43004ae593a75ad88543b17178aa5e1b9c4f15f56f" dependencies = [ - "unicode-xid", + "unicode-ident", ] [[package]] @@ -408,9 +412,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.16" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4af2ec4714533fcdf07e886f17025ace8b997b9ce51204ee69b6da831c3da57" +checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" dependencies = [ "proc-macro2", ] @@ -474,9 +478,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.9.2" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f51245e1e62e1f1629cbfec37b5793bbabcaeb90f30e94d2ba03564687353e4" +checksum = "258bcdb5ac6dad48491bb2992db6b7cf74878b0384908af124823d118c99683f" dependencies = [ "crossbeam-channel", "crossbeam-deque", @@ -486,9 +490,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae183fc1b06c149f0c1793e1eb447c8b04bfe46d48e9e48bfb8d2d7ed64ecf0" +checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" dependencies = [ "bitflags", ] @@ -513,6 +517,8 @@ dependencies = [ "rand_pcg", "rayon", "rustworkx-core", + "serde", + "serde_json", ] [[package]] @@ -527,12 +533,49 @@ dependencies = [ "rayon", ] +[[package]] +name = "ryu" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" + [[package]] name = "scopeguard" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "serde" +version = "1.0.137" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.137" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f26faba0c3959972377d3b2d306ee9f71faee9714294e41bb777f83f88578be" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c" +dependencies = [ + "itoa", + "ryu", + "serde", +] + [[package]] name = "smallvec" version = "1.8.0" @@ -541,32 +584,32 @@ checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" [[package]] name = "syn" -version = "1.0.89" +version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea297be220d52398dcc07ce15a209fce436d361735ac1db700cab3b6cdfb9f54" +checksum = "0748dd251e24453cb8717f0354206b91557e4ec8703673a4b30208f2abaf1ebf" dependencies = [ "proc-macro2", "quote", - "unicode-xid", + "unicode-ident", ] [[package]] name = "target-lexicon" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7fa7e55043acb85fca6b3c01485a2eeb6b69c5d21002e273c79e465f43b7ac1" +checksum = "c02424087780c9b71cc96799eaeddff35af2bc513278cda5c99fc1f5d026d3c1" [[package]] -name = "unicode-xid" -version = "0.2.2" +name = "unicode-ident" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" +checksum = "d22af068fba1eb5edcb4aea19d382b2a3deb4c8f9d475c589b6ada9e0fd493ee" [[package]] name = "unindent" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514672a55d7380da379785a4d70ca8386c8883ff7eaae877be4d2081cebe73d8" +checksum = "52fee519a3e570f7df377a06a1a7775cdbfb7aa460be7e08de2b1f0e69973a44" [[package]] name = "version_check" diff --git a/Cargo.toml b/Cargo.toml index 15088b3fe..f192f820a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ num-traits = "0.2" num-bigint = "0.4" num-complex = "0.4" quick-xml = "0.22.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" rustworkx-core = { path = "rustworkx-core", version = "=0.12.0" } [dependencies.pyo3] diff --git a/docs/source/api.rst b/docs/source/api.rst index edb29d0be..2512e60f7 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -243,14 +243,15 @@ Layout Functions rustworkx.spiral_layout -.. _graphml: +.. _serialization: -GraphML -========== +Serialization +============= .. autosummary:: :toctree: apiref + rustworkx.node_link_json rustworkx.read_graphml .. _converters: @@ -316,6 +317,7 @@ the functions from the explicitly typed based on the data type. rustworkx.digraph_unweighted_average_shortest_path_length rustworkx.digraph_bfs_search rustworkx.digraph_dijkstra_search + rustworkx.digraph_node_link_json .. _api-functions-pygraph: @@ -369,6 +371,7 @@ typed API based on the data type. rustworkx.graph_unweighted_average_shortest_path_length rustworkx.graph_bfs_search rustworkx.graph_dijkstra_search + rustworkx.graph_node_link_json Exceptions ========== @@ -386,6 +389,7 @@ Exceptions rustworkx.NullGraph rustworkx.visit.StopSearch rustworkx.visit.PruneSearch + rustworkx.JSONSerializationError Custom Return Types =================== diff --git a/releasenotes/notes/add-json-dump-func-91c43cb4a3cd6951.yaml b/releasenotes/notes/add-json-dump-func-91c43cb4a3cd6951.yaml new file mode 100644 index 000000000..30b99db7a --- /dev/null +++ b/releasenotes/notes/add-json-dump-func-91c43cb4a3cd6951.yaml @@ -0,0 +1,55 @@ +--- +features: + - | + Added a new function, :func:`~.retworkx.node_link_json`, which is used to + generate JSON node-link data representation of an input :class:`~.PyGraph` + or :class:`~.PyDiGraph` object. For example, running:: + + import retworkx + + graph = retworkx.generators.path_graph(weights=['a', 'b', 'c']) + print(retworkx.node_link_json(graph, node_attrs=lambda n: {'label': n})) + + will output a JSON payload equivalent (identical except for whitespace) to: + + .. code-block:: json + + { + "directed": false, + "multigraph": true, + "attrs": null, + "nodes": [ + { + "id": 0, + "data": { + "label": "a" + } + }, + { + "id": 1, + "data": { + "label": "b" + } + }, + { + "id": 2, + "data": { + "label": "c" + } + } + ], + "links": [ + { + "source": 0, + "target": 1, + "id": 0, + "data": null + }, + { + "source": 1, + "target": 2, + "id": 1, + "data": null + } + ] + } diff --git a/rustworkx/__init__.py b/rustworkx/__init__.py index 1e90f2169..a31c0cf2a 100644 --- a/rustworkx/__init__.py +++ b/rustworkx/__init__.py @@ -2338,3 +2338,47 @@ def _digraph_all_pairs_bellman_ford_shortest_path(graph, edge_cost_fn): @all_pairs_bellman_ford_shortest_paths.register(PyGraph) def _graph_all_pairs_bellman_ford_shortest_path(graph, edge_cost_fn): return graph_all_pairs_bellman_ford_shortest_paths(graph, edge_cost_fn) + + +@functools.singledispatch +def node_link_json(graph, path=None, graph_attrs=None, node_attrs=None, edge_attrs=None): + """Generate a JSON object representing a graph in a node-link format + + :param graph: The graph to generate the JSON for. Can either be a + :class:`~retworkx.PyGraph` or :class:`~retworkx.PyDiGraph`. + :param str path: An optional path to write the JSON output to. If specified + the function will not return anything and instead will write the JSON + to the file specified. + :param graph_attrs: An optional callable that will be passed the + :attr:`~.PyGraph.attrs` attribute of the graph and is expected to + return a dictionary of string keys to string values representing the + graph attributes. This dictionary will be included as attributes in + the output JSON. If anything other than a dictionary with string keys + and string values is returned an exception will be raised. + :param node_attrs: An optional callable that will be passed the node data + payload for each node in the graph and is expected to return a + dictionary of string keys to string values representing the data payload. + This dictionary will be used as the ``data`` field for each node. + :param edge_attrs: An optional callable that will be passed the edge data + payload for each node in the graph and is expected to return a + dictionary of string keys to string values representing the data payload. + This dictionary will be used as the ``data`` field for each edge. + + :returns: Either the JSON string for the payload or ``None`` if ``path`` is specified + :rtype: str + """ + raise TypeError("Invalid Input Type %s for graph" % type(graph)) + + +@node_link_json.register(PyDiGraph) +def _digraph_node_link_json(graph, path=None, graph_attrs=None, node_attrs=None, edge_attrs=None): + return digraph_node_link_json( + graph, path=path, graph_attrs=graph_attrs, node_attrs=node_attrs, edge_attrs=edge_attrs + ) + + +@node_link_json.register(PyGraph) +def _graph_node_link_json(graph, path=None, graph_attrs=None, node_attrs=None, edge_attrs=None): + return graph_node_link_json( + graph, path=path, graph_attrs=graph_attrs, node_attrs=node_attrs, edge_attrs=edge_attrs + ) diff --git a/src/json/mod.rs b/src/json/mod.rs new file mode 100644 index 000000000..deb709f41 --- /dev/null +++ b/src/json/mod.rs @@ -0,0 +1,112 @@ +// 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. + +mod node_link_data; + +use crate::{digraph, graph}; + +use pyo3::prelude::*; +use pyo3::Python; + +/// Generate a JSON object representing a :class:`~.PyDiGraph` in a node-link format +/// +/// :param PyDiGraph graph: The graph to generate the JSON for +/// :param str path: An optional path to write the JSON output to. If specified +/// the function will not return anything and instead will write the JSON +/// to the file specified. +/// :param graph_attrs: An optional callable that will be passed the +/// :attr:`~.PyDiGraph.attrs` attribute of the graph and is expected to +/// return a dictionary of string keys to string values representing the +/// graph attributes. This dictionary will be included as attributes in +/// the output JSON. If anything other than a dictionary with string keys +/// and string values is returned an exception will be raised. +/// :param node_attrs: An optional callable that will be passed the node data +/// payload for each node in the graph and is expected to return a +/// dictionary of string keys to string values representing the data payload. +/// This dictionary will be used as the ``data`` field for each node. +/// :param edge_attrs: An optional callable that will be passed the edge data +/// payload for each node in the graph and is expected to return a +/// dictionary of string keys to string values representing the data payload. +/// This dictionary will be used as the ``data`` field for each edge. +/// +/// :returns: Either the JSON string for the payload or ``None`` if ``path`` is specified +/// :rtype: str +#[pyfunction] +#[pyo3( + text_signature = "(graph, /, path=None, graph_attrs=None, node_attrs=None, edge_attrs=None)" +)] +pub fn digraph_node_link_json( + py: Python, + graph: &digraph::PyDiGraph, + path: Option, + graph_attrs: Option, + node_attrs: Option, + edge_attrs: Option, +) -> PyResult> { + node_link_data::node_link_data( + py, + &graph.graph, + graph.multigraph, + &graph.attrs, + path, + graph_attrs, + node_attrs, + edge_attrs, + ) +} + +/// Generate a JSON object representing a :class:`~.PyGraph` in a node-link format +/// +/// :param PyGraph graph: The graph to generate the JSON for +/// :param str path: An optional path to write the JSON output to. If specified +/// the function will not return anything and instead will write the JSON +/// to the file specified. +/// :param graph_attrs: An optional callable that will be passed the +/// :attr:`~.PyGraph.attrs` attribute of the graph and is expected to +/// return a dictionary of string keys to string values representing the +/// graph attributes. This dictionary will be included as attributes in +/// the output JSON. If anything other than a dictionary with string keys +/// and string values is returned an exception will be raised. +/// :param node_attrs: An optional callable that will be passed the node data +/// payload for each node in the graph and is expected to return a +/// dictionary of string keys to string values representing the data payload. +/// This dictionary will be used as the ``data`` field for each node. +/// :param edge_attrs: An optional callable that will be passed the edge data +/// payload for each node in the graph and is expected to return a +/// dictionary of string keys to string values representing the data payload. +/// This dictionary will be used as the ``data`` field for each edge. +/// +/// :returns: Either the JSON string for the payload or ``None`` if ``path`` is specified +/// :rtype: str +#[pyfunction] +#[pyo3( + text_signature = "(graph, /, path=None, graph_attrs=None, node_attrs=None, edge_attrs=None)" +)] +pub fn graph_node_link_json( + py: Python, + graph: &graph::PyGraph, + path: Option, + graph_attrs: Option, + node_attrs: Option, + edge_attrs: Option, +) -> PyResult> { + node_link_data::node_link_data( + py, + &graph.graph, + graph.multigraph, + &graph.attrs, + path, + graph_attrs, + node_attrs, + edge_attrs, + ) +} diff --git a/src/json/node_link_data.rs b/src/json/node_link_data.rs new file mode 100644 index 000000000..1be349c97 --- /dev/null +++ b/src/json/node_link_data.rs @@ -0,0 +1,122 @@ +// 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 std::collections::BTreeMap; +use std::fs::File; + +use serde::{Deserialize, Serialize}; + +use pyo3::prelude::*; +use pyo3::Python; + +use petgraph::visit::EdgeRef; +use petgraph::visit::IntoEdgeReferences; +use petgraph::EdgeType; + +use crate::JSONSerializationError; +use crate::StablePyGraph; + +#[derive(Serialize, Deserialize)] +struct Graph { + directed: bool, + multigraph: bool, + attrs: Option>, + nodes: Vec, + links: Vec, +} + +#[derive(Serialize, Deserialize)] +struct Node { + id: usize, + data: Option>, +} + +#[derive(Serialize, Deserialize)] +struct Link { + source: usize, + target: usize, + id: usize, + data: Option>, +} + +#[allow(clippy::too_many_arguments)] +pub fn node_link_data( + py: Python, + graph: &StablePyGraph, + multigraph: bool, + attrs: &PyObject, + path: Option, + graph_attrs: Option, + node_attrs: Option, + edge_attrs: Option, +) -> PyResult> { + let attr_callable = |attrs: &PyObject, obj: &PyObject| -> PyResult> { + let res = attrs.call1(py, (obj,))?; + res.extract(py) + }; + let mut nodes: Vec = Vec::with_capacity(graph.node_count()); + for n in graph.node_indices() { + let data = match node_attrs { + Some(ref callback) => Some(attr_callable(callback, &graph[n])?), + None => None, + }; + nodes.push(Node { + id: n.index(), + data, + }); + } + let mut links: Vec = Vec::with_capacity(graph.edge_count()); + for e in graph.edge_references() { + let data = match edge_attrs { + Some(ref callback) => Some(attr_callable(callback, e.weight())?), + None => None, + }; + links.push(Link { + source: e.source().index(), + target: e.target().index(), + id: e.id().index(), + data, + }); + } + + let graph_attrs = match graph_attrs { + Some(ref callback) => Some(attr_callable(callback, attrs)?), + None => None, + }; + + let output_struct = Graph { + directed: graph.is_directed(), + multigraph, + attrs: graph_attrs, + nodes, + links, + }; + match path { + None => match serde_json::to_string(&output_struct) { + Ok(v) => Ok(Some(v)), + Err(e) => Err(JSONSerializationError::new_err(format!( + "JSON Error: {}", + e + ))), + }, + Some(filename) => { + let file = File::create(filename)?; + match serde_json::to_writer(file, &output_struct) { + Ok(_) => Ok(None), + Err(e) => Err(JSONSerializationError::new_err(format!( + "JSON Error: {}", + e + ))), + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index d34450e01..39ee874d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,7 @@ mod graph; mod graphml; mod isomorphism; mod iterators; +mod json; mod layout; mod matching; mod random_graph; @@ -41,6 +42,7 @@ use connectivity::*; use dag_algo::*; use graphml::*; use isomorphism::*; +use json::*; use layout::*; use matching::*; use random_graph::*; @@ -314,6 +316,8 @@ create_exception!(rustworkx, NoPathFound, PyException); import_exception!(rustworkx.visit, PruneSearch); // Stop graph traversal. import_exception!(rustworkx.visit, StopSearch); +// JSON Error +create_exception!(rustworkx, JSONSerializationError, PyException); // Negative Cycle found on shortest-path algorithm create_exception!(rustworkx, NegativeCycle, PyException); // Failed to Converge on a solution @@ -330,6 +334,10 @@ fn rustworkx(py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add("NoPathFound", py.get_type::())?; m.add("NullGraph", py.get_type::())?; m.add("NegativeCycle", py.get_type::())?; + m.add( + "JSONSerializationError", + py.get_type::(), + )?; m.add("FailedToConverge", py.get_type::())?; m.add_wrapped(wrap_pyfunction!(bfs_successors))?; m.add_wrapped(wrap_pyfunction!(graph_bfs_search))?; @@ -461,6 +469,8 @@ fn rustworkx(py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(biconnected_components))?; m.add_wrapped(wrap_pyfunction!(chain_decomposition))?; m.add_wrapped(wrap_pyfunction!(read_graphml))?; + m.add_wrapped(wrap_pyfunction!(digraph_node_link_json))?; + m.add_wrapped(wrap_pyfunction!(graph_node_link_json))?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/tests/rustworkx_tests/digraph/test_node_link_json.py b/tests/rustworkx_tests/digraph/test_node_link_json.py new file mode 100644 index 000000000..b03f765cc --- /dev/null +++ b/tests/rustworkx_tests/digraph/test_node_link_json.py @@ -0,0 +1,142 @@ +# 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 json +import tempfile +import uuid + +import unittest +import rustworkx + + +class TestNodeLinkJSON(unittest.TestCase): + def test_empty_graph(self): + graph = rustworkx.PyDiGraph() + res = rustworkx.node_link_json(graph) + expected = {"attrs": None, "directed": True, "links": [], "multigraph": True, "nodes": []} + self.assertEqual(json.loads(res), expected) + + def test_directed_path_graph(self): + graph = rustworkx.generators.directed_path_graph(3) + res = rustworkx.node_link_json(graph) + expected = { + "attrs": None, + "directed": True, + "links": [ + {"data": None, "id": 0, "source": 0, "target": 1}, + {"data": None, "id": 1, "source": 1, "target": 2}, + ], + "multigraph": True, + "nodes": [{"data": None, "id": 0}, {"data": None, "id": 1}, {"data": None, "id": 2}], + } + self.assertEqual(json.loads(res), expected) + + def test_directed_path_graph_node_attrs(self): + graph = rustworkx.generators.directed_path_graph(3) + for node in graph.node_indices(): + graph[node] = {"nodeLabel": f"node={node}"} + res = rustworkx.node_link_json(graph, node_attrs=dict) + expected = { + "attrs": None, + "directed": True, + "links": [ + {"data": None, "id": 0, "source": 0, "target": 1}, + {"data": None, "id": 1, "source": 1, "target": 2}, + ], + "multigraph": True, + "nodes": [ + {"data": {"nodeLabel": "node=0"}, "id": 0}, + {"data": {"nodeLabel": "node=1"}, "id": 1}, + {"data": {"nodeLabel": "node=2"}, "id": 2}, + ], + } + self.assertEqual(json.loads(res), expected) + + def test_directed_path_graph_edge_attr(self): + graph = rustworkx.generators.directed_path_graph(3) + for edge, (source, target, _weight) in graph.edge_index_map().items(): + graph.update_edge_by_index(edge, {"edgeLabel": f"{source}->{target}"}) + + res = rustworkx.node_link_json(graph, edge_attrs=dict) + expected = { + "attrs": None, + "directed": True, + "links": [ + {"data": {"edgeLabel": "0->1"}, "id": 0, "source": 0, "target": 1}, + {"data": {"edgeLabel": "1->2"}, "id": 1, "source": 1, "target": 2}, + ], + "multigraph": True, + "nodes": [{"data": None, "id": 0}, {"data": None, "id": 1}, {"data": None, "id": 2}], + } + self.assertEqual(json.loads(res), expected) + + def test_directed_path_graph_attr(self): + graph = rustworkx.PyDiGraph(attrs="label") + res = rustworkx.node_link_json(graph, graph_attrs=lambda x: {"label": x}) + expected = { + "attrs": {"label": "label"}, + "directed": True, + "links": [], + "multigraph": True, + "nodes": [], + } + self.assertEqual(json.loads(res), expected) + + def test_file_output(self): + graph = rustworkx.generators.directed_path_graph(3) + graph.attrs = "directed_path_graph" + for node in graph.node_indices(): + graph[node] = {"nodeLabel": f"node={node}"} + for edge, (source, target, _weight) in graph.edge_index_map().items(): + graph.update_edge_by_index(edge, {"edgeLabel": f"{source}->{target}"}) + expected = { + "attrs": {"label": "directed_path_graph"}, + "directed": True, + "links": [ + {"data": {"edgeLabel": "0->1"}, "id": 0, "source": 0, "target": 1}, + {"data": {"edgeLabel": "1->2"}, "id": 1, "source": 1, "target": 2}, + ], + "multigraph": True, + "nodes": [ + {"data": {"nodeLabel": "node=0"}, "id": 0}, + {"data": {"nodeLabel": "node=1"}, "id": 1}, + {"data": {"nodeLabel": "node=2"}, "id": 2}, + ], + } + with tempfile.NamedTemporaryFile() as fd: + res = rustworkx.node_link_json( + graph, + path=fd.name, + graph_attrs=lambda x: {"label": x}, + node_attrs=dict, + edge_attrs=dict, + ) + self.assertIsNone(res) + json_dict = json.load(fd) + self.assertEqual(json_dict, expected) + + def test_invalid_path_dir(self): + nonexistent_path = tempfile.gettempdir() + "/" + str(uuid.uuid4()) + "/graph.rustworkx.json" + graph = rustworkx.PyDiGraph() + with self.assertRaises(FileNotFoundError): + rustworkx.node_link_json(graph, path=nonexistent_path) + + def test_attr_callback_invalid_type(self): + graph = rustworkx.PyDiGraph() + with self.assertRaises(TypeError): + rustworkx.node_link_json(graph, graph_attrs=lambda _: "attrs_field") + + def test_not_multigraph(self): + graph = rustworkx.PyDiGraph(multigraph=False) + res = rustworkx.node_link_json(graph) + expected = {"attrs": None, "directed": True, "links": [], "multigraph": False, "nodes": []} + self.assertEqual(json.loads(res), expected) diff --git a/tests/rustworkx_tests/graph/test_node_link_json.py b/tests/rustworkx_tests/graph/test_node_link_json.py new file mode 100644 index 000000000..45f05ae3d --- /dev/null +++ b/tests/rustworkx_tests/graph/test_node_link_json.py @@ -0,0 +1,142 @@ +# 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 json +import tempfile +import uuid + +import unittest +import rustworkx + + +class TestNodeLinkJSON(unittest.TestCase): + def test_empty_graph(self): + graph = rustworkx.PyGraph() + res = rustworkx.node_link_json(graph) + expected = {"attrs": None, "directed": False, "links": [], "multigraph": True, "nodes": []} + self.assertEqual(json.loads(res), expected) + + def test_path_graph(self): + graph = rustworkx.generators.path_graph(3) + res = rustworkx.node_link_json(graph) + expected = { + "attrs": None, + "directed": False, + "links": [ + {"data": None, "id": 0, "source": 0, "target": 1}, + {"data": None, "id": 1, "source": 1, "target": 2}, + ], + "multigraph": True, + "nodes": [{"data": None, "id": 0}, {"data": None, "id": 1}, {"data": None, "id": 2}], + } + self.assertEqual(json.loads(res), expected) + + def test_path_graph_node_attrs(self): + graph = rustworkx.generators.path_graph(3) + for node in graph.node_indices(): + graph[node] = {"nodeLabel": f"node={node}"} + res = rustworkx.node_link_json(graph, node_attrs=dict) + expected = { + "attrs": None, + "directed": False, + "links": [ + {"data": None, "id": 0, "source": 0, "target": 1}, + {"data": None, "id": 1, "source": 1, "target": 2}, + ], + "multigraph": True, + "nodes": [ + {"data": {"nodeLabel": "node=0"}, "id": 0}, + {"data": {"nodeLabel": "node=1"}, "id": 1}, + {"data": {"nodeLabel": "node=2"}, "id": 2}, + ], + } + self.assertEqual(json.loads(res), expected) + + def test_path_graph_edge_attr(self): + graph = rustworkx.generators.path_graph(3) + for edge, (source, target, _weight) in graph.edge_index_map().items(): + graph.update_edge_by_index(edge, {"edgeLabel": f"{source}->{target}"}) + + res = rustworkx.node_link_json(graph, edge_attrs=dict) + expected = { + "attrs": None, + "directed": False, + "links": [ + {"data": {"edgeLabel": "0->1"}, "id": 0, "source": 0, "target": 1}, + {"data": {"edgeLabel": "1->2"}, "id": 1, "source": 1, "target": 2}, + ], + "multigraph": True, + "nodes": [{"data": None, "id": 0}, {"data": None, "id": 1}, {"data": None, "id": 2}], + } + self.assertEqual(json.loads(res), expected) + + def test_path_graph_attr(self): + graph = rustworkx.PyGraph(attrs="label") + res = rustworkx.node_link_json(graph, graph_attrs=lambda x: {"label": x}) + expected = { + "attrs": {"label": "label"}, + "directed": False, + "links": [], + "multigraph": True, + "nodes": [], + } + self.assertEqual(json.loads(res), expected) + + def test_file_output(self): + graph = rustworkx.generators.path_graph(3) + graph.attrs = "path_graph" + for node in graph.node_indices(): + graph[node] = {"nodeLabel": f"node={node}"} + for edge, (source, target, _weight) in graph.edge_index_map().items(): + graph.update_edge_by_index(edge, {"edgeLabel": f"{source}->{target}"}) + expected = { + "attrs": {"label": "path_graph"}, + "directed": False, + "links": [ + {"data": {"edgeLabel": "0->1"}, "id": 0, "source": 0, "target": 1}, + {"data": {"edgeLabel": "1->2"}, "id": 1, "source": 1, "target": 2}, + ], + "multigraph": True, + "nodes": [ + {"data": {"nodeLabel": "node=0"}, "id": 0}, + {"data": {"nodeLabel": "node=1"}, "id": 1}, + {"data": {"nodeLabel": "node=2"}, "id": 2}, + ], + } + with tempfile.NamedTemporaryFile() as fd: + res = rustworkx.node_link_json( + graph, + path=fd.name, + graph_attrs=lambda x: {"label": x}, + node_attrs=dict, + edge_attrs=dict, + ) + self.assertIsNone(res) + json_dict = json.load(fd) + self.assertEqual(json_dict, expected) + + def test_invalid_path_dir(self): + nonexistent_path = tempfile.gettempdir() + "/" + str(uuid.uuid4()) + "/graph.rustworkx.json" + graph = rustworkx.PyGraph() + with self.assertRaises(FileNotFoundError): + rustworkx.node_link_json(graph, path=nonexistent_path) + + def test_attr_callback_invalid_type(self): + graph = rustworkx.PyGraph() + with self.assertRaises(TypeError): + rustworkx.node_link_json(graph, graph_attrs=lambda _: "attrs_field") + + def test_not_multigraph(self): + graph = rustworkx.PyGraph(multigraph=False) + res = rustworkx.node_link_json(graph) + expected = {"attrs": None, "directed": False, "links": [], "multigraph": False, "nodes": []} + self.assertEqual(json.loads(res), expected)