From eff12513ebb26c18c73e7c395bcdb893bb564c71 Mon Sep 17 00:00:00 2001 From: Paul Pauls Date: Mon, 16 Oct 2023 21:17:33 +0200 Subject: [PATCH] Release v1.1.0 * Change package management to poetry * Add more type annotations for better static code checking * Add pre-commit configuration to ensure code quality * Optimize the algorithm implementation --- .../cyclic_toposort_graphs.svg | 0 .pre-commit-config.yaml | 28 + LICENSE | 19 - MANIFEST.in | 3 - README.md | 111 ++-- cyclic_toposort.py | 496 ---------------- cyclic_toposort/__init__.py | 4 + cyclic_toposort/acyclic_toposort.py | 76 +++ cyclic_toposort/cyclic_toposort.py | 180 ++++++ cyclic_toposort/utils.py | 56 ++ poetry.lock | 396 +++++++++++++ pyproject.toml | 51 ++ setup.py | 24 - tests/__init__.py | 1 + tests/acyclic_toposort_test.py | 12 + tests/coverage.xml | 542 ++++-------------- tests/cyclic_toposort_test.py | 54 ++ tests/test_cyclic_toposort.py | 163 ------ tests/test_utils.py | 168 ------ tests/utils.py | 127 ++++ tests/utils_test.py | 46 ++ 21 files changed, 1210 insertions(+), 1347 deletions(-) rename {illustrations => .illustrations}/cyclic_toposort_graphs.svg (100%) create mode 100644 .pre-commit-config.yaml delete mode 100644 LICENSE delete mode 100644 MANIFEST.in delete mode 100644 cyclic_toposort.py create mode 100644 cyclic_toposort/__init__.py create mode 100644 cyclic_toposort/acyclic_toposort.py create mode 100644 cyclic_toposort/cyclic_toposort.py create mode 100644 cyclic_toposort/utils.py create mode 100644 poetry.lock create mode 100644 pyproject.toml delete mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/acyclic_toposort_test.py create mode 100644 tests/cyclic_toposort_test.py delete mode 100644 tests/test_cyclic_toposort.py delete mode 100644 tests/test_utils.py create mode 100644 tests/utils.py create mode 100644 tests/utils_test.py diff --git a/illustrations/cyclic_toposort_graphs.svg b/.illustrations/cyclic_toposort_graphs.svg similarity index 100% rename from illustrations/cyclic_toposort_graphs.svg rename to .illustrations/cyclic_toposort_graphs.svg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..289521d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-json + - id: check-toml + - id: check-xml + - id: check-yaml + - id: detect-private-key +- repo: https://github.com/psf/black + rev: 23.9.1 + hooks: + - id: black +- repo: https://github.com/PyCQA/docformatter + rev: v1.7.5 + hooks: + - id: docformatter + additional_dependencies: [tomli] +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.292 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.6.0 + hooks: + - id: mypy + additional_dependencies: [types-PyYAML] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 94aeb73..0000000 --- a/LICENSE +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2020 Paul Pauls - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index eddcd9f..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -# Include README and illustrations in pypi package -include README.md -include illustrations/cyclic_toposort_graphs.svg diff --git a/README.md b/README.md index 43df6f7..e067a40 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,122 @@ -## Topological Sorting Algorithm for Cyclic Graphs ## +## Topological Sorting Algorithm for Cyclic Graphs -**Version 1.0.0** +**Version 1.1.0** -![Python version req](https://img.shields.io/badge/python-v3.0%2B-informational) +![Python version req](https://img.shields.io/badge/python-v3.10%2B-informational) [![PyPI version](https://badge.fury.io/py/cyclic-toposort.svg)](https://badge.fury.io/py/cyclic-toposort) [![codecov](https://codecov.io/gh/PaulPauls/cyclic-toposort/branch/master/graph/badge.svg)](https://codecov.io/gh/PaulPauls/cyclic-toposort) -Sorting algorithm for cyclic as well as acyclic directed graphs such as those below. A directed graph is cyclic if any node exists that has a directed path leading to another node and back to the origin node. +Sorting algorithm for cyclic as well as acyclic directed graphs such as those below. A directed graph is cyclic if any node exists within the graph that has a directed path leading to at least 1 other node and then back to the original node again.

- Example cyclic and acyclic graphs + Example cyclic and acyclic graphs

