From 5017e859ace732871e9922593c133d3cbb7c8398 Mon Sep 17 00:00:00 2001 From: Vipul Cariappa Date: Fri, 13 Oct 2023 10:37:43 +0530 Subject: [PATCH] Added `show_diagram` method for Push Down Automata --- automata/pda/dpda.py | 10 +++ automata/pda/npda.py | 11 +++ automata/pda/pda.py | 183 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 203 insertions(+), 1 deletion(-) diff --git a/automata/pda/dpda.py b/automata/pda/dpda.py index 319a3442..88182eec 100644 --- a/automata/pda/dpda.py +++ b/automata/pda/dpda.py @@ -53,6 +53,16 @@ def __init__( acceptance_mode=acceptance_mode, ) + def iter_transitions( + self, + ) -> Generator[Tuple[DPDAStateT, DPDAStateT, Tuple[str, str, str]], None, None]: + return ( + (from_, to_, (input_symbol, stack_symbol, "".join(stack_push))) + for from_, input_lookup in self.transitions.items() + for input_symbol, stack_lookup in input_lookup.items() + for stack_symbol, (to_, stack_push) in stack_lookup.items() + ) + def _validate_transition_invalid_symbols( self, start_state: DPDAStateT, paths: DPDATransitionsT ) -> None: diff --git a/automata/pda/npda.py b/automata/pda/npda.py index be43ab3b..8bdf6f13 100644 --- a/automata/pda/npda.py +++ b/automata/pda/npda.py @@ -55,6 +55,17 @@ def __init__( acceptance_mode=acceptance_mode, ) + def iter_transitions( + self, + ) -> Generator[Tuple[NPDAStateT, NPDAStateT, Tuple[str, str, str]], None, None]: + return ( + (from_, to_, (input_symbol, stack_symbol, "".join(stack_push))) + for from_, input_lookup in self.transitions.items() + for input_symbol, stack_lookup in input_lookup.items() + for stack_symbol, op_ in stack_lookup.items() + for (to_, stack_push) in op_ + ) + def _validate_transition_invalid_symbols( self, start_state: NPDAStateT, paths: NPDATransitionsT ) -> None: diff --git a/automata/pda/pda.py b/automata/pda/pda.py index 5fa5bf3b..8a2fe995 100644 --- a/automata/pda/pda.py +++ b/automata/pda/pda.py @@ -2,7 +2,12 @@ """Classes and methods for working with all pushdown automata.""" import abc -from typing import AbstractSet, Literal +import os +import pathlib +import random +import uuid +from collections import defaultdict +from typing import AbstractSet, Generator, Literal, Optional, Tuple, Union import automata.base.exceptions as exceptions import automata.pda.exceptions as pda_exceptions @@ -10,10 +15,21 @@ from automata.pda.configuration import PDAConfiguration from automata.pda.stack import PDAStack +# Optional imports for use with visual functionality +try: + import coloraide + import pygraphviz as pgv +except ImportError: + _visual_imports = False +else: + _visual_imports = True + PDAStateT = AutomatonStateT PDATransitionsT = AutomatonTransitionsT PDAAcceptanceModeT = Literal["final_state", "empty_stack", "both"] +LayoutMethod = Literal["neato", "dot", "twopi", "circo", "fdp", "nop"] + class PDA(Automaton, metaclass=abc.ABCMeta): """An abstract base class for pushdown automata.""" @@ -24,6 +40,171 @@ class PDA(Automaton, metaclass=abc.ABCMeta): initial_stack_symbol: str acceptance_mode: PDAAcceptanceModeT + @staticmethod + def _get_state_name(state_data: PDAStateT) -> str: + """ + Get an string representation of a state. This is used for displaying and + uses `str` for any unsupported python data types. + """ + if isinstance(state_data, str): + if state_data == "": + return "λ" + + return state_data + + elif isinstance(state_data, (frozenset, tuple)): + inner = ", ".join(PDA._get_state_name(sub_data) for sub_data in state_data) + if isinstance(state_data, frozenset): + if state_data: + return "{" + inner + "}" + else: + return "∅" + + elif isinstance(state_data, tuple): + return "(" + inner + ")" + + return str(state_data) + + @staticmethod + def _get_edge_name( + input_symbol: str = "", stack_top_symbol: str = "", stack_push: str = "" + ) -> str: + return ( + ("ε" if input_symbol == "" else str(input_symbol)) + + ", " + + ("ε" if stack_top_symbol == "" else str(stack_top_symbol)) + + " | " + + ("ε" if stack_push == "" else str(stack_push)) + ) + + @abc.abstractmethod + def iter_transitions( + self, + ) -> Generator[Tuple[PDAStateT, PDAStateT, Tuple[str, str, str]], None, None]: + """ + Iterate over all transitions in the automaton. Each transition is a tuple + of the form (from_state, to_state, (input_symbol, stack_top_symbol, stack_push_symbols)) + """ + + def show_diagram( + self, + path: Union[str, os.PathLike, None] = None, + *, + layout_method: LayoutMethod = "dot", + horizontal: bool = True, + reverse_orientation: bool = False, + fig_size: Union[Tuple[float, float], Tuple[float], None] = None, + font_size: float = 14.0, + arrow_size: float = 0.85, + state_separation: float = 0.5, + ) -> pgv.AGraph: + """ + Generates the graph associated with the given PDA. + Args: + input_str (str, optional): String list of input symbols. Defaults to None. + - path (str or os.PathLike, optional): Path to output file. If + None, the output will not be saved. + - horizontal (bool, optional): Direction of node layout. Defaults + to True. + - reverse_orientation (bool, optional): Reverse direction of node + layout. Defaults to False. + - fig_size (tuple, optional): Figure size. Defaults to None. + - font_size (float, optional): Font size. Defaults to 14.0. + - arrow_size (float, optional): Arrow head size. Defaults to 0.85. + - state_separation (float, optional): Node distance. Defaults to 0.5. + Returns: + AGraph corresponding to the given automaton. + """ + + if not _visual_imports: + raise ImportError( + "Missing visualization packages; " + "please install coloraide and pygraphviz." + ) + + # Defining the graph. + graph = pgv.AGraph(strict=False, directed=True) + + if fig_size is not None: + graph.graph_attr.update(size=", ".join(map(str, fig_size))) + + graph.graph_attr.update(ranksep=str(state_separation)) + font_size_str = str(font_size) + arrow_size_str = str(arrow_size) + + if horizontal: + rankdir = "RL" if reverse_orientation else "LR" + else: + rankdir = "BT" if reverse_orientation else "TB" + + graph.graph_attr.update(rankdir=rankdir) + + # we use a random uuid to make sure that the null node has a + # unique id to avoid colliding with other states. + # To be able to set the random seed, took code from: + # https://nathanielknight.ca/articles/consistent_random_uuids_in_python.html + null_node = str( + uuid.UUID(bytes=bytes(random.getrandbits(8) for _ in range(16)), version=4) + ) + graph.add_node( + null_node, + label="", + tooltip=".", + shape="point", + fontsize=font_size_str, + ) + initial_node = self._get_state_name(self.initial_state) + graph.add_edge( + null_node, + initial_node, + tooltip="->" + initial_node, + arrowsize=arrow_size_str, + ) + + nonfinal_states = map(self._get_state_name, self.states - self.final_states) + final_states = map(self._get_state_name, self.final_states) + graph.add_nodes_from(nonfinal_states, shape="circle", fontsize=font_size_str) + graph.add_nodes_from(final_states, shape="doublecircle", fontsize=font_size_str) + + is_edge_drawn = defaultdict(lambda: False) + + edge_labels = defaultdict(list) + for from_state, to_state, symbol in self.iter_transitions(): + if is_edge_drawn[from_state, to_state, symbol]: + continue + + from_node = self._get_state_name(from_state) + to_node = self._get_state_name(to_state) + label = self._get_edge_name(*symbol) + edge_labels[from_node, to_node].append(label) + + for (from_node, to_node), labels in edge_labels.items(): + graph.add_edge( + from_node, + to_node, + label="\n".join(sorted(labels)), + arrowsize=arrow_size_str, + fontsize=font_size_str, + ) + + # Set layout + graph.layout(prog=layout_method) + + # Write diagram to file. PNG, SVG, etc. + if path is not None: + save_path_final: pathlib.Path = pathlib.Path(path) + + format = ( + save_path_final.suffix.split(".")[1] if save_path_final.suffix else None + ) + + graph.draw( + path=save_path_final, + format=format, + ) + + return graph + def _validate_transition_invalid_input_symbols( self, start_state: PDAStateT, input_symbol: str ) -> None: