diff --git a/.gitignore b/.gitignore index 9e41ecf..ee548d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,9 @@ -test* .idea .idea img out* *frames* -packaging.txt # Byte-compiled / optimized / DLL files __pycache__/ @@ -632,6 +630,3 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ - -!/packaging.txt -!/packaging.txt diff --git a/packaging.txt b/packaging.txt deleted file mode 100644 index ffbac37..0000000 --- a/packaging.txt +++ /dev/null @@ -1,5 +0,0 @@ -python3 setup.py sdist bdist_wheel - -twine upload --repository-url https://test.pypi.org/legacy/ dist/* - -twine upload dist/* diff --git a/tests/test_edge.py b/tests/test_edge.py new file mode 100644 index 0000000..28e7648 --- /dev/null +++ b/tests/test_edge.py @@ -0,0 +1,19 @@ +from visualiser import Edge, Node + +""" + Test case class for nodes +""" + + +class TestEdge: + def test_create_new_edge(self): + # Create Node + A = Node('A', color='red', style='filled') + B = Node('B', label='B label', color='red', style='filled') + + # Create Edge + edge = Edge(A, B, label='Test Label') + + assert edge.label == 'Test Label' + assert edge.source_node.label == 'A' + assert edge.source_node == A diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..3b746cd --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,35 @@ +from visualiser import Node, Edge, Graph + + +class TestGraph: + def test_normal_graph_flow(self): + graph = Graph('hello') + + # Create Node + A = Node('A', color='red') + B = Node('B', color='green') + C = Node('C', color='yellow') + + # Add node + graph.add_node(A) + graph.add_node(B) + graph.add_node(C) + + # Make edge + edge1 = Edge(A, B) + edge2 = Edge(A, C) + + # Add node and graph to edge + graph.add_edge(edge1) + graph.add_edge(edge2) + + assert graph.to_string() == 'digraph G {\nA [label="A", color="red"];\nB [label="B", color="green"];\nC [label="C", color="yellow"];\nA -> B [];\nA -> C [];\n}' + + def test_node_methods(self): + pass + + def test_edge_methods(self): + pass + + def test_mutations(self): + pass diff --git a/tests/test_node.py b/tests/test_node.py new file mode 100644 index 0000000..669ffcd --- /dev/null +++ b/tests/test_node.py @@ -0,0 +1,40 @@ +from visualiser import * + +""" + Test case class for nodes +""" + + +class TestNode: + def test_create_new_node(self): + node = Node('bishal', 'Bishal label', color='red', style='filled') + + assert node.label == 'Bishal label' + assert node.name == 'bishal' + assert node.get_attribute('color') == 'red' + assert node.get_attribute('style') == 'filled' + assert node.to_string() == 'bishal [label="Bishal label", color="red", style="filled"];' + + def test_set_attributes(self): + node = Node('bishal', 'Bishal label', color='red', style='filled') + assert node.get_attribute('color') == 'red' + node.set_attribute('color', 'green') + assert node.get_attribute('color') == 'green' + node.set_attribute('background', 'grey') + assert node.get_attribute('background') == 'grey' + assert node.to_string() == 'bishal [label="Bishal label", color="green", style="filled", background="grey"];' + + def test_rename_name_label(self): + node = Node('bishal', 'Bishal label', color='red', style='filled') + + node.set_attribute('color', 'green') + + assert node.label == 'Bishal label' + node.label = 'Bishal renamed label' + assert node.label == 'Bishal renamed label' + + assert node.name == 'bishal' + node.name = 'Bishal renamed' + assert node.name == 'Bishal renamed' + + assert node.to_string() == 'Bishal renamed [label="Bishal renamed label", color="green", style="filled"];' diff --git a/visualiser/__init__.py b/visualiser/__init__.py index 41ebf4c..d93fddc 100644 --- a/visualiser/__init__.py +++ b/visualiser/__init__.py @@ -1,3 +1,11 @@ - # Version of the recursion-visualiser -__version__ = "1.0.1" \ No newline at end of file +__version__ = "1.0.1" + +# Maintain this order to avoid circular import +# because we are using Node inside Edge for type annotations. +from .node import Node +from .edge import Edge +from .graph import Graph +from .animation import Animation + +from visualiser import * diff --git a/visualiser/animation.py b/visualiser/animation.py new file mode 100644 index 0000000..7e60cb8 --- /dev/null +++ b/visualiser/animation.py @@ -0,0 +1,68 @@ +import copy + +import pydot +import glob +import os +import shutil +import imageio + +from visualiser import Node, Edge, Graph + + +class Animation: + def __init__(self): + self.frames = [] + self._frame_id = 0 + + def next_step(self, frame): + self.frames.append(copy.deepcopy(frame)) + self._frame_id += 1 + + @staticmethod + def make_directory(): + if not os.path.exists("frames"): + os.makedirs("frames") + + def next_frame(self): + pass + + def previous_frame(self): + pass + + def get_frame(self, frame_id): + return self.frames[frame_id] + + def get_frames(self): + return self.frames[::] + + def write_frame(self, frame_id): + frame = self.get_frame(frame_id) + + dot_graph, *rest = pydot.graph_from_dot_data(frame) + dot_graph.write_png(f"frames/temp_{frame_id}.png") + + def write_file(self): + self.make_directory() + + for frame_id in range(len(self.get_frames())): + self.write_frame(frame_id) + + def write_gif(self, name="out.gif", delay=3): + self.write_file() + + images = [] + + # sort frames images in ascending order to number in image filename + # image filename: frames/temp_1.png + sorted_images = sorted( + glob.glob("frames/*.png"), + key=lambda fn: int(fn.split("_")[1].split(".")[0]) + ) + + for filename in sorted_images: + images.append(imageio.imread(filename)) + + imageio.mimsave(name, images, duration=delay) + # Delete temporary directory + shutil.rmtree("frames") + diff --git a/visualiser/edge.py b/visualiser/edge.py new file mode 100644 index 0000000..7057a41 --- /dev/null +++ b/visualiser/edge.py @@ -0,0 +1,123 @@ +import copy +from typing import Union + +from visualiser import Node + + +class Edge: + + def __init__(self, source_node: Union[Node, str], destination_node: Union[Node, str], label: str = '', + **attrs: str) -> None: + self._source_node = Node(source_node) if isinstance(source_node, str) else copy.deepcopy(source_node) + self._destination_node = Node(destination_node) if isinstance(destination_node, str) else copy.deepcopy( + destination_node) + + self._name = f"{self._source_node.name} -> {self._destination_node.name}" + self._label = label + self._attrs = attrs + + @property + def label(self) -> str: + """ + Get label for edge. + :return: str + """ + return self._label + + @label.setter + def label(self, _label: str) -> None: + """ + Set label for edge. + :param _label: str + """ + self._label = _label + + @property + def name(self) -> str: + """ + Get name for edge. + :return: str + """ + return self._name + + @name.setter + def name(self, _name: str) -> None: + """ + Set label for edge. + :param _name: str + """ + self._name = _name + + @property + def source_node(self) -> Node: + """ + Get source node + :return: Node + """ + return self._source_node + + @source_node.setter + def source_node(self, _source_node: Node) -> None: + """ + Set source node. + :param _source_node: Node + """ + self._source_node = _source_node + + @property + def destination_node(self) -> Node: + """ + Get destination node. + :return: Node + """ + return self._destination_node + + @destination_node.setter + def destination_node(self, _destination_node: Node) -> None: + """ + Sets destination node. + :param _destination_node: Node + """ + self._destination_node = _destination_node + + def get_attribute(self, key: str) -> str: + """ + Get attribute for edge. + :param key: str + :return: The value of attribute with key + """ + return self._attrs.get(key) + + def set_attribute(self, key: str, value: str) -> None: + """ + Set attribute for edge + :param key: str + :param value: str + """ + self._attrs[key] = value + + def remove_attribute(self, key: str) -> None: + """ + Remove attribute from edge. + :param key: str + """ + if self._attrs.get(key): + del self._attrs[key] + + def get_attributes_string(self) -> str: + """ + Get attributes string enclosed in [] + :return: + """ + if len(self._label) == 0: + return '[' + ', '.join([f'{key}="{value}"' for key, value in self._attrs.items()]) + '];' + + return '[' + f'label="{self._label}", ' + ', '.join( + [f'{key}="{value}"' for key, value in self._attrs.items()]) + '];' + + def to_string(self) -> str: + """ + Converts dot string equivalent of the current edge. + :return: Str + """ + return f'{self._source_node.name} -> {self._destination_node.name} {self.get_attributes_string()}' diff --git a/visualiser/graph.py b/visualiser/graph.py new file mode 100644 index 0000000..a9338b0 --- /dev/null +++ b/visualiser/graph.py @@ -0,0 +1,102 @@ +import copy + +from visualiser import Node, Edge + + +class Graph: + def __init__(self, name='', **attrs): + self._name = name + self._attrs = attrs + + self._nodes = [] + self._edges = [] + + def get_node(self, name): + filtered_nodes = list(filter(lambda node: node.name == name, self._nodes)) + + return filtered_nodes[0] if len(filtered_nodes) else None + + def remove_node(self, _node): + if isinstance(_node, Node): + self._nodes = list(filter(lambda node: node.name != _node.name, self._nodes)) + return + + self._nodes = list(filter(lambda node: node.name != _node, self._nodes)) + + def add_node(self, node): + if self.get_node(node.name) is not None: + self.remove_node(node) + + self._nodes.append(copy.deepcopy(node)) + + def set_node_attributes(self, node, **attrs): + for key, value in attrs.items(): + self.set_node_attribute(node, key, value) + + def set_node_attribute(self, _node, key, value): + def _set_attribute(name): + node = self.get_node(name) + if node: + node.set_attribute(key, value) + + if isinstance(_node, Node): + _set_attribute(_node.name) + return + + _set_attribute(_node) + + def remove_node_attribute(self, _node, key): + def _remove_attribute(name): + node = self.get_node(name) + if node: + node.remove_attribute(key) + + if isinstance(_node, Node): + _remove_attribute(_node.name) + return + + _remove_attribute(_node) + + def add_edge(self, edge): + if self.get_edge(edge.name) is not None: + self.remove_edge(edge) + + # TODO: Check if node used in node exist in graph. If not create one + self._edges.append(copy.deepcopy(edge)) + + def get_edge(self, name): + filtered_edges = list(filter(lambda edge: edge.name == name, self._edges)) + + return filtered_edges[0] if len(filtered_edges) else None + + def remove_edge(self, _edge): + if isinstance(_edge, Edge): + self._edges = list(filter(lambda edge: edge.name != _edge.name, self._edges)) + return + + self._edges = list(filter(lambda edge: edge.name != _edge, self._edges)) + + def set_edge_label(self, name, value): + edge = self.get_edge(name) + if edge: + edge.label = value + + def highlight_node(self, name, color): + self.set_node_attribute(name, 'color', color) + + def highlight_edge(self, name, color): + edge = self.get_edge(name) + if edge: + edge.set_attribute('color', color) + + def reverse_edge_orientation(self, name): + pass + + def get_nodes_string(self): + return "\n".join(list(map(lambda node: node.to_string(), self._nodes))) + + def get_edges_string(self): + return "\n".join(list(map(lambda edge: edge.to_string(), self._edges))) + + def to_string(self): + return "digraph G {\n" + f"{self.get_nodes_string()}\n" + f"{self.get_edges_string()}\n" + "}" diff --git a/visualiser/node.py b/visualiser/node.py new file mode 100644 index 0000000..d5469cd --- /dev/null +++ b/visualiser/node.py @@ -0,0 +1,50 @@ + +class Node: + def __init__(self, name, label='', **attrs): + # TODO: Add support for initialization of attributes from attribute dict + self._name = name + self._attrs = attrs + + if len(label) == 0: + self._label = name + else: + self._label = label + + def __repr__(self): + return f"Node('{self.name}')" + + def __eq__(self, other): + return self.to_string() == other.to_string() + + @property + def name(self): + return self._name + + @name.setter + def name(self, _name): + self._name = _name + + @property + def label(self): + return self._label + + @label.setter + def label(self, _label): + self._label = _label + + def get_attribute(self, key): + return self._attrs.get(key) + + def set_attribute(self, key, value): + self._attrs[key] = value + + def remove_attribute(self, key): + del self._attrs[key] + + def get_attributes_string(self): + # TODO: Check if no any attributes and labels to avoid '[]' + return '[' + f'label="{self._label}", ' + ', '.join( + [f'{key}="{value}"' for key, value in self._attrs.items()]) + '];' + + def to_string(self): + return f'{self.name} {self.get_attributes_string()}'