-The project provides three sorting algorithms for these graphs. `cyclic_topoosort` sorts a cyclic graph and returns a 2-tuple with the first element being a list of ordered nodes and the second element being a set of 2-tuples that are the cyclic edges. The set of cyclic edges is minimal and if the graph is acyclic will be an empty set. `cyclic_toposort_groupings` functions identical though will return as the first element of the 2-tuple an ordered list of sets of nodes, representing topological levels that can be visited at the same time. The set of cyclic edges is also minimal with the groupings variant and empty if the graph is acyclic. `acyclic_toposort` sorts only acyclic graphs and returns an ordered list of sets of nodes, again representing the topological levels. +This project provides 2 sorting algorithms for these graphs. A graph is represented as a set of edges with an edge being a 2-tuple describing the start-node and the end-node of an edge. + +`acyclic_toposort` sorts an acyclic graph (or raises a RuntimeError if called on a cyclic graph) into a list of topological groupings. These topological groupings are sets of nodes that are on the same topological level. Nodes in a topological grouping are either dependent on an incoming edge from the prior topological grouping or have no incoming edges if they are in the first topological grouping. + +```python3 +def acyclic_toposort(edges: Iterable[tuple[int, int]]) -> list[set[int]]: + """Create and return a topological sorting of an acyclic graph as a list of sets, each set representing a + topological level, starting with the nodes that have no dependencies. + + :param edges: iterable of edges represented as 2-tuples, whereas each 2-tuple represents the start-index and end- + index of an edge + :return: topological sorting of the graph represented by the input edges as a list of sets that represent each + topological level in order beginning with all dependencyless nodes. + :raises RuntimeError: if a cyclic graph is detected. + """ +``` + +`cyclic_topoosort` on the other hand sorts cyclic graphs and returns a 2-tuple with the first element being the same list of topological groupings that is returned in the `acyclic_toposort` function and the second element being a set of edges that is required to be cyclic in order to make the rest of the graph acyclic. The determined set of cyclic edges is minimal and if the graph is acyclic will be an empty set. If there are multiple sets of cyclic edges that would turn the rest of the graph acyclic and all have the same size then the set of cyclic edges is chosen which enables the acyclic restgraph to be sorted with the least amount of topological groupings. Unfortunately does this algorithm employ full polynomial recursion and can have a runtime of up to O(2^n). + +```python3 +def cyclic_toposort( + edges: set[tuple[int, int]], + start_node: int | None = None, +) -> tuple[list[set[int]], set[tuple[int, int]]]: + """Perform a topological sorting on a potentially cyclic graph, returning a tuple consisting of a graph topology + with the fewest topological groupings and a minimal set of cyclic edges. + + :param edges: A set of tuples where each tuple represents a directed edge (start_node, end_node) in the graph. + :param start_node: An optional node. If provided, any edge leading into this node will be considered as a forced + cyclic edge. + :return: A tuple containing: + - A list of sets representing the topological ordering of nodes. Each set contains nodes at the same depth. The + amount of topological groupings is minimal out of all possible sets of cyclic edges. + - A set of tuples representing the cyclic edges that were identified in the graph and that yielded a graph + topology with the fewest topological groupings. + """ +``` ------------------------------------------------------------------------------------------------------------------------ -### Example Usage ### +### Installation & Example Usage -The following examples encode the cyclic and acyclic graphs displayed above: +Install `cyclic-toposort` via pip or your preferred Python package manager: + +```shell +pip install cyclic-toposort +``` + +The following examples encode the cyclic and acyclic graphs displayed above and show the usage of cyclic-toposort as a package: ``` python ->>> edges = {(1, 2), (2, 3), (3, 5), (3, 6), (4, 1), (4, 5), (4, 6), (5, 2), (5, 7), (6, 1), (8, 6)} ->>> cyclic_toposort(edges) -([8, 3, 4, 5, 6, 1, 7, 2], {(2, 3)}) ->>> cyclic_toposort_groupings(edges) -([{8, 3, 4}, {5, 6}, {1, 7}, {2}], {(2, 3)}) ->>> cyclic_toposort_groupings(edges, start_node=2, end_node=5) -([{8, 2, 4}, {3}, {6}, {1, 5}], {(1, 2), (5, 7), (5, 2)}) +>>> from cyclic_toposort import cyclic_toposort, acyclic_toposort +>>> cyclic_graph_edges = {(1, 2), (2, 3), (3, 5), (3, 6), (4, 1), (4, 5), (4, 6), (5, 2), (5, 7), (6, 1), (8, 6)} +>>> cyclic_toposort(cyclic_graph_edges) +([{8, 3, 4}, {5, 6}, {1, 7}, {2}], {(2, 3)}) +>>> cyclic_toposort(cyclic_graph_edges, start_node=2) +([{8, 2, 4}, {3}, {5, 6}, {1, 7}], {(1, 2), (5, 2)}) ->>> edges = {(1, 2), (1, 3), (2, 3), (2, 4), (3, 4), (5, 3), (5, 6), (7, 6)} ->>> acyclic_toposort(edges) +>>> acyclic_toposort_edges = {(1, 2), (1, 3), (2, 3), (2, 4), (3, 4), (5, 3), (5, 6), (7, 6)} +>>> acyclic_toposort(acyclic_toposort_edges) [{1, 5, 7}, {2, 6}, {3}, {4}] ``` ------------------------------------------------------------------------------------------------------------------------ -### Correctness and Performance ### +### Correctness and Performance Since I am unable to formerly validate the specifications of my algorithms have I opted to prove the correctness of the cyclic sorting algorithm by randomly generating cyclic graphs, sorting them with the algortihms and verifying the correctness of the results by testing them against a bruteforce sorting method that takes a long time though is able to calculate all correct results. The random graphs are generated with the following parameters: ``` python +CYCLIC_NODES_PROBABILITY = 0.2 +START_NODE_PROBABILITY = 0.2 + num_edges = random.randint(8, 16) -start_node = random.choice([None, random.randint(1, 5)]) -end_node = random.choice([None, random.randint(6, 10)]) -full_cyclic_graph = False -cyclic_nodes = random.choice([True, False]) -nodes, edges = test_utils.create_random_graph(num_edges=num_edges, - start_node=start_node, - end_node=end_node, - full_cyclic_graph=full_cyclic_graph, - cyclic_nodes=cyclic_nodes) +cyclic_nodes = random.random() < CYCLIC_NODES_PROBABILITY +edges = create_random_graph( + num_edges=num_edges, + cyclic_nodes=cyclic_nodes +) +start_node = None +if random.random() < START_NODE_PROBABILITY: + start_node = random.choice(list(edges))[0] ``` -This verification process is repeated 1000 times in the test files and yielded the following average processing times for the sorting algorithms given the graphs generated with the parameters above. The average processing times were calculated on a Ryzen 5 2600X (6 x 3.6Ghz): - -`cyclic_toposort` mean. time: 0.4936s (std. dev: 2.6189s) +This verification process is repeated 1000 times in the test files and yielded the following average processing times for the sorting algorithms given the graphs generated with the parameters above. The average processing times were calculated on a Ryzen 5 2600X (6 x 3.6Ghz) using Python 3.10.11: -`cyclic_toposort_groupings` mean. time: 0.8320s (std. dev: 4.3270s) +`cyclic_toposort` mean. time: 0.2439s (std. dev: 2.658s) ------------------------------------------------------------------------------------------------------------------------ -### Dev Comments ### +### Dev Comments -* The cyclic sorting algorithms are slow when applied to graphs that are fully cyclic (each node has at least 1 incoming and at least 1 outgoing edge). The Bruteforce method is surprisingly quick when the graph is fully cyclic. +* The cyclic sorting algorithm is slow when applied to graphs that are fully cyclic (each node has at least 1 incoming and at least 1 outgoing edge). The Bruteforce method is surprisingly quick when the graph is fully cyclic. -* The implementaiton has further considerable speed up potential by using multithreading as it is currently single-threaded while being easily parallelizable. The algorithm would also benefit if implemented in a lower level programming language as it relies heavily on recursion and CPython is known to be ressource-hungry on recursion. If the project will be well received and gains some users then I will optimize the implementation (and possibly algorithm) more. +* The implementation has considerable speed up potential by using multithreading, which however would require coordination of the multitude of recursively spawned threads. Since the algorithm relies heavily on recurstion and CPython is known to be slow and ressource-hungry on recursion have I not bothered in parallelizing the implementation for the maximum attainable speed since a proper implementation would require a low-level programming language like C++ or Rust anyway. If the project will be well received and gains some actual use then I hope to find the time to implement this project in Rust and make Python bindings available. * I would be thankful for feedback, issues (with reproducing code) or even concrete ideas or code for improvement ------------------------------------------------------------------------------------------------------------------------ -### Known Issues ### +### Known Issues None diff --git a/cyclic_toposort.py b/cyclic_toposort.py deleted file mode 100644 index 56a5d25..0000000 --- a/cyclic_toposort.py +++ /dev/null @@ -1,496 +0,0 @@ -""" -Copyright (c) 2020 Paul Pauls - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -""" - -import sys -import itertools -from copy import deepcopy - - -def acyclic_toposort(edges) -> [{int}]: - """ - Create topological sorting of an acyclic graph with maximized groupings of levels. Return this topological sorting - as list of sets that represent each topological level beginning with the start (= dependencyless) nodes of the - acyclic graph. - :param edges: iterable of 2-tuples, specifying start and end for each edge - :return: topological sorting of graph as list of sets that represent each topological level beginning with the start - nodes - """ - # Create python dict that associates each node with the set of all nodes that having an incoming edge (node_ins) to - # that particular node. If a node has no incoming connections will the node be associated with an empty set. - node_ins = dict() - for (edge_start, edge_end) in edges: - # Don't consider cyclic node edges as not relevant for topological sorting - if edge_start == edge_end: - continue - - if edge_start not in node_ins: - node_ins[edge_start] = set() - - if edge_end not in node_ins: - node_ins[edge_end] = {edge_start} - else: - node_ins[edge_end].add(edge_start) - - graph_topology = list() - - while True: - # Determine all nodes having no input/dependency in the current topological level - dependencyless = set() - for node, incomings in node_ins.items(): - if len(incomings) == 0: - dependencyless.add(node) - - if not dependencyless: - if not node_ins: - raise RuntimeError("Invalid graph detected") - else: - raise RuntimeError("Cyclic graph detected in acyclic_toposort function") - - # Set dependencyless nodes as the nodes of the next topological level - graph_topology.append(dependencyless) - - # Remove dependencyless nodes from node_ins collection, as those dependencyless nodes have been placed. - for node in dependencyless: - del node_ins[node] - - # If all nodes are placed, exit topological sorting - if not node_ins: - break - - # Remove depdencyless nodes from node_ins (the set of required incoming nodes) as those dependencyless nodes - # have been placed and their dependency to other nodes is therefore fulfilled - for node, incomings in node_ins.items(): - node_ins[node] = incomings - dependencyless - - return graph_topology - - -def cyclic_toposort(edges, start_node=None, end_node=None) -> ([int], {(int, int)}): - """ - Sorts directed cyclic graphs given the edges that define the graph and potential start_node or end_node constraints. - The function returns a 2-tuple consisting of an ordered list of nodes as well as a set of 2-tuples being the - necessary minmal cyclic edges. - - :param edges: Set of 2-tuples, with the 2-tuples specifying the start and end node of an edge - :param start_node: int (optional), a node with which the sorted list of nodes should start - :param end_node: int (optional), a node with which the sorted list of nodes should end - :return: 2-tuple of ordered list of nodes and the minimal set of cyclic edges - """ - # Process edges by determining the incoming and outgoing connections for each node - node_ins = dict() - node_outs = dict() - cyclic_edges = set() - for (edge_start, edge_end) in edges: - # Don't consider cyclic node edges as not relevant for topological sorting - if edge_start == edge_end: - continue - - # Make sure nodes are considered in the incoming/outgoing nodes dicts even if they have no incoming/outgoing - # edge - if edge_start not in node_ins: - node_ins[edge_start] = set() - if edge_end not in node_outs: - node_outs[edge_end] = set() - - # If start or endnodes are supplied then violating edges are automatically considered cyclic - if start_node and edge_end == start_node: - cyclic_edges.add((edge_start, start_node)) - continue - if end_node and edge_start == end_node: - cyclic_edges.add((end_node, edge_end)) - continue - - if edge_end not in node_ins: - node_ins[edge_end] = {edge_start} - else: - node_ins[edge_end].add(edge_start) - - if edge_start not in node_outs: - node_outs[edge_start] = {edge_end} - else: - node_outs[edge_start].add(edge_end) - - # Recursively sort the graph, finding the minimal number of cyclic edges - cyclic_edges_restgraph = _cyclic_toposort_recursive(node_ins, node_outs) - - # Add the required edges due to optionally supplied start/end nodes to the minimal number of cyclic edges - cyclic_edges = cyclic_edges.union(cyclic_edges_restgraph) - - # Create a set of reduced edges that would create an acyclic graph - reduced_edges = edges - cyclic_edges - - # Process reduced edges by determining the incoming edges - node_ins = dict() - for (edge_start, edge_end) in reduced_edges: - # Don't consider cyclic node edges as not relevant for topological sorting - if edge_start == edge_end: - continue - - # Make sure nodes are considered in the incoming nodes dict even if they have no incoming edge - if edge_start not in node_ins: - node_ins[edge_start] = set() - - # Disregard start/end node as they are applied later - if start_node and edge_end == start_node: - continue - if end_node and edge_end == end_node: - continue - - if edge_end not in node_ins: - node_ins[edge_end] = {edge_start} - else: - node_ins[edge_end].add(edge_start) - - if start_node: - graph_topology = [start_node] - else: - graph_topology = list() - - # Perform simple acyclic topological sorting of the restgraph - while True: - # Determine nodes with no incoming edges in current state of sorting which therefore can be placed and removed - # from consideration - dependencyless = set() - for node, incomings in node_ins.items(): - if len(incomings) == 0: - dependencyless.add(node) - - if not dependencyless: - raise RuntimeError("Invalid graph detected") - - # Set dependencyless nodes as the nodes of the next topological level - graph_topology += list(dependencyless) - - # Remove nodes with no incoming edges from consideration - for node in dependencyless: - del node_ins[node] - - # Break if all nodes are placed - if not node_ins: - break - - # Remove nodes that were placed/removed from consideration of being necessary nodes of other nodes - for node, incomings in node_ins.items(): - node_ins[node] = incomings - dependencyless - - # Add optional end node if set - if end_node: - graph_topology += [end_node] - - return (graph_topology, cyclic_edges) - - -def _cyclic_toposort_recursive(node_ins, node_outs) -> {(int, int)}: - """ - Recursive part of the cyclic toposort algorithm, taking a graph as input that is represented through all its nodes - and its according inputs and outputs. Returns a minimal set of cyclic connections that would create an order for - the graph. - :param node_ins: dict (keys: int, values: {int}), specifying all nodes that have an incoming edge to the respective - node - :param node_outs: dict (keys: int, values: {int}), specifying all nodes that have an outgoing edge to the respective - node - :return: minimal set of cyclic edges (2-tuples) that would make the grpah acyclic and therefore sortable - """ - cyclic_edges = set() - - while True: - #### FORWARD SORTING ########################################################################################### - # Determine nodes with no incoming edges in current state of sorting which therefore can be placed and removed - # from consideration - dependencyless = set() - for node, incomings in node_ins.items(): - if len(incomings) == 0: - dependencyless.add(node) - - if not dependencyless: - #### BACKWARD SORTING ###################################################################################### - while True: - # Determine nodes with no outgoing edges in current state of sorting which therefore can be placed and - # removed from consideration - followerless = set() - for node, outgoings in node_outs.items(): - if len(outgoings) == 0: - followerless.add(node) - - if not followerless: - #### CYCLE RESOLUTION ############################################################################## - min_number_cyclic_edges = sys.maxsize - - # Recreate edge list from current state of node_ins - edges = list() - for node, incomings in node_ins.items(): - for edge_start in incomings: - edges.append((edge_start, node)) - - # Iteratively and randomly declare more and more edges as cyclic and see how well the resulting - # graph (represented as reduced_node_ins and reduced_node_outs) is sortable. - for reduced_node_ins, reduced_node_outs, necessary_cyclic_edges in \ - _create_reduced_node_ins_outs(edges, node_ins, node_outs): - - # If the necessary cyclic edges from now on are higher than the already found minimum number - # of cyclic edges then break - if len(necessary_cyclic_edges) > min_number_cyclic_edges: - break - - # Recursively check for the minimum amount of cyclic edges in the resulting restgraph - cyclic_edges_restgraph = _cyclic_toposort_recursive(reduced_node_ins, reduced_node_outs) - - # If a new minimal amount of cyclic edges has been found save it - if len(necessary_cyclic_edges) + len(cyclic_edges_restgraph) < min_number_cyclic_edges: - min_number_cyclic_edges = len(necessary_cyclic_edges) + len(cyclic_edges_restgraph) - cyclic_edges = necessary_cyclic_edges.union(cyclic_edges_restgraph) - - # If the restgraph is acyclic break search - if len(cyclic_edges_restgraph) == 0: - break - break - #################################################################################################### - - # Remove nodes with no outgoing edges from consideration - for node in followerless: - del node_outs[node] - del node_ins[node] - - # Remove nodes that were placed/removed from consideration of being following nodes of other nodes - for node, outgoings in node_outs.items(): - node_outs[node] = outgoings - followerless - - break - ############################################################################################################ - - # Remove nodes with no incoming edges from consideration - for node in dependencyless: - del node_ins[node] - del node_outs[node] - - # Break if all nodes are placed - if not node_ins: - break - - # Remove nodes that were placed/removed from consideration of being necessary nodes of other nodes - for node, incomings in node_ins.items(): - node_ins[node] = incomings - dependencyless - ################################################################################################################ - - return cyclic_edges - - -def cyclic_toposort_groupings(edges, start_node=None, end_node=None) -> ([{int}], {(int, int)}): - """ - Sorts directed cyclic graphs given the edges that define the graph and potential start_node or end_node constraints. - The function returns a 2-tuple consisting of an ordered list of set of nodes as well as a set of 2-tuples being the - necessary minimal cyclic edges. Each set of nodes represents a topological level. - - :param edges: Set of 2-tuples, with the 2-tuples specifying the start and end node of an edge - :param start_node: int (optional), a node with which the sorted list of nodes should start - :param end_node: int (optional), a node with which the sorted list of nodes should end - :return: 2-tuple of ordered list of set of nodes and the minimal set of cyclic edges - """ - # Process edges by determining the incoming and outgoing connections for each node - node_ins = dict() - node_outs = dict() - start_end_cyclic_edges = set() - for (edge_start, edge_end) in edges: - # Don't consider cyclic node edges as not relevant for topological sorting - if edge_start == edge_end: - continue - - # Make sure nodes are considered in the incoming/outgoing nodes dicts even if they have no incoming/outgoing - # edge - if edge_start not in node_ins: - node_ins[edge_start] = set() - if edge_end not in node_outs: - node_outs[edge_end] = set() - - # If start or endnodes are supplied then violating edges are automatically considered cyclic - if start_node and edge_end == start_node: - start_end_cyclic_edges.add((edge_start, start_node)) - continue - if end_node and edge_start == end_node: - start_end_cyclic_edges.add((end_node, edge_end)) - continue - - if edge_end not in node_ins: - node_ins[edge_end] = {edge_start} - else: - node_ins[edge_end].add(edge_start) - - if edge_start not in node_outs: - node_outs[edge_start] = {edge_end} - else: - node_outs[edge_start].add(edge_end) - - # Recursively sort the graph, finding all minmal sets cyclic edges that would make the graph acyclic - cyclic_edges_restgraph = _cyclic_toposort_groupings_recursive(node_ins, node_outs) - - # Add all necessary cyclic edges stemming from the set start/end node to the determined minimal sets of cyclic edges - cyclic_edges = list() - for cyclic_edges_restgraph_set in cyclic_edges_restgraph: - cyclic_edges.append(cyclic_edges_restgraph_set.union(start_end_cyclic_edges)) - - # If graph has cyclic edges and is not acyclic to begin with - if cyclic_edges[0]: - min_groupings_graph_topology = None - min_groupings_graph_topology_len = sys.maxsize - # Determine the set of cyclic edges that leads to a topological sorting with the least amount of topological - # groupings - for cyclic_edges_set in cyclic_edges: - reduced_edges = edges - cyclic_edges_set - graph_topology = acyclic_toposort(reduced_edges) - if len(graph_topology) < min_groupings_graph_topology_len: - min_groupings_graph_topology = (graph_topology, cyclic_edges_set) - min_groupings_graph_topology_len = len(graph_topology) - else: - # Determine the minimal groupings graph topology by acyclic sorting the original graph - min_groupings_graph_topology = (acyclic_toposort(edges), set()) - - # If end node is specially set but it is not in the last topological level remove end node from other levels and - # add it in the last level - if end_node and end_node not in min_groupings_graph_topology[0][-1]: - for grouping in min_groupings_graph_topology[0]: - grouping -= {end_node} - min_groupings_graph_topology[0][-1].add(end_node) - - return min_groupings_graph_topology - - -def _cyclic_toposort_groupings_recursive(node_ins, node_outs) -> [{(int, int)}]: - """ - Recursive part of the cyclic toposort algorithm, taking a graph as input that is represented through all its nodes - and its according inputs and outputs. Returns all minimal sets of cyclic connections that would create an order for - the graph. - :param node_ins: dict (keys: int, values: {int}), specifying all nodes that have an incoming edge to the respective - node - :param node_outs: dict (keys: int, values: {int}), specifying all nodes that have an outgoing edge to the respective - node - :return: All minimal sets of cyclic edges (2-tuples) that would make the grpah acyclic and therefore sortable - """ - cyclic_edges = [set()] - - while True: - #### FORWARD SORTING ########################################################################################### - # Determine nodes with no incoming edges in current state of sorting which therefore can be placed and removed - # from consideration - dependencyless = set() - for node, incomings in node_ins.items(): - if len(incomings) == 0: - dependencyless.add(node) - - if not dependencyless: - #### BACKWARD SORTING ###################################################################################### - while True: - # Determine nodes with no outgoing edges in current state of sorting which therefore can be placed and - # removed from consideration - followerless = set() - for node, outgoings in node_outs.items(): - if len(outgoings) == 0: - followerless.add(node) - - if not followerless: - #### CYCLE RESOLUTION ############################################################################## - min_number_cyclic_edges = sys.maxsize - - # Recreate edge list from current state of node_ins - edges = list() - for node, incomings in node_ins.items(): - for edge_start in incomings: - edges.append((edge_start, node)) - - # Iteratively and randomly declare more and more edges as cyclic and see how well the resulting - # graph (represented as reduced_node_ins and reduced_node_outs) is sortable. - for reduced_node_ins, reduced_node_outs, necessary_cyclic_edges in \ - _create_reduced_node_ins_outs(edges, node_ins, node_outs): - - # If the necessary cyclic edges from now on are higher than the already found minimum number - # of cyclic edges then break - if len(necessary_cyclic_edges) > min_number_cyclic_edges: - break - - # Recursively check for the minimum amount of cyclic edges in the resulting restgraph - cyclic_edges_restgraph = _cyclic_toposort_groupings_recursive(reduced_node_ins, - reduced_node_outs) - - # If a new minimal amount of cyclic edges has been found update the min_number_cyclic_edges - # variables and only save the new min cyclic edges - if len(necessary_cyclic_edges) + len(cyclic_edges_restgraph[0]) < min_number_cyclic_edges: - min_number_cyclic_edges = len(necessary_cyclic_edges) + len(cyclic_edges_restgraph[0]) - cyclic_edges = list() - for cyclic_edges_restgraph_set in cyclic_edges_restgraph: - cyclic_edges.append(cyclic_edges_restgraph_set.union(necessary_cyclic_edges)) - # If a set of cyclic edges has been found that has the same size as the current minimum, - # save these cyclic edges as well - elif len(necessary_cyclic_edges) + len(cyclic_edges_restgraph[0]) == min_number_cyclic_edges: - for cyclic_edges_restgraph_set in cyclic_edges_restgraph: - cyclic_edges.append(cyclic_edges_restgraph_set.union(necessary_cyclic_edges)) - - break - #################################################################################################### - - # Remove nodes with no outgoing edges from consideration - for node in followerless: - del node_outs[node] - del node_ins[node] - - # Remove nodes that were placed/removed from consideration of being following nodes of other nodes - for node, outgoings in node_outs.items(): - node_outs[node] = outgoings - followerless - - break - ############################################################################################################ - - # Remove nodes with no incoming edges from consideration - for node in dependencyless: - del node_ins[node] - del node_outs[node] - - # Break if all nodes are placed - if not node_ins: - break - - # Remove nodes that were placed/removed from consideration of being necessary nodes of other nodes - for node, incomings in node_ins.items(): - node_ins[node] = incomings - dependencyless - ################################################################################################################ - - return cyclic_edges - - -def _create_reduced_node_ins_outs(edges, node_ins, node_outs) -> ({int: {int}}, {int: {int}}, {(int, int)}): - """ - Iteratively and randomly select more and more edges, declare them as cyclic and return a deepcopied node_ins and - node_outs with the cyclic edge removed. - :param edges: Set of 2-tuples, with the 2-tuples specifying the start and end node of an edge - :param node_ins: dict (keys: int, values: {int}), specifying all nodes that have an incoming edge to the respective - node - :param node_outs: dict (keys: int, values: {int}), specifying all nodes that have an outgoing edge to the respective - node - :return: deepcopied node_ins with the cyclic edge removed, deepcopied node_outs with the cyclic edge removed, - cyclic edge - """ - for n in range(1, len(edges) + 1): - for necessary_cyclic_edges in itertools.combinations(edges, n): - reduced_node_ins = deepcopy(node_ins) - reduced_node_outs = deepcopy(node_outs) - for (edge_start, edge_end) in necessary_cyclic_edges: - reduced_node_ins[edge_end] -= {edge_start} - reduced_node_outs[edge_start] -= {edge_end} - yield reduced_node_ins, reduced_node_outs, set(necessary_cyclic_edges) diff --git a/cyclic_toposort/__init__.py b/cyclic_toposort/__init__.py new file mode 100644 index 0000000..71227ec --- /dev/null +++ b/cyclic_toposort/__init__.py @@ -0,0 +1,4 @@ +"""Init module to create a clean namespace when importing cyclic_toposort.""" + +from cyclic_toposort.acyclic_toposort import acyclic_toposort +from cyclic_toposort.cyclic_toposort import cyclic_toposort diff --git a/cyclic_toposort/acyclic_toposort.py b/cyclic_toposort/acyclic_toposort.py new file mode 100644 index 0000000..6fee0e6 --- /dev/null +++ b/cyclic_toposort/acyclic_toposort.py @@ -0,0 +1,76 @@ +"""Module providing functions for sorting directed acyclic graphs.""" + +# Copyright (c) 2020 Paul Pauls. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from collections.abc import Iterable + + +def acyclic_toposort(edges: Iterable[tuple[int, int]]) -> list[set[int]]: + """Create and return a topological sorting of an acyclic graph as a list of sets, each set representing a + topological level, starting with the nodes that have no dependencies. + + :param edges: iterable of edges represented as 2-tuples, whereas each 2-tuple represents the start-index and end- + index of an edge + :return: topological sorting of the graph represented by the input edges as a list of sets that represent each + topological level in order beginning with all dependencyless nodes. + :raises RuntimeError: if a cyclic graph is detected. + """ + # Create dict that associates each node with the set of all nodes that having an incoming edge (node_ins) to + # that particular node. If a node has no incoming connections will the node be associated with an empty set. + node_ins: dict[int, set[int]] = {} + for edge_start, edge_end in edges: + # Don't consider cyclic node edges as not relevant for topological sorting + if edge_start == edge_end: + continue + + # Ensure that each node is present in the node_ins dict, even if it has no incoming edges + node_ins.setdefault(edge_start, set()) + + # Add the edge_start node to the set of incoming nodes of the edge_end node + node_ins.setdefault(edge_end, set()).add(edge_start) + + # Create the topological sorting of the graph represented by the input edges as a list of sets that represent each + # topological level in order beginning with all dependencyless nodes. + graph_topology: list[set[int]] = [] + while True: + # Determine all nodes having no input/dependency in the current topological level + dependencyless = {node for node, incomings in node_ins.items() if not incomings} + + if not dependencyless: + msg = "Cyclic graph detected in acyclic_toposort function" if node_ins else "Invalid graph detected" + raise RuntimeError(msg) + + # Set dependencyless nodes as the nodes of the next topological level + graph_topology.append(dependencyless) + + # Remove dependencyless nodes from node_ins collection, as those dependencyless nodes have been placed. + for node in dependencyless: + del node_ins[node] + + # If all nodes are placed, exit topological sorting + if not node_ins: + break + + # Remove depdencyless nodes from node_ins (the set of required incoming nodes) as those dependencyless nodes + # have been placed and their dependency to other nodes is therefore fulfilled + node_ins = {node: incomings - dependencyless for node, incomings in node_ins.items()} + + return graph_topology diff --git a/cyclic_toposort/cyclic_toposort.py b/cyclic_toposort/cyclic_toposort.py new file mode 100644 index 0000000..424ae19 --- /dev/null +++ b/cyclic_toposort/cyclic_toposort.py @@ -0,0 +1,180 @@ +"""Module providing functions for sorting directed cyclic graphs with minimal cyclic edges into topological groups.""" + +# Copyright (c) 2020 Paul Pauls. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import sys + +from cyclic_toposort.acyclic_toposort import acyclic_toposort +from cyclic_toposort.utils import generate_reduced_ins_outs + + +def cyclic_toposort( + edges: set[tuple[int, int]], + start_node: int | None = None, +) -> tuple[list[set[int]], set[tuple[int, int]]]: + """Perform a topological sorting on a potentially cyclic graph, returning a tuple consisting of a graph topology + with the fewest topological groupings and a minimal set of cyclic edges. + + :param edges: A set of tuples where each tuple represents a directed edge (start_node, end_node) in the graph. + :param start_node: An optional node. If provided, any edge leading into this node will be considered as a forced + cyclic edge. + :return: A tuple containing: + - A list of sets representing the topological ordering of nodes. Each set contains nodes at the same depth. The + amount of topological groupings is minimal out of all possible sets of cyclic edges. + - A set of tuples representing the cyclic edges that were identified in the graph and that yielded a graph + topology with the fewest topological groupings. + """ + node_ins: dict[int, set[int]] = {} + node_outs: dict[int, set[int]] = {} + cyclic_edges_forced: set[tuple[int, int]] = set() + + for edge_start, edge_end in edges: + # Don't consider cyclic node edges as not relevant for topological sorting + if edge_start == edge_end: + continue + + # Ensure the nodes exist in the dictionaries + node_ins.setdefault(edge_start, set()) + node_outs.setdefault(edge_end, set()) + + # If start_node is supplied then violating edges are considered as forced cyclic edges + if start_node and start_node == edge_end: + cyclic_edges_forced.add((edge_start, edge_end)) + continue + + # Store the edge_start and edge_end in the node_ins and node_outs dictionaries + node_ins.setdefault(edge_end, set()).add(edge_start) + node_outs.setdefault(edge_start, set()).add(edge_end) + + # Recursively sort the (possibly cyclic) graph represented by the just determined node inputs and outputs, which + # take the potential start_node constraint in consideration. + cyclic_edges = _cyclic_toposort_recursive( + node_ins=node_ins, + node_outs=node_outs, + ) + + # If there are forced cyclic_edges due to a start_node constraint add them to the computed cyclic_edges + if cyclic_edges_forced: + for cyclic_edges_set in cyclic_edges: + cyclic_edges_set.update(cyclic_edges_forced) + + # Determine the topological groupings for each set of minimal cyclic edges and return the graph topology with + # the least amount of topological groupings and its corresponding cyclic edges + graph_topologies = [ + (acyclic_toposort(edges - cyclic_edges_set), cyclic_edges_set) for cyclic_edges_set in cyclic_edges + ] + return min(graph_topologies, key=lambda x: len(x[0])) + + +def _cyclic_toposort_recursive( + node_ins: dict[int, set[int]], + node_outs: dict[int, set[int]], +) -> list[set[tuple[int, int]]]: + """Recursive helper function to perform a topological sorting on a potentially cyclic graph by finding minimal + cyclic edges in the graph represented by the node inputs and outputs. + + :param node_ins: A dictionary mapping each node to a set of nodes that have edges directed towards it. + :param node_outs: A dictionary mapping each node to a set of nodes it directs edges towards. + :returns: A list of sets of tuples, where each tuple represents a cyclic edge in the graph. + """ + cyclic_edges: list[set[tuple[int, int]]] = [set()] + + while True: + #### FORWARD SORTING ########################################################################################### + # Determine nodes with no incoming edges in current state of sorting which therefore can be placed and removed + # from consideration + dependencyless = {node for node, incomings in node_ins.items() if not incomings} + + if not dependencyless: + #### BACKWARD SORTING ###################################################################################### + while True: + # Determine nodes with no outgoing edges in current state of sorting which therefore can be placed and + # removed from consideration + followerless = {node for node, outgoings in node_outs.items() if not outgoings} + + if not followerless: + #### CYCLE RESOLUTION ############################################################################## + min_number_cyclic_edges = sys.maxsize + + # Recreate edge list from current state of node_ins + edges = { + (edge_start, edge_end) for edge_end, incomings in node_ins.items() for edge_start in incomings + } + + # Iteratively and randomly declare more and more edges as cyclic and see how well the resulting + # graph (represented as reduced_node_ins and reduced_node_outs) is sortable. + for reduced_node_ins, reduced_node_outs, forced_cyclic_edges in generate_reduced_ins_outs( + edges=edges, + node_ins=node_ins, + node_outs=node_outs, + ): + # Break if the necessary cyclic edges are higher than the already found minimum number of + # cyclic edges + if len(forced_cyclic_edges) > min_number_cyclic_edges: + break + + # Recursively check for the minimum amount of cyclic edges in the resulting restgraph + reduced_cyclic_edges = _cyclic_toposort_recursive( + node_ins=reduced_node_ins, + node_outs=reduced_node_outs, + ) + + # If a new minimal amount of cyclic edges has been found update the min_number_cyclic_edges + # variables and only save the new min cyclic edges + total_cyclic_edges = len(forced_cyclic_edges) + len(reduced_cyclic_edges[0]) + if total_cyclic_edges < min_number_cyclic_edges: + min_number_cyclic_edges = total_cyclic_edges + cyclic_edges = [ + reduced_cyclic_edges_set.union(forced_cyclic_edges) + for reduced_cyclic_edges_set in reduced_cyclic_edges + ] + # If a set of cyclic edges has been found that has the same size as the current minimum, + # save these cyclic edges as well + elif total_cyclic_edges == min_number_cyclic_edges: + for reduced_cyclic_edges_set in reduced_cyclic_edges: + cyclic_edges.append(reduced_cyclic_edges_set.union(forced_cyclic_edges)) + + return cyclic_edges + #################################################################################################### + + # Remove nodes with no outgoing edges from consideration + for node in followerless: + del node_ins[node] + del node_outs[node] + + # Remove nodes that were placed/removed from consideration of being following nodes of other nodes + node_outs = {node: outgoings - followerless for node, outgoings in node_outs.items()} + ############################################################################################################ + + # Remove nodes with no incoming edges from consideration + for node in dependencyless: + del node_ins[node] + del node_outs[node] + + # Break if all nodes are placed + if not node_ins: + break + + # Remove nodes that were placed/removed from consideration of being necessary nodes of other nodes + node_ins = {node: incomings - dependencyless for node, incomings in node_ins.items()} + ################################################################################################################ + + return cyclic_edges diff --git a/cyclic_toposort/utils.py b/cyclic_toposort/utils.py new file mode 100644 index 0000000..e15aa5f --- /dev/null +++ b/cyclic_toposort/utils.py @@ -0,0 +1,56 @@ +"""Module providing utility functions for the cyclic_toposort package.""" + +# Copyright (c) 2020 Paul Pauls. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +import itertools +from collections.abc import Iterator +from copy import deepcopy + + +def generate_reduced_ins_outs( + edges: set[tuple[int, int]], + node_ins: dict[int, set[int]], + node_outs: dict[int, set[int]], +) -> Iterator[tuple[dict[int, set[int]], dict[int, set[int]], set[tuple[int, int]]]]: + """Randomly select subsets of edges, treat them as cyclic, and yield modified node_ins and node_outs with those + cyclic edges removed. + + :param edges: Set of edges, each represented as a (start_node, end_node) tuple. + :param node_ins: Dictionary mapping nodes to sets of nodes from which they receive edges. + :param node_outs: Dictionary mapping nodes to sets of nodes to which they send edges. + :yield: Iterator for a 3-tuple consisting of + - Modified node_ins with cyclic edges removed. + - Modified node_outs with cyclic edges removed. + - Set of edges treated as cyclic. + """ + # Iterate over subsets of edges of increasing sizes + for n in range(1, len(edges) + 1): + for cyclic_edges in itertools.combinations(edges, n): + modified_ins = deepcopy(node_ins) + modified_outs = deepcopy(node_outs) + + # Remove cyclic edges from modified ins and outs + for edge_start, edge_end in cyclic_edges: + modified_ins[edge_end].discard(edge_start) + modified_outs[edge_start].discard(edge_end) + + yield modified_ins, modified_outs, set(cyclic_edges) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..7775fa4 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,396 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.3.2" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"}, + {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"}, + {file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"}, + {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"}, + {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"}, + {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"}, + {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"}, + {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"}, + {file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"}, + {file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"}, + {file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"}, + {file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"}, + {file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"}, + {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"}, + {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"}, + {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"}, + {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"}, + {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"}, + {file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"}, + {file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"}, + {file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"}, + {file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"}, + {file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"}, + {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"}, + {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"}, + {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"}, + {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"}, + {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"}, + {file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"}, + {file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"}, + {file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"}, + {file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"}, + {file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"}, + {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"}, + {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"}, + {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"}, + {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"}, + {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"}, + {file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"}, + {file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"}, + {file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"}, + {file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"}, + {file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"}, + {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"}, + {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"}, + {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"}, + {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"}, + {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"}, + {file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"}, + {file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"}, + {file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"}, + {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "distlib" +version = "0.3.7" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, + {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.1.3" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, + {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "filelock" +version = "3.12.4" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.12.4-py3-none-any.whl", hash = "sha256:08c21d87ded6e2b9da6728c3dff51baf1dcecf973b768ef35bcbc3447edb9ad4"}, + {file = "filelock-3.12.4.tar.gz", hash = "sha256:2e6f249f1f3654291606e046b09f1fd5eac39b360664c27f5aad072012f8bcbd"}, +] + +[package.extras] +docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-timeout (>=2.1)"] +typing = ["typing-extensions (>=4.7.1)"] + +[[package]] +name = "graphviz" +version = "0.20.1" +description = "Simple Python interface for Graphviz" +optional = false +python-versions = ">=3.7" +files = [ + {file = "graphviz-0.20.1-py3-none-any.whl", hash = "sha256:587c58a223b51611c0cf461132da386edd896a029524ca61a1462b880bf97977"}, + {file = "graphviz-0.20.1.zip", hash = "sha256:8c58f14adaa3b947daf26c19bc1e98c4e0702cdc31cf99153e6f06904d492bf8"}, +] + +[package.extras] +dev = ["flake8", "pep8-naming", "tox (>=3)", "twine", "wheel"] +docs = ["sphinx (>=5)", "sphinx-autodoc-typehints", "sphinx-rtd-theme"] +test = ["coverage", "mock (>=4)", "pytest (>=7)", "pytest-cov", "pytest-mock (>=3)"] + +[[package]] +name = "identify" +version = "2.5.30" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.30-py2.py3-none-any.whl", hash = "sha256:afe67f26ae29bab007ec21b03d4114f41316ab9dd15aa8736a167481e108da54"}, + {file = "identify-2.5.30.tar.gz", hash = "sha256:f302a4256a15c849b91cfcdcec052a8ce914634b2f77ae87dad29cd749f2d88d"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "packaging" +version = "23.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "platformdirs" +version = "3.11.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, + {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, +] + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + +[[package]] +name = "pluggy" +version = "1.3.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, + {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "3.5.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, + {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pytest" +version = "7.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"}, + {file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "setuptools" +version = "68.2.2" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"}, + {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "virtualenv" +version = "20.24.5" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.24.5-py3-none-any.whl", hash = "sha256:b80039f280f4919c77b30f1c23294ae357c4c8701042086e3fc005963e4e537b"}, + {file = "virtualenv-20.24.5.tar.gz", hash = "sha256:e8361967f6da6fbdf1426483bfe9fca8287c242ac0bc30429905721cefbff752"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<4" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.10" +content-hash = "821440d3a5005b95de095e7682a87a90fb3a6e6dd64ad63bf37c82bf3b489d53" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e6c220d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[tool.poetry] +name = "cyclic-toposort" +version = "1.1.0" +description = "A sorting algorithm for directed cyclic graphs that results in a sorting with minimal cyclic edges." +authors = ["Paul Pauls "] +license = "MIT" +readme = "README.md" +packages = [{include = "cyclic_toposort"}] + +[tool.poetry.dependencies] +python = "^3.10" + +[tool.poetry.group.dev.dependencies] +pre-commit = "^3.5.0" +pytest = "^7.4.2" +pytest-cov = "^4.1.0" +pyyaml = "^6.0.1" +graphviz = "^0.20.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +target-version = ["py310", "py311", "py312"] +line-length = 120 + +[tool.docformatter] +wrap-summaries = 120 +wrap-descriptions = 120 + +[tool.ruff] +select = ["ALL"] +target-version = "py310" +line-length = 120 +ignore = [ + "C901", # Ignore rule to check for too complex functions, leaving it to the developer to decide. + "PLR0912", # Ignore rule to check for too many branches in functions. + "PLR0915", # Ignore rule to check for too many statements in functions. + "D205", # Ignore rule to force single line docstring summaries. + "S311", # Ignore rule to prohibit standard pseudo-random generators. + "FBT", # Ignore the flake8-boolean-trap rules as way too strict. +] + +[tool.ruff.per-file-ignores] +"__init__.py" = ["F401"] # Ignore rule to check for unused imports in __init__.py files. +"tests/*" = ["S101"] # Allow asserts in tests as it is the pytest default practice to check conditions with assert. + +[tool.mypy] +strict = true +python_version = "3.10" diff --git a/setup.py b/setup.py deleted file mode 100644 index bbb595d..0000000 --- a/setup.py +++ /dev/null @@ -1,24 +0,0 @@ -import setuptools - -with open("README.md", "r") as readme: - long_description = readme.read() - -setuptools.setup( - name='cyclic-toposort', - version='1.0.0', - author='Paul Pauls', - author_email='mail@paulpauls.de', - description='A sorting algorithm for directed cyclic graphs that results in a sorting with minimal cyclic edges', - long_description=long_description, - long_description_content_type='text/markdown', - url="https://github.com/PaulPauls/cyclic-toposort", - packages=setuptools.find_packages(), - include_package_data=True, - py_modules=['cyclic_toposort'], - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - python_requires='>= 3.0', -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..a6496eb --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Init module for tests.""" diff --git a/tests/acyclic_toposort_test.py b/tests/acyclic_toposort_test.py new file mode 100644 index 0000000..f09ce1a --- /dev/null +++ b/tests/acyclic_toposort_test.py @@ -0,0 +1,12 @@ +"""Tests for the acyclic_toposrt module.""" + +import pytest + +from cyclic_toposort.acyclic_toposort import acyclic_toposort + + +def test_runtimeerror_acyclic_toposort() -> None: + """Test acyclic_toposort with a cyclic graph, expecting a cyclic graph RuntimeError.""" + edges = {(1, 2), (2, 3), (3, 1)} + with pytest.raises(RuntimeError, match="Cyclic graph detected in acyclic_toposort function"): + acyclic_toposort(edges) diff --git a/tests/coverage.xml b/tests/coverage.xml index 7fe09d2..9637052 100644 --- a/tests/coverage.xml +++ b/tests/coverage.xml @@ -1,458 +1,122 @@ - - + + - + /home/ppp/git_projects/cyclic-toposort/cyclic_toposort - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/tests/cyclic_toposort_test.py b/tests/cyclic_toposort_test.py new file mode 100644 index 0000000..17f5b09 --- /dev/null +++ b/tests/cyclic_toposort_test.py @@ -0,0 +1,54 @@ +"""Tests for the cyclic_toposort module.""" +import random +from pathlib import Path + +import yaml +from graphviz import Digraph + +from cyclic_toposort.cyclic_toposort import cyclic_toposort +from tests.utils import bruteforce_toposort, create_random_graph + +TEST_GRAPHS_DIR = "./test_graphs/" +TEST_RESULTS_YAML = "./test_results.yaml" +CYCLIC_NODES_PROBABILITY = 0.2 +START_NODE_PROBABILITY = 0.2 + + +def test_random_graphs_against_bruteforce() -> None: + """Test cyclic_toposort with randomly generated graphs against bruteforced solutions.""" + test_graphs_dir = Path(TEST_GRAPHS_DIR) + test_graphs_dir.resolve() + test_graphs_dir.mkdir(exist_ok=True) + + test_results_yaml = Path(TEST_RESULTS_YAML) + test_results_yaml.resolve() + + test_results: dict[str, dict] = {} # type: ignore[type-arg] + for i in range(100): + test_name = f"test_graph_{i}" + + num_edges = random.randint(8, 16) + cyclic_nodes = random.random() < CYCLIC_NODES_PROBABILITY + edges = create_random_graph(num_edges=num_edges, cyclic_nodes=cyclic_nodes) + start_node = None + if random.random() < START_NODE_PROBABILITY: + start_node = random.choice(list(edges))[0] + + graph = Digraph(graph_attr={"rankdir": "TB"}) + for edge_start, edge_end in edges: + graph.edge(str(edge_start), str(edge_end)) + graph.render(filename=test_name, directory=test_graphs_dir, view=False, cleanup=True, format="svg") + + algorithm_results = cyclic_toposort(edges=edges) + bruteforce_results = bruteforce_toposort(edges=edges) + assert algorithm_results in bruteforce_results + + test_results[test_name] = { + "edges": edges, + "start_node": start_node, + "algorithm_results": algorithm_results, + "bruteforce_results": bruteforce_results, + } + + with test_results_yaml.open("w") as test_results_yaml_file: + yaml.dump(test_results, test_results_yaml_file) diff --git a/tests/test_cyclic_toposort.py b/tests/test_cyclic_toposort.py deleted file mode 100644 index 237f1e1..0000000 --- a/tests/test_cyclic_toposort.py +++ /dev/null @@ -1,163 +0,0 @@ -import os -import time -import random -from graphviz import Digraph - -import cyclic_toposort -import test_utils - - -def test_create_random_graph(): - """ - Creating multiple graphs with random parameters and visualizing them in seperate directoy. - """ - graph_viz_dir = os.path.dirname(os.path.realpath(__file__)) + '/random_test_graphs/' - os.makedirs(graph_viz_dir, exist_ok=True) - - for i in range(100): - num_edges = random.randint(6, 24) - start_node = random.choice([None, random.randint(1, 5)]) - end_node = random.choice([None, random.randint(6, 10)]) - full_cyclic_graph = True if start_node is None and end_node is None else False - cyclic_nodes = random.choice([True, False]) - nodes, edges = test_utils.create_random_graph(num_edges=num_edges, - start_node=start_node, - end_node=end_node, - full_cyclic_graph=full_cyclic_graph, - cyclic_nodes=cyclic_nodes) - - dot = Digraph(graph_attr={'rankdir': 'TB'}) - for (edge_start, edge_end) in edges: - dot.edge(str(edge_start), str(edge_end)) - - dot.render(filename=f'random_test_graph_{i}', directory=graph_viz_dir, view=False, cleanup=True, format='svg') - - -def test_bruteforce_cyclic_graph_topologies(): - """ - Showcase bruteforcing of minimal topologies for random cyclic graphs - """ - num_edges = random.randint(6, 24) - start_node = random.choice([None, random.randint(1, 5)]) - end_node = random.choice([None, random.randint(6, 10)]) - full_cyclic_graph = True if start_node is None and end_node is None else False - cyclic_nodes = random.choice([True, False]) - nodes, edges = test_utils.create_random_graph(num_edges=num_edges, - start_node=start_node, - end_node=end_node, - full_cyclic_graph=full_cyclic_graph, - cyclic_nodes=cyclic_nodes) - - print(f"nodes: {nodes}") - print(f"edges: {edges}") - - t_start = time.time() - bruteforced_cyclic_graph_topologies = test_utils.bruteforce_cyclic_graph_topologies(nodes=nodes, - edges=edges, - start_node=start_node, - end_node=end_node) - t_end = time.time() - print(f"bruteforced cyclic graph topologies: {bruteforced_cyclic_graph_topologies}") - print(f"bruteforce time: {t_end - t_start}\n") - - -def test_cyclic_toposort(): - """""" - t_total_cyclic_toposort = 0 - t_total_cyclic_toposort_groupings = 0 - t_total_cyclic_toposort_bruteforce = 0 - cyclic_toposort_correct_log = True - cyclic_toposort_groupings_correct_log = True - - for i in range(1000): - print(f"Run {i}") - - num_edges = random.randint(8, 16) - start_node = random.choice([None, random.randint(1, 5)]) - end_node = random.choice([None, random.randint(6, 10)]) - full_cyclic_graph = False # Too complex given the high number of random tests - cyclic_nodes = random.choice([True, False]) - nodes, edges = test_utils.create_random_graph(num_edges=num_edges, - start_node=start_node, - end_node=end_node, - full_cyclic_graph=full_cyclic_graph, - cyclic_nodes=cyclic_nodes) - - print(f"nodes: {nodes}") - print(f"edges: {edges}") - print(f"start_node: {start_node}") - print(f"end_node: {end_node}") - - dot = Digraph(graph_attr={'rankdir': 'TB'}) - for (edge_start, edge_end) in edges: - dot.edge(str(edge_start), str(edge_end)) - - dot.render(view=False, cleanup=True, format='svg') - - t_start = time.time() - cyclic_toposort_graph_topology = cyclic_toposort.cyclic_toposort(edges=edges, - start_node=start_node, - end_node=end_node) - t_end = time.time() - print(f"cyclic toposort graph topology: {cyclic_toposort_graph_topology}") - print(f"cyclic toposort time: {t_end - t_start}") - t_total_cyclic_toposort += (t_end - t_start) - - t_start = time.time() - cyclic_toposort_groupings_graph_topology = cyclic_toposort.cyclic_toposort_groupings(edges=edges, - start_node=start_node, - end_node=end_node) - t_end = time.time() - print(f"cyclic toposort groupings graph topology: {cyclic_toposort_groupings_graph_topology}") - print(f"cyclic toposort groupings time: {t_end - t_start}") - t_total_cyclic_toposort_groupings += (t_end - t_start) - - t_start = time.time() - bruteforced_cyclic_graph_topologies = test_utils.bruteforce_cyclic_graph_topologies(nodes=nodes, - edges=edges, - start_node=start_node, - end_node=end_node) - t_end = time.time() - print(f"bruteforced cyclic graph topologies: {bruteforced_cyclic_graph_topologies}") - print(f"bruteforce time: {t_end - t_start}") - t_total_cyclic_toposort_bruteforce += (t_end - t_start) - - cyclic_toposort_correct_flag = True - if not len(cyclic_toposort_graph_topology[1]) == len(bruteforced_cyclic_graph_topologies[0][1]): - cyclic_toposort_correct_flag = False - - for (edge_start, edge_end) in edges: - if edge_start == edge_end or (edge_start, edge_end) in cyclic_toposort_graph_topology[1]: - continue - edge_start_index = None - edge_end_index = None - for index, node in enumerate(cyclic_toposort_graph_topology[0]): - if node == edge_start: - edge_start_index = index - if node == edge_end: - edge_end_index = index - if edge_start_index >= edge_end_index: - cyclic_toposort_correct_flag = False - - print(f"cyclic toposort result correct: {cyclic_toposort_correct_flag}") - cyclic_toposort_correct_log &= cyclic_toposort_correct_flag - - print("cyclic toposort groupings result one of the bruteforced and therefore correct: {}" - .format(cyclic_toposort_groupings_graph_topology in bruteforced_cyclic_graph_topologies)) - cyclic_toposort_groupings_correct_log &= \ - cyclic_toposort_groupings_graph_topology in bruteforced_cyclic_graph_topologies - - print(f"Total time cyclic toposort: {t_total_cyclic_toposort}") - print(f"Total time cyclic toposort groupings: {t_total_cyclic_toposort_groupings}") - print(f"Total time cyclic toposort bruteforce: {t_total_cyclic_toposort_bruteforce}") - - print(f"Cyclic toposort always correct: {cyclic_toposort_correct_log}") - print(f"Cyclic toposort groupings always correct: {cyclic_toposort_groupings_correct_log}") - assert cyclic_toposort_correct_log - assert cyclic_toposort_groupings_correct_log - - -if __name__ == '__main__': - test_create_random_graph() - test_bruteforce_cyclic_graph_topologies() - test_cyclic_toposort() diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index c77e9c1..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,168 +0,0 @@ -import sys -import random -import itertools - - -def create_random_graph(num_edges, - start_node=None, - end_node=None, - full_cyclic_graph=False, - cyclic_nodes=False) -> (list, {(int, int)}): - """ - Create a random graph (nodes and edges) with the specified amount of edges. The random graph can optionally have - an explicitely set start_node or end_node, which has no incoming (or respectively outgoing) edges. If the graph is - set to be fully cyclic then there exist no single node without at least 1 incoming and outgoing connection - :param num_edges: int, number of edges in created random graph - :param start_node: int (optional), node id of a start node that has no incoming connections - :param end_node: int (optional), node id of an end node that has no outgoing connections - :param full_cyclic_graph: bool (optional), if set then all nodes in the created graph have at least 1 incoming and - 1 outgoing connection - :param cyclic_nodes: bool (optional), if set then an edge can have the same start and end node - :return: list of node ids, set of all edges as tuples - """ - # No full cyclic graph can be created if start or end node is supplied - assert (full_cyclic_graph is True and start_node is None and end_node is None) or full_cyclic_graph is False - - nodes = list() - edges = set() - - first_node = 1 if start_node is None else start_node - second_node = 2 if end_node is None else end_node - - nodes.append(first_node) - nodes.append(second_node) - edges.add((first_node, second_node)) - if full_cyclic_graph: - edges.add((second_node, first_node)) - - while len(edges) < num_edges: - possible_new_node = max(nodes) + 1 - possible_nodes = nodes + [possible_new_node] - if cyclic_nodes: - edge_start = random.choice(possible_nodes) - if edge_start == possible_new_node: - edge_end = random.choice(nodes) - else: - edge_end = random.choice(possible_nodes) - else: - edge_start, edge_end = random.sample(possible_nodes, k=2) - - # Skip connection creation if the edge would create an incoming connection for the start node or an outgoing - # connection for the end node if either is supplied - if (start_node and edge_end == start_node) or (end_node and edge_start == end_node): - continue - - if (edge_start, edge_end) not in edges: - edges.add((edge_start, edge_end)) - - if full_cyclic_graph and edge_start == possible_new_node: - full_cyclic_edge_start = random.choice(nodes) - edges.add((full_cyclic_edge_start, edge_start)) - - if full_cyclic_graph and edge_end == possible_new_node: - full_cyclic_edge_end = random.choice(nodes) - edges.add((edge_end, full_cyclic_edge_end)) - - if edge_start == possible_new_node or edge_end == possible_new_node: - nodes.append(possible_new_node) - - return nodes, edges - - -def create_groupings(inputs): - """ - Create all possible groupings of an iterable as generator. - :param inputs: iterable - :return: generator of all possible groupings - """ - for n in range(1, len(inputs) + 1): - for split_indices in itertools.combinations(range(1, len(inputs)), n - 1): - grouping = [] - prev_split_index = None - for split_index in itertools.chain(split_indices, [None]): - group = set(inputs[prev_split_index:split_index]) - grouping.append(group) - prev_split_index = split_index - yield grouping - - -def bruteforce_cyclic_graph_topologies(nodes, edges, start_node=None, end_node=None) -> [([{int}], {(int, int)})]: - """ - Bruteforce all graph topologies with a minimal amount of cyclic edges and a minimal amount of seperate topology - groupings by creating all possible permutations of node orderings and groupings and saving only those with minimal - size. The bruteforcing can be accelerated if a desired start and/or end node for the minimal topologies is supplied. - Edges that start and end in the same node are not considered cyclic. Return all minimal graph topologies with their - amount of cyclic edges. - :param nodes: list of all nodes - :param edges: iterable of 2-tuples of the start node and end node of each edge - :param start_node: int (optional), node id of a start node that should be in first grouping of graph topology - :param end_node: int (optional), node id of an end node that should be in last grouping of graph topology - :return: minimal cyclic and minimal grouped graph topologies and their corresponding cyclic edges - """ - assert (start_node is None or start_node in nodes) and (end_node is None or end_node in nodes) - - minimal_cyclic_graph_topologies = list() - minimal_graph_topology_groupings = sys.maxsize - minimal_number_cyclic_edges = sys.maxsize - previously_checked_graph_topologies = list() - - # Remove single node cyclic edges and copy nodes in case it has been passed as reference - edges = [(edge_start, edge_end) for (edge_start, edge_end) in edges if edge_start != edge_end] - nodes_copy = nodes.copy() - - # Remove and later insert fixed start and end node in order to always ensure that start node is in first grouping - # and end node is in last grouping if they are supplied. - if start_node: - nodes_copy.remove(start_node) - if end_node: - nodes_copy.remove(end_node) - - r_nodes_iter = itertools.permutations(nodes_copy) - - for ordering in r_nodes_iter: - - if start_node: - ordering = (start_node,) + ordering - if end_node: - ordering = ordering + (end_node,) - - for graph_topology in create_groupings(ordering): - # If the current graph topology has been checked before in another premutation, skip check - if graph_topology in previously_checked_graph_topologies: - continue - else: - previously_checked_graph_topologies.append(graph_topology) - - cyclic_edges = set() - for edge_start, edge_end in edges: - edge_start_index = None - edge_end_index = None - - for level_index in range(len(graph_topology)): - if edge_start in graph_topology[level_index]: - edge_start_index = level_index - - if edge_end in graph_topology[level_index]: - edge_end_index = level_index - - if edge_start_index is not None and edge_end_index is not None: - break - - if edge_start_index >= edge_end_index: - cyclic_edges.add((edge_start, edge_end)) - - if len(cyclic_edges) > minimal_number_cyclic_edges: - break - - if len(cyclic_edges) < minimal_number_cyclic_edges: - minimal_cyclic_graph_topologies = [(graph_topology, cyclic_edges)] - minimal_graph_topology_groupings = len(graph_topology) - minimal_number_cyclic_edges = len(cyclic_edges) - elif len(cyclic_edges) == minimal_number_cyclic_edges: - if len(graph_topology) < minimal_graph_topology_groupings: - minimal_cyclic_graph_topologies = [(graph_topology, cyclic_edges)] - minimal_graph_topology_groupings = len(graph_topology) - elif len(graph_topology) == minimal_graph_topology_groupings: - minimal_cyclic_graph_topologies.append((graph_topology, cyclic_edges)) - - return minimal_cyclic_graph_topologies diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..83c3259 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,127 @@ +"""Module for utility functions used in tests.""" +import itertools +import random +import sys +from collections.abc import Iterator + + +def create_random_graph( + num_edges: int, + cyclic_nodes: bool = False, +) -> set[tuple[int, int]]: + """Generate a random graph with the given number of edges. + + :param num_edges: The desired number of edges in the graph. Minimum of 1. + :param cyclic_nodes: If True, allows edges to loop back to their starting node; if False, edges are strictly between + distinct nodes. + :return: A set of edges represented as tuples where each tuple contains two integers corresponding to node IDs. + """ + nodes = [1, 2] + edges = {(1, 2)} + + while len(edges) < num_edges: + possible_new_node = max(nodes) + 1 + possible_nodes = [*nodes, possible_new_node] + if cyclic_nodes: + edge_start = random.choice(possible_nodes) + edge_end = random.choice(nodes) if edge_start == possible_new_node else random.choice(possible_nodes) + else: + edge_start, edge_end = random.sample(possible_nodes, k=2) + + new_edge = (edge_start, edge_end) + if new_edge not in edges: + edges.add(new_edge) + + if possible_new_node in new_edge: + nodes.append(possible_new_node) + + return edges + + +def bruteforce_toposort( + edges: set[tuple[int, int]], + start_node: int | None = None, +) -> list[tuple[list[set[int]], set[tuple[int, int]]]]: + """Determine all possible graph topologies for a given set of edges that yield a minimal number of necessary cyclic + edges and a minimal number of necessary topological groupings. + + :param edges: A set of tuples where each tuple represents a directed edge (start_node, end_node) in the graph. + :param start_node: An optional node. If provided, any edge leading into this node will be considered as a forced + cyclic edge. + :return: A list of tuples consisting of + - A graph topology with the minimum number of topological groupings. + - A minimal set of edges that are necessary to be considered cyclic in order to make the graph acyclic. + """ + minimal_graph_topologies = [] + minimal_graph_topology_groupings = sys.maxsize + minimal_cyclic_edges = sys.maxsize + previously_checked_graph_topologies = [] + + # Remove single node cyclic edges + edges = {(edge_start, edge_end) for (edge_start, edge_end) in edges if edge_start != edge_end} + nodes = {node for edge in edges for node in edge} + + if start_node: + nodes.remove(start_node) + + for node_ordering in itertools.permutations(nodes): + if start_node: + node_ordering = (start_node, *node_ordering) # noqa: PLW2901 + + for graph_topology in create_groupings(node_ordering): + if graph_topology in previously_checked_graph_topologies: + continue + + previously_checked_graph_topologies.append(graph_topology) + cyclic_edges = set() + for edge_start, edge_end in edges: + edge_start_index = None + edge_end_index = None + + # Determine the level of each node in the graph topology + for level_index in range(len(graph_topology)): + if edge_start in graph_topology[level_index]: + edge_start_index = level_index + + if edge_end in graph_topology[level_index]: + edge_end_index = level_index + + if edge_start_index is not None and edge_end_index is not None: + break + + # Determine if the edge is cyclic in the current graph topology + if edge_start_index >= edge_end_index: # type: ignore[operator] + cyclic_edges.add((edge_start, edge_end)) + + if len(cyclic_edges) > minimal_cyclic_edges: + break + + if len(cyclic_edges) < minimal_cyclic_edges: + minimal_graph_topologies = [(graph_topology, cyclic_edges)] + minimal_graph_topology_groupings = len(graph_topology) + minimal_cyclic_edges = len(cyclic_edges) + elif len(cyclic_edges) == minimal_cyclic_edges: + if len(graph_topology) < minimal_graph_topology_groupings: + minimal_graph_topologies = [(graph_topology, cyclic_edges)] + minimal_graph_topology_groupings = len(graph_topology) + elif len(graph_topology) == minimal_graph_topology_groupings: + minimal_graph_topologies.append((graph_topology, cyclic_edges)) + + return minimal_graph_topologies + + +def create_groupings(inputs: list[int] | tuple[int, ...]) -> Iterator[list[set[int]]]: + """Generate all possible groupings of an iterable. + + :param inputs: Iterable (e.g., list) of elements + :return: generator yielding lists of sets of grouped nodes + """ + for n in range(1, len(inputs) + 1): + for split_indices in itertools.combinations(range(1, len(inputs)), n - 1): + grouping = [] + prev_split_index = None + for split_index in itertools.chain(split_indices, [None]): + group = set(inputs[prev_split_index:split_index]) + grouping.append(group) + prev_split_index = split_index + yield grouping diff --git a/tests/utils_test.py b/tests/utils_test.py new file mode 100644 index 0000000..992b849 --- /dev/null +++ b/tests/utils_test.py @@ -0,0 +1,46 @@ +"""Tests for the utils module.""" + +from cyclic_toposort.utils import generate_reduced_ins_outs + + +def test_basic_functionality() -> None: + """Test basic functionality of generate_reduced_ins_outs() with a small graph.""" + edges = {(1, 3), (2, 3), (3, 4)} + node_ins: dict[int, set[int]] = {1: set(), 2: set(), 3: {1, 2}, 4: {3}} + node_outs: dict[int, set[int]] = {1: {3}, 2: {3}, 3: {4}, 4: set()} + + results = list(generate_reduced_ins_outs(edges=edges, node_ins=node_ins, node_outs=node_outs)) + expected = [ + ({1: set(), 2: set(), 3: {1}, 4: {3}}, {1: {3}, 2: set(), 3: {4}, 4: set()}, {(2, 3)}), + ({1: set(), 2: set(), 3: {2}, 4: {3}}, {1: set(), 2: {3}, 3: {4}, 4: set()}, {(1, 3)}), + ({1: set(), 2: set(), 3: {1, 2}, 4: set()}, {1: {3}, 2: {3}, 3: set(), 4: set()}, {(3, 4)}), + ({1: set(), 2: set(), 3: set(), 4: {3}}, {1: set(), 2: set(), 3: {4}, 4: set()}, {(2, 3), (1, 3)}), + ({1: set(), 2: set(), 3: {1}, 4: set()}, {1: {3}, 2: set(), 3: set(), 4: set()}, {(2, 3), (3, 4)}), + ({1: set(), 2: set(), 3: {2}, 4: set()}, {1: set(), 2: {3}, 3: set(), 4: set()}, {(1, 3), (3, 4)}), + ({1: set(), 2: set(), 3: set(), 4: set()}, {1: set(), 2: set(), 3: set(), 4: set()}, {(2, 3), (1, 3), (3, 4)}), + ] + + assert results == expected + + +def test_empty_edges() -> None: + """Make sure the function works with empty edges.""" + edges: set[tuple[int, int]] = set() + node_ins: dict[int, set[int]] = {} + node_outs: dict[int, set[int]] = {} + + results = list(generate_reduced_ins_outs(edges=edges, node_ins=node_ins, node_outs=node_outs)) + + assert results == [] + + +def test_input_not_modified() -> None: + """Make sure the input dictionaries are not modified after calling the function.""" + edges = {(1, 2), (2, 3), (3, 5), (3, 6), (4, 1), (4, 5), (4, 6), (5, 2), (5, 7), (6, 1), (8, 6)} + node_ins: dict[int, set[int]] = {1: {4, 6}, 2: {1, 5}, 3: {2}, 4: set(), 5: {3, 4}, 6: {3, 4, 8}, 7: {5}, 8: set()} + node_outs: dict[int, set[int]] = {1: {2}, 2: {3}, 3: {5, 6}, 4: {1, 5, 6}, 5: {2, 7}, 6: {1}, 7: set(), 8: {6}} + + _ = list(generate_reduced_ins_outs(edges=edges, node_ins=node_ins, node_outs=node_outs)) + + assert node_ins == {1: {4, 6}, 2: {1, 5}, 3: {2}, 4: set(), 5: {3, 4}, 6: {3, 4, 8}, 7: {5}, 8: set()} + assert node_outs == {1: {2}, 2: {3}, 3: {5, 6}, 4: {1, 5, 6}, 5: {2, 7}, 6: {1}, 7: set(), 8: {6}}