diff --git a/automata/base/automaton.py b/automata/base/automaton.py index 2675fe6c..cfabdc5c 100644 --- a/automata/base/automaton.py +++ b/automata/base/automaton.py @@ -27,6 +27,33 @@ class Automaton(metaclass=abc.ABCMeta): transitions: AutomatonTransitionsT input_symbols: AbstractSet[str] + @staticmethod + def _get_state_name(state_data: Any) -> str: + """ + Get a 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( + Automaton._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) + def __init__(self, **kwargs: Any) -> None: if not global_config.allow_mutable_automata: for attr_name, attr_value in kwargs.items(): diff --git a/automata/base/exceptions.py b/automata/base/exceptions.py index 8341b98c..95b8c4e4 100644 --- a/automata/base/exceptions.py +++ b/automata/base/exceptions.py @@ -87,3 +87,9 @@ class InfiniteLanguageException(AutomatonException): """The operation cannot be performed because the language is infinite""" pass + + +class DiagramException(AutomatonException): + """The diagram cannot be produced""" + + pass diff --git a/automata/base/utils.py b/automata/base/utils.py index f94526ce..fead648a 100644 --- a/automata/base/utils.py +++ b/automata/base/utils.py @@ -1,12 +1,40 @@ #!/usr/bin/env python3 """Miscellaneous utility functions and classes.""" +from __future__ import annotations +import os +import pathlib +import random +import uuid from collections import defaultdict from itertools import count, tee, zip_longest -from typing import Any, Callable, Dict, Generic, Iterable, List, Set, Tuple, TypeVar +from typing import ( + Any, + Callable, + Dict, + Generic, + Iterable, + List, + Literal, + Set, + Tuple, + TypeVar, + Union, +) from frozendict import frozendict +# Optional imports for use with visual functionality +try: + import pygraphviz as pgv +except ImportError: + _visual_imports = False +else: + _visual_imports = True + + +LayoutMethod = Literal["neato", "dot", "twopi", "circo", "fdp", "nop"] + def freeze_value(value: Any) -> Any: """ @@ -41,6 +69,72 @@ def get_renaming_function(counter: count) -> Callable[[Any], int]: return defaultdict(counter.__next__).__getitem__ +def create_unique_random_id() -> str: + # To be able to set the random seed, took code from: + # https://nathanielknight.ca/articles/consistent_random_uuids_in_python.html + return str( + uuid.UUID(bytes=bytes(random.getrandbits(8) for _ in range(16)), version=4) + ) + + +def create_graph( + horizontal: bool = True, + reverse_orientation: bool = False, + fig_size: Union[Tuple[float, float], Tuple[float], None] = None, + state_separation: float = 0.5, +) -> pgv.AGraph: + """Creates and returns a graph object + Args: + - 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. + - state_separation (float, optional): Node distance. Defaults to 0.5. + Returns: + AGraph with the given configuration. + """ + 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)) + + if horizontal: + rankdir = "RL" if reverse_orientation else "LR" + else: + rankdir = "BT" if reverse_orientation else "TB" + + graph.graph_attr.update(rankdir=rankdir) + + return graph + + +def save_graph( + graph: pgv.AGraph, + path: Union[str, os.PathLike], +) -> None: + """Write `graph` to file given by `path`. PNG, SVG, etc. + Returns the same graph.""" + + 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, + ) + + T = TypeVar("T") diff --git a/automata/fa/fa.py b/automata/fa/fa.py index c3b17d4c..7f74d437 100644 --- a/automata/fa/fa.py +++ b/automata/fa/fa.py @@ -4,13 +4,16 @@ import abc import os -import pathlib -import random -import uuid from collections import defaultdict -from typing import Any, Dict, Generator, List, Literal, Optional, Set, Tuple, Union +from typing import Any, Dict, Generator, List, Optional, Set, Tuple, Union from automata.base.automaton import Automaton, AutomatonStateT +from automata.base.utils import ( + LayoutMethod, + create_graph, + create_unique_random_id, + save_graph, +) # Optional imports for use with visual functionality try: @@ -23,7 +26,6 @@ FAStateT = AutomatonStateT -LayoutMethod = Literal["neato", "dot", "twopi", "circo", "fdp", "nop"] class FA(Automaton, metaclass=abc.ABCMeta): @@ -31,31 +33,6 @@ class FA(Automaton, metaclass=abc.ABCMeta): __slots__ = tuple() - @staticmethod - def _get_state_name(state_data: FAStateT) -> 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(FA._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(symbol: str) -> str: return "ε" if symbol == "" else str(symbol) @@ -67,6 +44,10 @@ def iter_transitions(self) -> Generator[Tuple[FAStateT, FAStateT, str], None, No of the form (from_state, to_state, symbol) """ + raise NotImplementedError( + f"iter_transitions is not implemented for {self.__class__}" + ) + def show_diagram( self, input_str: Optional[str] = None, @@ -83,7 +64,7 @@ def show_diagram( """ Generates the graph associated with the given DFA. Args: - input_str (str, optional): String list of input symbols. Defaults to None. + - 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 @@ -105,29 +86,16 @@ def show_diagram( ) # 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 = create_graph( + horizontal, reverse_orientation, fig_size, state_separation + ) - 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" + # create unique id to avoid colliding with other states + null_node = create_unique_random_id() - 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="", @@ -200,18 +168,9 @@ def show_diagram( # Set layout graph.layout(prog=layout_method) - # Write diagram to file. PNG, SVG, etc. + # Write diagram to file 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, - ) + save_graph(graph, path) return graph diff --git a/automata/pda/configuration.py b/automata/pda/configuration.py index 7fc6b90b..71564317 100644 --- a/automata/pda/configuration.py +++ b/automata/pda/configuration.py @@ -2,6 +2,7 @@ """Classes and methods for working with PDA configurations.""" from dataclasses import dataclass +from typing import Any from automata.base.automaton import AutomatonStateT from automata.pda.stack import PDAStack @@ -27,3 +28,14 @@ def __repr__(self) -> str: return "{}({!r}, {!r}, {!r})".format( self.__class__.__name__, self.state, self.remaining_input, self.stack ) + + def __eq__(self, other: Any) -> bool: + """Return True if two PDAConfiguration are equivalent""" + if not isinstance(other, PDAConfiguration): + return NotImplemented + + return ( + self.state == other.state + and self.remaining_input == other.remaining_input + and self.stack == other.stack + ) diff --git a/automata/pda/dpda.py b/automata/pda/dpda.py index 319a3442..c5695026 100644 --- a/automata/pda/dpda.py +++ b/automata/pda/dpda.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 """Classes and methods for working with deterministic pushdown automata.""" -from typing import AbstractSet, Generator, Mapping, Optional, Set, Tuple, Union +from typing import AbstractSet, Generator, List, Mapping, Optional, Set, Tuple, Union import automata.base.exceptions as exceptions import automata.pda.exceptions as pda_exceptions import automata.pda.pda as pda +from automata.base.utils import pairwise from automata.pda.configuration import PDAConfiguration from automata.pda.stack import PDAStack @@ -53,6 +54,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: @@ -151,6 +162,31 @@ def _get_next_configuration(self, old_config: PDAConfiguration) -> PDAConfigurat ) return new_config + def _get_input_path( + self, input_str: str + ) -> Tuple[List[Tuple[PDAConfiguration, PDAConfiguration]], bool]: + """ + Calculate the path taken by input. + + Args: + input_str (str): The input string to run on the DPDA. + + Returns: + Tuple[List[Tuple[PDAConfiguration, PDAConfiguration]], bool]: A list + of all transitions taken in each step and a boolean indicating + whether the DPDA accepted the input. + + """ + + state_history = list(self.read_input_stepwise(input_str)) + + path = list(pairwise(state_history)) + + last_state = state_history[-1] if state_history else self.initial_state + accepted = last_state in self.final_states + + return path, accepted + def read_input_stepwise( self, input_str: str ) -> Generator[PDAConfiguration, None, None]: diff --git a/automata/pda/npda.py b/automata/pda/npda.py index be43ab3b..07c03038 100644 --- a/automata/pda/npda.py +++ b/automata/pda/npda.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 """Classes and methods for working with nondeterministic pushdown automata.""" -from typing import AbstractSet, Generator, Mapping, Set, Tuple, Union +from typing import AbstractSet, Generator, List, Mapping, Set, Tuple, Union import automata.base.exceptions as exceptions import automata.pda.pda as pda +from automata.base.utils import pairwise from automata.pda.configuration import PDAConfiguration from automata.pda.stack import PDAStack @@ -16,6 +17,8 @@ ] NPDATransitionsT = Mapping[NPDAStateT, NPDAPathT] +InputPathListT = List[Tuple[PDAConfiguration, PDAConfiguration]] + class NPDA(pda.PDA): """A nondeterministic pushdown automaton.""" @@ -55,6 +58,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: @@ -111,6 +125,40 @@ def _get_next_configurations( new_configs.add(new_config) return new_configs + def _get_input_path( + self, input_str: str + ) -> Tuple[List[Tuple[PDAConfiguration, PDAConfiguration]], bool]: + """ + Calculate the path taken by input. + + Args: + input_str (str): The input string to run on the NPDA. + + Returns: + Tuple[List[Tuple[PDAConfiguration, PDAConfiguration]], bool]: A list + of all transitions taken in each step and a boolean indicating + whether the NPDA accepted the input. + + """ + + steps = list(self.read_input_stepwise(input_str)) + + path: List[PDAConfiguration] = [steps.pop().pop()] + + accepted = path[0] in self.final_states + + for step in reversed(steps): + if len(step) == 1: + path.append(step.pop()) + continue + + for curr_step in step: + if path[-1] in self._get_next_configurations(curr_step): + path.append(curr_step) + break + + return list(pairwise(reversed(path))), accepted + def read_input_stepwise( self, input_str: str ) -> Generator[Set[PDAConfiguration], None, None]: diff --git a/automata/pda/pda.py b/automata/pda/pda.py index 5fa5bf3b..ada4048e 100644 --- a/automata/pda/pda.py +++ b/automata/pda/pda.py @@ -1,18 +1,47 @@ #!/usr/bin/env python3 """Classes and methods for working with all pushdown automata.""" +from __future__ import annotations import abc -from typing import AbstractSet, Literal +import os +from collections import defaultdict +from typing import ( + AbstractSet, + Any, + DefaultDict, + Generator, + List, + Literal, + Optional, + Tuple, + Union, +) import automata.base.exceptions as exceptions import automata.pda.exceptions as pda_exceptions from automata.base.automaton import Automaton, AutomatonStateT, AutomatonTransitionsT +from automata.base.utils import ( + LayoutMethod, + create_graph, + create_unique_random_id, + save_graph, +) 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"] +EdgeDrawnDictT = DefaultDict[Tuple[Any, Any, Tuple[str, str, str]], bool] class PDA(Automaton, metaclass=abc.ABCMeta): @@ -24,6 +53,319 @@ class PDA(Automaton, metaclass=abc.ABCMeta): initial_stack_symbol: str acceptance_mode: PDAAcceptanceModeT + @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)) + ) + + @staticmethod + def _get_symbol_configuration( + from_state: PDAConfiguration, to_state: PDAConfiguration + ) -> Tuple[str, str, str]: + if ( + from_state.remaining_input == to_state.remaining_input + or len(from_state.remaining_input) == 0 + ): + input_symbol = "" + else: + input_symbol = from_state.remaining_input[0] + + stack_top_symbol = from_state.stack.top() + + if len(from_state.stack) == len(to_state.stack) + 1: + stack_push_symbols = "" + else: + stack_push_symbols = "".join(to_state.stack[len(from_state.stack) - 1 :]) + + return (input_symbol, stack_top_symbol, stack_push_symbols[::-1]) + + @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)) + """ + + raise NotImplementedError( + f"iter_transitions is not implemented for {self.__class__}" + ) + + def show_diagram( + self, + input_str: Optional[str] = None, + with_machine: bool = True, + with_stack: bool = True, + 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. + - with_machine (bool, optional): Constructs the diagram with states and + transitions. Ignored if `input_str` is None. Default to True. + - with_stack (bool, optional): Constructs the diagram with stack and its + operations. Ignored if `input_str` is None. Default to True. + - 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 = create_graph( + horizontal, reverse_orientation, fig_size, state_separation + ) + + font_size_str = str(font_size) + arrow_size_str = str(arrow_size) + + is_edge_drawn: EdgeDrawnDictT = defaultdict(lambda: False) + if input_str is not None: + if not (with_machine or with_stack): + raise exceptions.DiagramException( + "Both with_machine and with_stack cannot be False." + " This will produce a empty diagram" + ) + + input_path, is_accepted = self._get_input_path(input_str) + + start_color = coloraide.Color("#ff0") + end_color = ( + coloraide.Color("#0f0") if is_accepted else coloraide.Color("#f00") + ) + + if with_machine: + # initialize diagram with all states + self._add_states_diagram(graph, arrow_size_str, font_size_str) + + # add required transitions to show execution of the + # PDA for the given input string + self._create_transitions_for_input_diagram( + graph, + input_path, + is_edge_drawn, + start_color, + end_color, + arrow_size_str, + font_size_str, + ) + + # add all the necessary transitions between states + self._add_transitions_diagram( + graph, is_edge_drawn, arrow_size_str, font_size_str + ) + + if with_stack: + # add the stack transitions + self._create_stack_diagram( + input_path, + graph, + start_color, + end_color, + font_size_str, + arrow_size_str, + ) + else: + # initialize diagram with all states + self._add_states_diagram(graph, arrow_size_str, font_size_str) + + # add all the necessary transitions between states + self._add_transitions_diagram( + graph, is_edge_drawn, arrow_size_str, font_size_str + ) + + # Set layout + graph.layout(prog=layout_method) + + # Write diagram to file + if path is not None: + save_graph(graph, path) + + return graph + + def _create_stack_diagram( + self, + input_path: List[Tuple[PDAConfiguration, PDAConfiguration]], + graph: pgv.AGraph, + start_color: coloraide.Color, + end_color: coloraide.Color, + font_size: str, + arrow_size: str, + ) -> None: + """ + Constructs stack for all the transitions in the `input_path` and + adds the constructed stacks into `graph`. Returns the same `graph` + """ + from_node = input_path[0][0] + label = " | ".join(reversed(from_node.stack)) + graph.add_node( + from_node, + label=f" | {label}", + shape="record", + arrowsize=arrow_size, + fontsize=font_size, + ) + + interpolation = coloraide.Color.interpolate( + [start_color, end_color], space="srgb" + ) + + for i, (c, n) in enumerate(input_path, start=1): + from_node = n + label = " | ".join(reversed(from_node.stack)) + + color = interpolation(i / len(input_path)) + + graph.add_node( + from_node, + label=f" | {label}", + shape="record", + arrowsize=arrow_size, + fontsize=font_size, + ) + graph.add_edge( + c, + n, + label=f"<[#{i}]>", + arrowsize=arrow_size, + fontsize=font_size, + color=color.to_string(hex=True), + penwidth="2.5", + ) + + return graph + + def _create_transitions_for_input_diagram( + self, + graph: pgv.AGraph, + input_path: List[Tuple[PDAConfiguration, PDAConfiguration]], + edge_drawn: EdgeDrawnDictT, + start_color: coloraide.Color, + end_color: coloraide.Color, + arrow_size: str, + font_size: str, + ) -> None: + """ + Add transitions to show execution of the PDA for the given input string + """ + + interpolation = coloraide.Color.interpolate( + [start_color, end_color], space="srgb" + ) + + # find all transitions in the finite state machine with traversal. + for transition_index, (from_state, to_state) in enumerate(input_path, start=1): + color = interpolation(transition_index / len(input_path)) + + symbol = self._get_symbol_configuration(from_state, to_state) + label = self._get_edge_name(*symbol) + + edge_drawn[from_state.state, to_state.state, symbol] = True + graph.add_edge( + self._get_state_name(from_state.state), + self._get_state_name(to_state.state), + label=f"<{label} [#{transition_index}]>", + arrowsize=arrow_size, + fontsize=font_size, + color=color.to_string(hex=True), + penwidth="2.5", + ) + + def _add_transitions_diagram( + self, + graph: pgv.AGraph, + is_edge_drawn: EdgeDrawnDictT, + arrow_size: str, + font_size: str, + ) -> None: + """ + Add transitions to between states + """ + + 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, + fontsize=font_size, + ) + + def _add_states_diagram( + self, + graph: pgv.AGraph, + arrow_size: str, + font_size: str, + ) -> None: + """ + Add all the states of the PDA + """ + + # create unique id to avoid colliding with other states + null_node = create_unique_random_id() + + graph.add_node( + null_node, + label="", + tooltip=".", + shape="point", + fontsize=font_size, + ) + initial_node = self._get_state_name(self.initial_state) + graph.add_edge( + null_node, + initial_node, + tooltip="->" + initial_node, + arrowsize=arrow_size, + ) + + 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) + graph.add_nodes_from(final_states, shape="doublecircle", fontsize=font_size) + def _validate_transition_invalid_input_symbols( self, start_state: PDAStateT, input_symbol: str ) -> None: @@ -66,6 +408,16 @@ def _validate_transition_invalid_symbols( ) -> None: pass + @abc.abstractmethod + def _get_input_path( + self, input_str: str + ) -> Tuple[List[Tuple[PDAConfiguration, PDAConfiguration]], bool]: + """Calculate the path taken by input.""" + + raise NotImplementedError( + f"_get_input_path is not implemented for {self.__class__}" + ) + def validate(self) -> None: """Return True if this PDA is internally consistent.""" for start_state, paths in self.transitions.items(): diff --git a/automata/pda/stack.py b/automata/pda/stack.py index 7f534263..6bd0bbf2 100644 --- a/automata/pda/stack.py +++ b/automata/pda/stack.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Iterator, Sequence, Tuple +from typing import Any, Iterator, Sequence, Tuple @dataclass(frozen=True) @@ -46,13 +46,24 @@ def __len__(self) -> int: return len(self.stack) def __iter__(self) -> Iterator[str]: - """Return an interator for the stack.""" + """Return an iterator for the stack.""" return iter(self.stack) - def __getitem__(self, key: int) -> str: + def __getitem__(self, key: int | slice) -> str | Sequence[str]: """Return the stack element at the given index""" return self.stack[key] + def __reversed__(self) -> Iterator[str]: + """Return an iterator for the stack in reversed order""" + return reversed(self.stack) + def __repr__(self) -> str: """Return a string representation of the stack.""" return "{}({})".format(self.__class__.__name__, self.stack) + + def __eq__(self, other: Any) -> bool: + """Return True if two PDAConfiguration are equivalent""" + if not isinstance(other, PDAStack): + return NotImplemented + + return self.stack == other.stack diff --git a/docs/base-exception-classes.md b/docs/base-exception-classes.md index 66ce8851..7552aff3 100644 --- a/docs/base-exception-classes.md +++ b/docs/base-exception-classes.md @@ -78,6 +78,9 @@ language is empty. Raised if the attempted operation cannot be performed because the associated language is infinite. +## class DiagramException(AutomatonException) +Raised if a diagram cannot be produced for an automaton. + ------ [Table of Contents](README.md) diff --git a/docs/pda/class-pda.md b/docs/pda/class-pda.md index 6823b468..996861d1 100644 --- a/docs/pda/class-pda.md +++ b/docs/pda/class-pda.md @@ -5,6 +5,22 @@ The `PDA` class is an abstract base class from which all pushdown automata inherit. It can be found under `automata/pda/pda.py`. +The `PDA` class has the following abstract methods: + +## PDA.show_diagram(self, input_str = None, with_machine = True, with_stack = True, path = None): + +Constructs and returns a pygraphviz `AGraph` corresponding to this PDA. If `input_str` is +set, then shows execution of the PDA on `input_str`. `with_machine` and `with_stack` +flags can be used to construct the state machine and transitions only or the stack and +its operations only. They are ignored if `input_str` is None. If `path` is +set, then an image of the diagram is written to the corresponding file. Other +customization options are available, see function signature for more +details. + +```python +pda.show_diagram(path='./pda.png') +``` + ## Subclasses ### [DPDA (Deterministic Pushdown Automaton)](class-dpda.md) diff --git a/tests/test_dpda.py b/tests/test_dpda.py index 52c3004a..97355d0f 100644 --- a/tests/test_dpda.py +++ b/tests/test_dpda.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Classes and functions for testing the behavior of DPDAs.""" +import os + from frozendict import frozendict import automata.base.exceptions as exceptions @@ -319,3 +321,132 @@ def test_empty_dpda(self) -> None: acceptance_mode="both", ) self.assertTrue(dpda.accepts_input("")) + + def test_show_diagram(self) -> None: + """Should construct the diagram for a DPDA""" + graph = self.dpda.show_diagram() + node_names = {node.get_name() for node in graph.nodes()} + self.assertTrue(set(self.dpda.states).issubset(node_names)) + self.assertEqual(len(self.dpda.states) + 1, len(node_names)) + + for state in self.dpda.states: + node = graph.get_node(state) + expected_shape = ( + "doublecircle" if state in self.dpda.final_states else "circle" + ) + self.assertEqual(node.attr["shape"], expected_shape) + + expected_transitions = { + ("q0", "a, 0 | 10", "q1"), + ("q1", "a, 1 | 11", "q1"), + ("q1", "b, 1 | ε", "q2"), + ("q2", "b, 1 | ε", "q2"), + ("q2", "ε, 0 | 0", "q3"), + } + seen_transitions = { + (edge[0], edge.attr["label"], edge[1]) for edge in graph.edges() + } + self.assertTrue(expected_transitions.issubset(seen_transitions)) + self.assertEqual(len(expected_transitions) + 1, len(seen_transitions)) + + source, symbol, dest = list(seen_transitions - expected_transitions)[0] + self.assertEqual(symbol, "") + self.assertEqual(dest, self.dpda.initial_state) + self.assertTrue(source not in self.dpda.states) + + def test_show_diagram_exception(self) -> None: + """Should raise exception""" + + self.assertRaises( + exceptions.DiagramException, + self.dpda.show_diagram, + "ab", + with_machine=False, + with_stack=False, + ) + + def test_show_diagram_read_input_machine_only(self) -> None: + """ + Should construct the diagram with machine only for a DPDA reading input. + """ + input_strings = ["ab", "aabb", "aaabbb"] + + for input_string in input_strings: + graph = self.dpda.show_diagram(input_str=input_string, with_stack=False) + + # Get edges corresponding to input path + colored_edges = [ + edge for edge in graph.edges() if "color" in dict(edge.attr) + ] + + edge_pairs = [ + (edge[0].state, edge[1].state) + for edge in self.dpda._get_input_path(input_string)[0] + ] + self.assertEqual(edge_pairs, colored_edges) + + def test_show_diagram_read_input_stack_only(self) -> None: + """ + Should construct the diagram with stack only for a DPDA reading input. + """ + input_strings = ["ab", "aabb", "aaabbb"] + + for input_string in input_strings: + graph = self.dpda.show_diagram(input_str=input_string, with_machine=False) + + # Get edges corresponding to input path + colored_edges = [ + edge for edge in graph.edges() if "color" in dict(edge.attr) + ] + + edge_pairs = [ + (str(edge[0]), str(edge[1])) + for edge in self.dpda._get_input_path(input_string)[0] + ] + + # Get stack content corresponding to input path + nodes = [node.attr["label"] for node in graph.nodes()] + + stack_content = [ + " | " + " | ".join(reversed(edge.stack)) + for edge in list(self.dpda.read_input_stepwise(input_string)) + ] + + self.assertEqual(nodes, stack_content) + self.assertEqual(edge_pairs, colored_edges) + + def test_show_diagram_write_file(self) -> None: + """ + Should construct the diagram for a DPDA + and write it to the specified file. + """ + diagram_path = os.path.join(self.temp_dir_path, "test_dpda.png") + try: + os.remove(diagram_path) + except OSError: + pass + self.assertFalse(os.path.exists(diagram_path)) + self.dpda.show_diagram(path=diagram_path) + self.assertTrue(os.path.exists(diagram_path)) + os.remove(diagram_path) + + def test_show_diagram_orientations(self) -> None: + graph = self.dpda.show_diagram() + self.assertEqual(graph.graph_attr["rankdir"], "LR") + graph = self.dpda.show_diagram(horizontal=False) + self.assertEqual(graph.graph_attr["rankdir"], "TB") + graph = self.dpda.show_diagram(reverse_orientation=True) + self.assertEqual(graph.graph_attr["rankdir"], "RL") + graph = self.dpda.show_diagram(horizontal=False, reverse_orientation=True) + self.assertEqual(graph.graph_attr["rankdir"], "BT") + + def test_show_diagram_fig_size(self) -> None: + """ + Testing figure size. Just need to make sure it matches the input + (the library handles the rendering). + """ + graph = self.dpda.show_diagram(fig_size=(1.1, 2)) + self.assertEqual(graph.graph_attr["size"], "1.1, 2") + + graph = self.dpda.show_diagram(fig_size=(3.3,)) + self.assertEqual(graph.graph_attr["size"], "3.3") diff --git a/tests/test_fa.py b/tests/test_fa.py index fbb6bfbe..ccea6350 100644 --- a/tests/test_fa.py +++ b/tests/test_fa.py @@ -63,6 +63,14 @@ class TestFAAbstract(unittest.TestCase): def test_abstract_methods_not_implemented(self) -> None: """Should raise NotImplementedError when calling abstract methods.""" + abstract_methods = { + "iter_transitions": (FA,), + "_get_input_path": (FA, ""), + } + for method_name, method_args in abstract_methods.items(): + with self.assertRaises(NotImplementedError): + getattr(FA, method_name)(*method_args) + with self.assertRaises(NotImplementedError): getattr(FA, "_get_input_path")(FA, "") diff --git a/tests/test_npda.py b/tests/test_npda.py index e9f5982a..fcb72014 100644 --- a/tests/test_npda.py +++ b/tests/test_npda.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Classes and functions for testing the behavior of NPDAs.""" +import os + from frozendict import frozendict import automata.base.exceptions as exceptions @@ -390,3 +392,155 @@ def test_accepts_input_true(self) -> None: def test_accepts_input_false(self) -> None: """Should return False if NPDA input is rejected.""" self.assertFalse(self.npda.accepts_input("aaba")) + + def test_show_diagram(self) -> None: + """Should construct the diagram for a NPDA""" + graph = self.npda.show_diagram() + node_names = {node.get_name() for node in graph.nodes()} + self.assertTrue(set(self.npda.states).issubset(node_names)) + self.assertEqual(len(self.npda.states) + 1, len(node_names)) + + for state in self.npda.states: + node = graph.get_node(state) + expected_shape = ( + "doublecircle" if state in self.npda.final_states else "circle" + ) + self.assertEqual(node.attr["shape"], expected_shape) + + expected_transitions = { + ("q0", frozenset({"a, A | ε", "b, B | ε"}), "q1"), + ( + "q0", + frozenset( + { + "ε, # | #", + } + ), + "q2", + ), + ( + "q0", + frozenset( + { + "a, # | A#", + "a, A | AA", + "a, B | AB", + "b, # | B#", + "b, A | BA", + "b, B | BB", + } + ), + "q0", + ), + ("q1", frozenset({"a, A | ε", "b, B | ε"}), "q1"), + ( + "q1", + frozenset( + { + "ε, # | #", + } + ), + "q2", + ), + } + + seen_transitions = { + ( + edge[0], + frozenset(map(str.strip, edge.attr["label"].split("\n"))), + edge[1], + ) + for edge in graph.edges() + } + + self.assertTrue(expected_transitions.issubset(seen_transitions)) + self.assertEqual(len(expected_transitions) + 1, len(seen_transitions)) + + source, symbol, dest = list(seen_transitions - expected_transitions)[0] + self.assertEqual(symbol, frozenset({""})) + self.assertEqual(dest, self.npda.initial_state) + self.assertTrue(source not in self.npda.states) + + def test_show_diagram_read_input_machine_only(self) -> None: + """ + Should construct the diagram with machine only for a NPDA reading input. + """ + input_strings = ["abba", "aabbaa", "aabaabaa", ""] + + for input_string in input_strings: + graph = self.npda.show_diagram(input_str=input_string, with_stack=False) + + # Get edges corresponding to input path + colored_edges = [ + edge for edge in graph.edges() if "color" in dict(edge.attr) + ] + + edge_pairs = [ + (edge[0].state, edge[1].state) + for edge in self.npda._get_input_path(input_string)[0] + ] + self.assertEqual(edge_pairs, colored_edges) + + def test_show_diagram_read_input_stack_only(self) -> None: + """ + Should construct the diagram with stack only for a NPDA reading input. + """ + input_strings = ["abba", "aabbaa", "aabaabaa", ""] + + for input_string in input_strings: + graph = self.npda.show_diagram(input_str=input_string, with_machine=False) + + # Get edges corresponding to input path + colored_edges = [ + edge for edge in graph.edges() if "color" in dict(edge.attr) + ] + + input_path = self.npda._get_input_path(input_string)[0] + + edge_pairs = [(str(edge[0]), str(edge[1])) for edge in input_path] + + # Get stack content corresponding to input path + nodes = [node.attr["label"] for node in graph.nodes()] + + stack_content = [ + " | " + " | ".join(reversed(edge[0].stack)) for edge in input_path + ] + [" | " + " | ".join(reversed(input_path[-1][1].stack))] + + self.assertEqual(nodes, stack_content) + self.assertEqual(edge_pairs, colored_edges) + + def test_show_diagram_write_file(self) -> None: + """ + Should construct the diagram for a NPDA + and write it to the specified file. + """ + diagram_path = os.path.join(self.temp_dir_path, "test_npda.png") + try: + os.remove(diagram_path) + except OSError: + pass + self.assertFalse(os.path.exists(diagram_path)) + self.npda.show_diagram(path=diagram_path) + self.assertTrue(os.path.exists(diagram_path)) + os.remove(diagram_path) + + def test_show_diagram_orientations(self) -> None: + graph = self.npda.show_diagram() + self.assertEqual(graph.graph_attr["rankdir"], "LR") + graph = self.npda.show_diagram(horizontal=False) + self.assertEqual(graph.graph_attr["rankdir"], "TB") + graph = self.npda.show_diagram(reverse_orientation=True) + self.assertEqual(graph.graph_attr["rankdir"], "RL") + graph = self.npda.show_diagram(horizontal=False, reverse_orientation=True) + self.assertEqual(graph.graph_attr["rankdir"], "BT") + + def test_show_diagram_fig_size(self) -> None: + """ + Testing figure size. Just need to make sure it matches the input + (the library handles the rendering). + """ + graph = self.npda.show_diagram(fig_size=(1.1, 2)) + self.assertEqual(graph.graph_attr["size"], "1.1, 2") + + graph = self.npda.show_diagram(fig_size=(3.3,)) + self.assertEqual(graph.graph_attr["size"], "3.3") diff --git a/tests/test_pda.py b/tests/test_pda.py index 969b48fc..94218c8f 100644 --- a/tests/test_pda.py +++ b/tests/test_pda.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 """Classes and functions for testing the behavior of PDAs.""" +import tempfile import unittest from automata.pda.dpda import DPDA from automata.pda.npda import NPDA +from automata.pda.pda import PDA class TestPDA(unittest.TestCase): @@ -13,6 +15,8 @@ class TestPDA(unittest.TestCase): dpda: DPDA npda: NPDA + temp_dir_path = tempfile.gettempdir() + def setUp(self) -> None: """Reset test automata before every test function.""" # DPDA which which matches zero or more 'a's, followed by the same @@ -73,3 +77,15 @@ def setUp(self) -> None: final_states={"q2"}, acceptance_mode="final_state", ) + + +class TestPDAAbstract(unittest.TestCase): + def test_abstract_methods_not_implemented(self) -> None: + """Should raise NotImplementedError when calling abstract methods.""" + abstract_methods = { + "iter_transitions": (PDA,), + "_get_input_path": (PDA, ""), + } + for method_name, method_args in abstract_methods.items(): + with self.assertRaises(NotImplementedError): + getattr(PDA, method_name)(*method_args) diff --git a/tests/test_pdaconfiguration.py b/tests/test_pdaconfiguration.py index 0bfc9cc3..4c3cd8df 100644 --- a/tests/test_pdaconfiguration.py +++ b/tests/test_pdaconfiguration.py @@ -22,3 +22,14 @@ def test_config_repr(self) -> None: repr(config), "PDAConfiguration('q0', 'ab', PDAStack(('a', 'b')))", # noqa ) + + def test_config_equality(self) -> None: + """Should only be equal for equal configurations.""" + config = PDAConfiguration("q0", "ab", PDAStack(["a", "b"])) + + self.assertEqual(config, config) + self.assertEqual(config, PDAConfiguration("q0", "ab", PDAStack(["a", "b"]))) + + self.assertNotEqual(config, "") + self.assertNotEqual(config, PDAConfiguration("q1", "ab", PDAStack(["a", "b"]))) + self.assertNotEqual(config, PDAConfiguration("q0", "ab", PDAStack(["b", "b"]))) diff --git a/tests/test_pdastack.py b/tests/test_pdastack.py index 24633fa0..9b8aa21d 100644 --- a/tests/test_pdastack.py +++ b/tests/test_pdastack.py @@ -28,3 +28,14 @@ def test_stack_get(self) -> None: def test_stack_repr(self) -> None: """Should create proper string representation of PDA stack.""" self.assertEqual(repr(self.stack), "PDAStack(('a', 'b'))") + + def test_stack_equality(self) -> None: + """Should only be equal for equal configurations.""" + stack = PDAStack(["a", "b"]) + + self.assertEqual(stack, stack) + self.assertEqual(stack, PDAStack(["a", "b"])) + + self.assertNotEqual(stack, "") + self.assertNotEqual(stack, PDAStack(["a", "a"])) + self.assertNotEqual(stack, PDAStack(["b", "b"]))