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"]))