├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── dag ├── __init__.py └── six_subset.py ├── setup.py └── tests ├── __init__.py └── init_tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | 56 | # Editor specific 57 | *.ropeproject/ 58 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | - "2.6" 6 | - "3.3" 7 | - "3.4" 8 | - "3.5" 9 | - "3.6" 10 | 11 | script: nosetests 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Travis Thieman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | py-dag 2 | ====== 3 | 4 | Python implementation of directed acyclic graph 5 | 6 | ![](https://travis-ci.org/thieman/py-dag.svg?branch=master) 7 | 8 | This library is [largely provided as-is](https://github.com/thieman/py-dag/issues/17#issuecomment-193371191). Breaking changes may happen without warning. If you choose to use it, you should peg your dependencies to a specific version. 9 | -------------------------------------------------------------------------------- /dag/__init__.py: -------------------------------------------------------------------------------- 1 | from copy import copy, deepcopy 2 | from collections import deque 3 | 4 | from . import six_subset as six 5 | 6 | try: 7 | from collections import OrderedDict 8 | except ImportError: 9 | from ordereddict import OrderedDict 10 | 11 | 12 | class DAGValidationError(Exception): 13 | pass 14 | 15 | 16 | class DAG(object): 17 | """ Directed acyclic graph implementation. """ 18 | 19 | def __init__(self): 20 | """ Construct a new DAG with no nodes or edges. """ 21 | self.reset_graph() 22 | 23 | def add_node(self, node_name, graph=None): 24 | """ Add a node if it does not exist yet, or error out. """ 25 | if not graph: 26 | graph = self.graph 27 | if node_name in graph: 28 | raise KeyError('node %s already exists' % node_name) 29 | graph[node_name] = set() 30 | 31 | def add_node_if_not_exists(self, node_name, graph=None): 32 | try: 33 | self.add_node(node_name, graph=graph) 34 | except KeyError: 35 | pass 36 | 37 | def delete_node(self, node_name, graph=None): 38 | """ Deletes this node and all edges referencing it. """ 39 | if not graph: 40 | graph = self.graph 41 | if node_name not in graph: 42 | raise KeyError('node %s does not exist' % node_name) 43 | graph.pop(node_name) 44 | 45 | for node, edges in six.iteritems(graph): 46 | if node_name in edges: 47 | edges.remove(node_name) 48 | 49 | def delete_node_if_exists(self, node_name, graph=None): 50 | try: 51 | self.delete_node(node_name, graph=graph) 52 | except KeyError: 53 | pass 54 | 55 | def add_edge(self, ind_node, dep_node, graph=None): 56 | """ Add an edge (dependency) between the specified nodes. """ 57 | if not graph: 58 | graph = self.graph 59 | if ind_node not in graph or dep_node not in graph: 60 | raise KeyError('one or more nodes do not exist in graph') 61 | test_graph = deepcopy(graph) 62 | test_graph[ind_node].add(dep_node) 63 | is_valid, message = self.validate(test_graph) 64 | if is_valid: 65 | graph[ind_node].add(dep_node) 66 | else: 67 | raise DAGValidationError() 68 | 69 | def delete_edge(self, ind_node, dep_node, graph=None): 70 | """ Delete an edge from the graph. """ 71 | if not graph: 72 | graph = self.graph 73 | if dep_node not in graph.get(ind_node, []): 74 | raise KeyError('this edge does not exist in graph') 75 | graph[ind_node].remove(dep_node) 76 | 77 | def rename_edges(self, old_task_name, new_task_name, graph=None): 78 | """ Change references to a task in existing edges. """ 79 | if not graph: 80 | graph = self.graph 81 | for node, edges in graph.items(): 82 | 83 | if node == old_task_name: 84 | graph[new_task_name] = copy(edges) 85 | del graph[old_task_name] 86 | 87 | else: 88 | if old_task_name in edges: 89 | edges.remove(old_task_name) 90 | edges.add(new_task_name) 91 | 92 | def predecessors(self, node, graph=None): 93 | """ Returns a list of all predecessors of the given node """ 94 | if graph is None: 95 | graph = self.graph 96 | return [key for key in graph if node in graph[key]] 97 | 98 | def downstream(self, node, graph=None): 99 | """ Returns a list of all nodes this node has edges towards. """ 100 | if graph is None: 101 | graph = self.graph 102 | if node not in graph: 103 | raise KeyError('node %s is not in graph' % node) 104 | return list(graph[node]) 105 | 106 | def all_downstreams(self, node, graph=None): 107 | """Returns a list of all nodes ultimately downstream 108 | of the given node in the dependency graph, in 109 | topological order.""" 110 | if graph is None: 111 | graph = self.graph 112 | nodes = [node] 113 | nodes_seen = set() 114 | i = 0 115 | while i < len(nodes): 116 | downstreams = self.downstream(nodes[i], graph) 117 | for downstream_node in downstreams: 118 | if downstream_node not in nodes_seen: 119 | nodes_seen.add(downstream_node) 120 | nodes.append(downstream_node) 121 | i += 1 122 | return list( 123 | filter( 124 | lambda node: node in nodes_seen, 125 | self.topological_sort(graph=graph) 126 | ) 127 | ) 128 | 129 | def all_leaves(self, graph=None): 130 | """ Return a list of all leaves (nodes with no downstreams) """ 131 | if graph is None: 132 | graph = self.graph 133 | return [key for key in graph if not graph[key]] 134 | 135 | def from_dict(self, graph_dict): 136 | """ Reset the graph and build it from the passed dictionary. 137 | 138 | The dictionary takes the form of {node_name: [directed edges]} 139 | """ 140 | 141 | self.reset_graph() 142 | for new_node in six.iterkeys(graph_dict): 143 | self.add_node(new_node) 144 | for ind_node, dep_nodes in six.iteritems(graph_dict): 145 | if not isinstance(dep_nodes, list): 146 | raise TypeError('dict values must be lists') 147 | for dep_node in dep_nodes: 148 | self.add_edge(ind_node, dep_node) 149 | 150 | def reset_graph(self): 151 | """ Restore the graph to an empty state. """ 152 | self.graph = OrderedDict() 153 | 154 | def ind_nodes(self, graph=None): 155 | """ Returns a list of all nodes in the graph with no dependencies. """ 156 | if graph is None: 157 | graph = self.graph 158 | 159 | dependent_nodes = set( 160 | node for dependents in six.itervalues(graph) for node in dependents 161 | ) 162 | return [node for node in graph.keys() if node not in dependent_nodes] 163 | 164 | def validate(self, graph=None): 165 | """ Returns (Boolean, message) of whether DAG is valid. """ 166 | graph = graph if graph is not None else self.graph 167 | if len(self.ind_nodes(graph)) == 0: 168 | return (False, 'no independent nodes detected') 169 | try: 170 | self.topological_sort(graph) 171 | except ValueError: 172 | return (False, 'failed topological sort') 173 | return (True, 'valid') 174 | 175 | def topological_sort(self, graph=None): 176 | """ Returns a topological ordering of the DAG. 177 | 178 | Raises an error if this is not possible (graph is not valid). 179 | """ 180 | if graph is None: 181 | graph = self.graph 182 | 183 | in_degree = {} 184 | for u in graph: 185 | in_degree[u] = 0 186 | 187 | for u in graph: 188 | for v in graph[u]: 189 | in_degree[v] += 1 190 | 191 | queue = deque() 192 | for u in in_degree: 193 | if in_degree[u] == 0: 194 | queue.appendleft(u) 195 | 196 | l = [] 197 | while queue: 198 | u = queue.pop() 199 | l.append(u) 200 | for v in graph[u]: 201 | in_degree[v] -= 1 202 | if in_degree[v] == 0: 203 | queue.appendleft(v) 204 | 205 | if len(l) == len(graph): 206 | return l 207 | else: 208 | raise ValueError('graph is not acyclic') 209 | 210 | def size(self): 211 | return len(self.graph) 212 | -------------------------------------------------------------------------------- /dag/six_subset.py: -------------------------------------------------------------------------------- 1 | """ 2 | This includes a subset of six. 3 | 4 | This was done since there is only a need for a couple of functions and want to 5 | avoid including dependencies in the library. 6 | 7 | Its important to note that the only dependency as of writing is used for 8 | Python 2.6. While this can potentially change, the scope of the library 9 | is small enough that it is very likely that it will not. 10 | 11 | Also refer notes of original library author: 12 | 13 | https://github.com/thieman/py-dag/pull/23#issuecomment-328617005 14 | """ 15 | # Copyright (c) 2010-2017 Benjamin Peterson 16 | # 17 | # Permission is hereby granted, free of charge, to any person obtaining a copy 18 | # of this software and associated documentation files (the "Software"), to deal 19 | # in the Software without restriction, including without limitation the rights 20 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 21 | # copies of the Software, and to permit persons to whom the Software is 22 | # furnished to do so, subject to the following conditions: 23 | # 24 | # The above copyright notice and this permission notice shall be included in all 25 | # copies or substantial portions of the Software. 26 | # 27 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 28 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 29 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 30 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 31 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 32 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 33 | # SOFTWARE. 34 | 35 | import operator 36 | import sys 37 | 38 | # Useful for very coarse version differentiation. 39 | PY2 = sys.version_info[0] == 2 40 | PY3_AND_UP = sys.version_info[0] >= 3 41 | 42 | if PY3_AND_UP: 43 | def iterkeys(d, **kw): 44 | return iter(d.keys(**kw)) 45 | 46 | def itervalues(d, **kw): 47 | return iter(d.values(**kw)) 48 | 49 | def iteritems(d, **kw): 50 | return iter(d.items(**kw)) 51 | 52 | def iterlists(d, **kw): 53 | return iter(d.lists(**kw)) 54 | 55 | viewkeys = operator.methodcaller("keys") 56 | 57 | viewvalues = operator.methodcaller("values") 58 | 59 | viewitems = operator.methodcaller("items") 60 | else: 61 | def iterkeys(d, **kw): 62 | return d.iterkeys(**kw) 63 | 64 | def itervalues(d, **kw): 65 | return d.itervalues(**kw) 66 | 67 | def iteritems(d, **kw): 68 | return d.iteritems(**kw) 69 | 70 | def iterlists(d, **kw): 71 | return d.iterlists(**kw) 72 | 73 | viewkeys = operator.methodcaller("viewkeys") 74 | 75 | viewvalues = operator.methodcaller("viewvalues") 76 | 77 | viewitems = operator.methodcaller("viewitems") 78 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from setuptools import setup 4 | 5 | python_ver = sys.version_info 6 | 7 | install_requires = [] 8 | 9 | # Do not use the attribute names as they are not available in Python 2.6. 10 | if python_ver[0] == 2 and python_ver[1] < 7: 11 | install_requires += ['ordereddict'] 12 | 13 | setup(name='py-dag', 14 | version='3.0.1', 15 | description='Directed acyclic graph implementation', 16 | url='https://github.com/thieman/py-dag', 17 | author='Travis Thieman', 18 | author_email='travis.thieman@gmail.com', 19 | license='MIT', 20 | packages=['dag'], 21 | install_requires=install_requires, 22 | test_suite='nose.collector', 23 | tests_require=['nose'] + install_requires, 24 | zip_safe=False) 25 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thieman/py-dag/5b5eed396c930751576bdf0d45907a665aac000b/tests/__init__.py -------------------------------------------------------------------------------- /tests/init_tests.py: -------------------------------------------------------------------------------- 1 | """ Tests on the DAG implementation """ 2 | 3 | from nose import with_setup 4 | from nose.tools import nottest, raises 5 | from dag import DAG, DAGValidationError 6 | 7 | dag = None 8 | 9 | 10 | @nottest 11 | def blank_setup(): 12 | global dag 13 | dag = DAG() 14 | 15 | 16 | @nottest 17 | def start_with_graph(): 18 | global dag 19 | dag = DAG() 20 | dag.from_dict({'a': ['b', 'c'], 21 | 'b': ['d'], 22 | 'c': ['b'], 23 | 'd': []}) 24 | 25 | 26 | @with_setup(blank_setup) 27 | def test_add_node(): 28 | dag.add_node('a') 29 | assert dag.graph == {'a': set()} 30 | 31 | 32 | @with_setup(blank_setup) 33 | def test_add_edge(): 34 | dag.add_node('a') 35 | dag.add_node('b') 36 | dag.add_edge('a', 'b') 37 | assert dag.graph == {'a': set('b'), 'b': set()} 38 | 39 | 40 | @with_setup(blank_setup) 41 | def test_from_dict(): 42 | dag.from_dict({'a': ['b', 'c'], 43 | 'b': ['d'], 44 | 'c': ['d'], 45 | 'd': []}) 46 | assert dag.graph == {'a': set(['b', 'c']), 47 | 'b': set('d'), 48 | 'c': set('d'), 49 | 'd': set()} 50 | 51 | 52 | @with_setup(blank_setup) 53 | def test_reset_graph(): 54 | dag.add_node('a') 55 | assert dag.graph == {'a': set()} 56 | dag.reset_graph() 57 | assert dag.graph == {} 58 | 59 | 60 | @with_setup(start_with_graph) 61 | def test_ind_nodes(): 62 | assert dag.ind_nodes(dag.graph) == ['a'] 63 | 64 | 65 | @with_setup(blank_setup) 66 | def test_topological_sort(): 67 | dag.from_dict({'a': [], 68 | 'b': ['a'], 69 | 'c': ['b']}) 70 | assert dag.topological_sort() == ['c', 'b', 'a'] 71 | 72 | 73 | @with_setup(start_with_graph) 74 | def test_successful_validation(): 75 | assert dag.validate()[0] is True 76 | 77 | 78 | @raises(DAGValidationError) 79 | @with_setup(blank_setup) 80 | def test_failed_validation(): 81 | dag.from_dict({'a': ['b'], 82 | 'b': ['a']}) 83 | 84 | 85 | @with_setup(start_with_graph) 86 | def test_downstream(): 87 | assert set(dag.downstream('a', dag.graph)) == set(['b', 'c']) 88 | 89 | 90 | @with_setup(start_with_graph) 91 | def test_all_downstreams(): 92 | assert dag.all_downstreams('a') == ['c', 'b', 'd'] 93 | assert dag.all_downstreams('b') == ['d'] 94 | assert dag.all_downstreams('d') == [] 95 | 96 | 97 | @with_setup(start_with_graph) 98 | def test_all_downstreams_pass_graph(): 99 | dag2 = DAG() 100 | dag2.from_dict({'a': ['c'], 101 | 'b': ['d'], 102 | 'c': ['d'], 103 | 'd': []}) 104 | assert dag.all_downstreams('a', dag2.graph) == ['c', 'd'] 105 | assert dag.all_downstreams('b', dag2.graph) == ['d'] 106 | assert dag.all_downstreams('d', dag2.graph) == [] 107 | 108 | 109 | @with_setup(start_with_graph) 110 | def test_predecessors(): 111 | assert set(dag.predecessors('a')) == set([]) 112 | assert set(dag.predecessors('b')) == set(['a', 'c']) 113 | assert set(dag.predecessors('c')) == set(['a']) 114 | assert set(dag.predecessors('d')) == set(['b']) 115 | 116 | 117 | @with_setup(start_with_graph) 118 | def test_all_leaves(): 119 | assert dag.all_leaves() == ['d'] 120 | 121 | 122 | @with_setup(start_with_graph) 123 | def test_size(): 124 | assert dag.size() == 4 125 | dag.delete_node('a') 126 | assert dag.size() == 3 127 | --------------------------------------------------------------------------------