diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index 753322bd..00000000
--- a/.coveragerc
+++ /dev/null
@@ -1,23 +0,0 @@
-# Configuration for coverage.py (https://pypi.python.org/pypi/coverage)
-
-[run]
-# Enable branch coverage
-branch = True
-
-[report]
-
-# Regexes for lines to exclude from consideration
-exclude_lines =
-
- pragma: no cover
-
- # Ignore non-runnable code
- if __name__ == .__main__.:
-
- pass
-
- ...
-
-# Only check coverage for source files
-include =
- automata/*/*.py
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 1cb65971..6ab7346b 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -23,8 +23,8 @@ jobs:
with:
python-version: "3.11"
- - name: Install graphviz
- run: sudo apt-get install graphviz
+ - name: Setup Graphviz
+ uses: ts-graphviz/setup-graphviz@v1
- name: Install dependencies
run: |
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 62b05cc9..c7b766c1 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -27,8 +27,8 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- - name: Install graphviz
- run: sudo apt-get install graphviz
+ - name: Setup Graphviz
+ uses: ts-graphviz/setup-graphviz@v1
- name: Install dependencies
run: |
diff --git a/automata/fa/dfa.py b/automata/fa/dfa.py
index 89433a26..d7ee9d85 100644
--- a/automata/fa/dfa.py
+++ b/automata/fa/dfa.py
@@ -17,18 +17,15 @@
Iterable,
Iterator,
List,
- Literal,
Mapping,
Optional,
Set,
Tuple,
Type,
- TypeVar,
cast,
)
import networkx as nx
-from pydot import Dot, Edge, Node
from typing_extensions import Self
import automata.base.exceptions as exceptions
@@ -50,7 +47,6 @@
class DFA(fa.FA):
"""A deterministic finite automaton."""
- # TODO allow
__slots__ = (
"states",
"input_symbols",
@@ -332,7 +328,7 @@ def minify(self, retain_names: bool = False) -> Self:
Create a minimal DFA which accepts the same inputs as this DFA.
First, non-reachable states are removed.
- Then, similiar states are merged using Hopcroft's Algorithm.
+ Then, similar states are merged using Hopcroft's Algorithm.
retain_names: If True, merged states retain names.
If False, new states will be named 0, ..., n-1.
"""
@@ -807,7 +803,7 @@ def predecessors(
*,
strict: bool = True,
key: Optional[Callable[[Any], Any]] = None,
- ) -> Iterable[str]:
+ ) -> Generator[str, None, None]:
"""
Generates all strings that come before the input string
in lexicographical order.
@@ -845,7 +841,7 @@ def successors(
strict: bool = True,
key: Optional[Callable[[Any], Any]] = None,
reverse: bool = False,
- ) -> Iterable[str]:
+ ) -> Generator[str, None, None]:
"""
Generates all strings that come after the input string in
lexicographical order. Passing in None will generate all words. If
@@ -906,7 +902,7 @@ def successors(
)
# Traverse to child if candidate is viable
if candidate_state in coaccessible_nodes:
- state_stack.append(cast(str, candidate_state))
+ state_stack.append(candidate_state)
char_stack.append(cast(str, candidate))
candidate = first_symbol
else:
@@ -1553,38 +1549,35 @@ def get_name_original(states: FrozenSet[DFAStateT]) -> DFAStateT:
final_states=dfa_final_states,
)
- def show_diagram(self, path=None):
+ def iter_transitions(
+ self,
+ ) -> Generator[Tuple[DFAStateT, DFAStateT, str], None, None]:
+ return (
+ (from_, to_, symbol)
+ for from_, lookup in self.transitions.items()
+ for symbol, to_ in lookup.items()
+ )
+
+ def _get_input_path(
+ self, input_str
+ ) -> Tuple[List[Tuple[DFAStateT, DFAStateT, DFASymbolT]], bool]:
"""
- Creates the graph associated with this DFA
+ Calculate the path taken by input.
+
+ Args:
+ input_str (str): The input string to run on the DFA.
+
+ Returns:
+ tuple[list[tuple[DFAStateT, DFAStateT, DFASymbolT], bool]]: A list
+ of all transitions taken in each step and a boolean indicating
+ whether the DFA accepted the input.
+
"""
- # Nodes are set of states
- graph = Dot(graph_type="digraph", rankdir="LR")
- nodes = {}
- for state in self.states:
- if state == self.initial_state:
- # color start state with green
- if state in self.final_states:
- initial_state_node = Node(
- state, style="filled", peripheries=2, fillcolor="#66cc33"
- )
- else:
- initial_state_node = Node(
- state, style="filled", fillcolor="#66cc33"
- )
- nodes[state] = initial_state_node
- graph.add_node(initial_state_node)
- else:
- if state in self.final_states:
- state_node = Node(state, peripheries=2)
- else:
- state_node = Node(state)
- nodes[state] = state_node
- graph.add_node(state_node)
- # adding edges
- for from_state, lookup in self.transitions.items():
- for to_label, to_state in lookup.items():
- graph.add_edge(Edge(nodes[from_state], nodes[to_state], label=to_label))
- if path:
- graph.write_png(path)
- return graph
+ state_history = list(self.read_input_stepwise(input_str, ignore_rejection=True))
+ path = list(zip(state_history, state_history[1:], input_str))
+
+ last_state = state_history[-1] if state_history else self.initial_state
+ accepted = last_state in self.final_states
+
+ return path, accepted
diff --git a/automata/fa/fa.py b/automata/fa/fa.py
index 2336d609..69cd595e 100644
--- a/automata/fa/fa.py
+++ b/automata/fa/fa.py
@@ -1,12 +1,29 @@
#!/usr/bin/env python3
"""Classes and methods for working with all finite automata."""
+from __future__ import annotations
import abc
-from typing import Set
+import os
+import pathlib
+import random
+import uuid
+from collections import defaultdict
+from typing import Dict, Generator, List, Literal, Optional, Set, Tuple, Union
from automata.base.automaton import Automaton, AutomatonStateT
+# Optional imports for use with visual functionality
+try:
+ import coloraide
+ import pygraphviz as pgv
+except ImportError:
+ _visual_imports = False
+else:
+ _visual_imports = True
+
+
FAStateT = AutomatonStateT
+LayoutMethod = Literal["neato", "dot", "twopi", "circo", "fdp", "nop"]
class FA(Automaton, metaclass=abc.ABCMeta):
@@ -14,6 +31,203 @@ 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)
+
+ @abc.abstractmethod
+ def iter_transitions(self) -> Generator[Tuple[FAStateT, FAStateT, str], None, None]:
+ """
+ Iterate over all transitions in the automaton. Each transition is a tuple
+ of the form (from_state, to_state, symbol)
+ """
+
+ def show_diagram(
+ self,
+ input_str: Optional[str] = None,
+ 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 DFA.
+ 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)
+ if input_str is not None:
+ input_path, is_accepted = self._get_input_path(input_str=input_str)
+
+ start_color = coloraide.Color("#ff0")
+ end_color = (
+ coloraide.Color("#0f0") if is_accepted else coloraide.Color("#f00")
+ )
+ 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, symbol) in enumerate(
+ input_path, start=1
+ ):
+ color = interpolation(transition_index / len(input_path))
+ label = self.get_edge_name(symbol)
+
+ is_edge_drawn[from_state, to_state, symbol] = True
+ graph.add_edge(
+ self.get_state_name(from_state),
+ self.get_state_name(to_state),
+ label=f"<{label} [#{transition_index}]>",
+ arrowsize=arrow_size_str,
+ fontsize=font_size_str,
+ color=color.to_string(hex=True),
+ penwidth="2.5",
+ )
+
+ 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=",".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
+
+ @abc.abstractmethod
+ def _get_input_path(
+ self, input_str: str
+ ) -> Tuple[List[Tuple[FAStateT, FAStateT, str]], bool]:
+ """Calculate the path taken by input."""
+
+ raise NotImplementedError(
+ f"_get_input_path is not implemented for {self.__class__}"
+ )
+
+ def _repr_mimebundle_(self, *args, **kwargs) -> Dict[str, Union[bytes, str]]:
+ return self.show_diagram()._repr_mimebundle_(*args, **kwargs)
+
@staticmethod
def _add_new_state(state_set: Set[FAStateT], start: int = 0) -> int:
"""Adds new state to the state set and returns it"""
diff --git a/automata/fa/gnfa.py b/automata/fa/gnfa.py
index 551250b3..eafba8f1 100644
--- a/automata/fa/gnfa.py
+++ b/automata/fa/gnfa.py
@@ -4,10 +4,18 @@
from __future__ import annotations
from itertools import product
-from typing import AbstractSet, Dict, Mapping, Optional, Set, Type, cast
+from typing import (
+ AbstractSet,
+ Dict,
+ Generator,
+ Mapping,
+ Optional,
+ Set,
+ Tuple,
+ Type,
+ cast,
+)
-from frozendict import frozendict
-from pydot import Dot, Edge, Node
from typing_extensions import NoReturn, Self
import automata.base.exceptions as exceptions
@@ -25,17 +33,14 @@
class GNFA(fa.FA):
"""A generalized nondeterministic finite automaton."""
- # The conventions of using __slots__ state that subclasses automatically
- # inherit __slots__ from parent classes, so there's no need to redeclare
- # slotted attributes for each subclass; however, because NFA has a
- # 'final_states' attribute but GNFA has a 'final_state' attribute, we must
- # redeclare them below to exclude 'final_states'
+ # Add __dict__ to deal with inheritance issue and the final_states attribute.
__slots__ = (
"states",
"input_symbols",
"transitions",
"initial_state",
"final_state",
+ "__dict__",
)
final_state: GNFAStateT
@@ -49,15 +54,14 @@ def __init__(
initial_state: GNFAStateT,
final_state: GNFAStateT,
) -> None:
- """Initialize a complete NFA."""
+ """Initialize a complete GNFA."""
super(fa.FA, self).__init__(
- states=frozenset(states),
- input_symbols=frozenset(input_symbols),
- transitions=frozendict(
- {state: frozendict(paths) for state, paths in transitions.items()}
- ),
+ states=states,
+ input_symbols=input_symbols,
+ transitions=transitions,
initial_state=initial_state,
final_state=final_state,
+ final_states={final_state},
)
# GNFA should NOT create the lambda closures via NFA.__post_init__()
@@ -327,39 +331,17 @@ def to_regex(self) -> str:
def read_input_stepwise(self, input_str: str) -> NoReturn:
raise NotImplementedError
- def show_diagram(self, path=None, show_None=True):
- """
- Creates the graph associated with this DFA
- """
- # Nodes are set of states
-
- graph = Dot(graph_type="digraph", rankdir="LR")
- nodes = {}
- for state in self.states:
- if state == self.initial_state:
- # color start state with green
- initial_state_node = Node(state, style="filled", fillcolor="#66cc33")
- nodes[state] = initial_state_node
- graph.add_node(initial_state_node)
- else:
- if state == self.final_state:
- state_node = Node(state, peripheries=2)
- else:
- state_node = Node(state)
- nodes[state] = state_node
- graph.add_node(state_node)
- # adding edges
- for from_state, lookup in self.transitions.items():
- for to_state, to_label in lookup.items(): # pragma: no branch
- if to_label is None and show_None:
- to_label = "ø"
- graph.add_edge(
- Edge(nodes[from_state], nodes[to_state], label=to_label)
- )
- elif to_label is not None:
- graph.add_edge(
- Edge(nodes[from_state], nodes[to_state], label=to_label)
- )
- if path:
- graph.write_png(path)
- return graph
+ def iter_transitions(
+ self,
+ ) -> Generator[Tuple[GNFAStateT, GNFAStateT, str], None, None]:
+ return (
+ (from_, to_, symbol)
+ for from_, lookup in self.transitions.items()
+ for to_, symbol in lookup.items()
+ if symbol is not None
+ )
+
+ def _get_input_path(self, input_str: str) -> NoReturn:
+ raise NotImplementedError(
+ f"_get_input_path is not implemented for {self.__class__}"
+ )
diff --git a/automata/fa/nfa.py b/automata/fa/nfa.py
index ac0228c9..4689386b 100644
--- a/automata/fa/nfa.py
+++ b/automata/fa/nfa.py
@@ -23,7 +23,6 @@
import networkx as nx
from frozendict import frozendict
-from pydot import Dot, Edge, Node
from typing_extensions import Self
import automata.base.exceptions as exceptions
@@ -35,7 +34,7 @@
NFAPathT = Mapping[str, AbstractSet[NFAStateT]]
NFATransitionsT = Mapping[NFAStateT, NFAPathT]
-
+InputPathListT = List[Tuple[NFAStateT, NFAStateT, str]]
DEFAULT_REGEX_SYMBOLS = frozenset(chain(string.ascii_letters, string.digits))
@@ -808,45 +807,6 @@ def left_quotient(self, other: NFA) -> Self:
final_states=new_final_states,
)
- def show_diagram(self, path=None):
- """
- Creates the graph associated with this DFA
- """
- # Nodes are set of states
-
- graph = Dot(graph_type="digraph", rankdir="LR")
- nodes = {}
- for state in self.states:
- if state == self.initial_state:
- # color start state with green
- if state in self.final_states:
- initial_state_node = Node(
- state, style="filled", peripheries=2, fillcolor="#66cc33"
- )
- else:
- initial_state_node = Node(
- state, style="filled", fillcolor="#66cc33"
- )
- nodes[state] = initial_state_node
- graph.add_node(initial_state_node)
- else:
- if state in self.final_states:
- state_node = Node(state, peripheries=2)
- else:
- state_node = Node(state)
- nodes[state] = state_node
- graph.add_node(state_node)
- # adding edges
- for from_state, lookup in self.transitions.items():
- for to_label, to_states in lookup.items():
- for to_state in to_states:
- graph.add_edge(
- Edge(nodes[from_state], nodes[to_state], label=to_label)
- )
- if path:
- graph.write_png(path)
- return graph
-
@staticmethod
def _load_new_transition_dict(
state_map_dict: Mapping[NFAStateT, NFAStateT],
@@ -1009,3 +969,79 @@ def add_any_transition(start_state_dict, end_state):
initial_state=(0, 0),
final_states=final_states,
)
+
+ def iter_transitions(
+ self,
+ ) -> Generator[Tuple[NFAStateT, NFAStateT, str], None, None]:
+ return (
+ (from_, to_, symbol)
+ for from_, lookup in self.transitions.items()
+ for symbol, to_lookup in lookup.items()
+ for to_ in to_lookup
+ )
+
+ def _get_input_path(self, input_str: str) -> Tuple[InputPathListT, bool]:
+ """
+ Get best input path. A path is better if (with priority):
+
+ 1. It is an accepting path (ends in a final state)
+ 2. It reads more of the input (if the input is not accepted we
+ select the path such that we can stay on the nfa the longest)
+ 3. It has the fewest jumps (uses less lambda symbols)
+
+ Returns a tuple of:
+ 1. the path taken
+ 2. whether the path was accepting
+ """
+
+ visited = set()
+ work_queue: Deque[Tuple[InputPathListT, NFAStateT, str]] = deque(
+ [([], self.initial_state, input_str)]
+ )
+
+ last_non_accepting_input: InputPathListT = []
+ least_input_remaining = len(input_str)
+
+ while work_queue:
+ visited_states, curr_state, remaining_input = work_queue.popleft()
+
+ # First final state we hit is the best according to desired criteria
+ if curr_state in self.final_states and not remaining_input:
+ return visited_states, True
+
+ # Otherwise, update longest non-accepting input
+ if len(remaining_input) < least_input_remaining:
+ least_input_remaining = len(remaining_input)
+ last_non_accepting_input = visited_states
+
+ # First, get next states that result from reading from input
+ if remaining_input:
+ next_symbol = remaining_input[0]
+ rest = remaining_input[1:] if remaining_input else ""
+
+ next_states_from_symbol = self.transitions[curr_state].get(
+ next_symbol, set()
+ )
+
+ for next_state in next_states_from_symbol:
+ if (next_state, rest) not in visited:
+ next_visited_states = visited_states.copy()
+ next_visited_states.append(
+ (curr_state, next_state, next_symbol)
+ )
+ visited.add((next_state, rest))
+ work_queue.append((next_visited_states, next_state, rest))
+
+ # Next, get next states resulting from lambda transition
+ next_states_from_lambda = self.transitions[curr_state].get("", set())
+
+ for next_state in next_states_from_lambda:
+ if (next_state, remaining_input) not in visited:
+ next_visited_states = visited_states.copy()
+ next_visited_states.append((curr_state, next_state, ""))
+ visited.add((next_state, remaining_input))
+ work_queue.append(
+ (next_visited_states, next_state, remaining_input)
+ )
+
+ return last_non_accepting_input, False
diff --git a/automata/pda/stack.py b/automata/pda/stack.py
index 6c537073..7f534263 100644
--- a/automata/pda/stack.py
+++ b/automata/pda/stack.py
@@ -49,7 +49,7 @@ def __iter__(self) -> Iterator[str]:
"""Return an interator for the stack."""
return iter(self.stack)
- def __getitem__(self, key):
+ def __getitem__(self, key: int) -> str:
"""Return the stack element at the given index"""
return self.stack[key]
diff --git a/pyproject.toml b/pyproject.toml
index cb52f407..a705eea6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,11 +13,18 @@ maintainers = [
{name = 'Caleb Evans', email = 'caleb@calebevans.me'}
]
dependencies = [
- "pydot>=1.4.2",
"networkx>=2.6.2",
- "frozendict>=2.3.4", #TODO I think that typing extensions needs to be in here
+ "frozendict>=2.3.4",
+ "typing-extensions>=4.5.0"
]
+# Per https://setuptools.pypa.io/en/latest/userguide/dependency_management.html#optional-dependencies
+[project.optional-dependencies]
+visual = ["coloraide>=1.8.2", "pygraphviz>=1.10"]
+
+[tool.setuptools.package-data]
+"automata" = ["py.typed"]
+
[project.urls]
homepage = "https://github.com/caleb531/automata"
documentation = "https://github.com/caleb531/automata#readme"
@@ -34,7 +41,7 @@ exclude = ["build"]
module = [
"setuptools.*",
"networkx.*",
- "pydot.*" # TODO pydot dependency is going to get removed, can delete
+ "pygraphviz.*"
]
ignore_missing_imports = true
@@ -46,3 +53,22 @@ extend-ignore = ["E203", "W503"]
# Per
[tool.isort]
profile = "black"
+
+
+# Configuration for coverage.py (https://pypi.python.org/pypi/coverage)
+
+[tool.coverage.run]
+# Enable branch coverage
+branch = true
+
+[tool.coverage.report]
+# Regexes for lines to exclude from consideration
+exclude_lines = [
+ "pragma: no cover",
+ # Ignore non-runnable code
+ "if __name__ == .__main__.:",
+ "pass",
+]
+
+# Only check coverage for source files
+include =["automata/*/*.py"]
diff --git a/requirements.txt b/requirements.txt
index 411b6c03..9883044c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,11 +1,13 @@
black==23.3.0
click==8.1.3
-coverage==6.4.4
+coloraide==1.8.2
+coverage[toml]==6.4.4
flake8==6.0.0
flake8-black==0.3.6
flake8-isort==6.0.0
Flake8-pyproject==1.2.3
frozendict==2.3.4
+pygraphviz==1.10
isort==5.10.1
mccabe==0.7.0
mypy==1.1.1
diff --git a/setup.py b/setup.py
index 60684932..7f1a1763 100644
--- a/setup.py
+++ b/setup.py
@@ -1,3 +1,4 @@
from setuptools import setup
-setup()
+if __name__ == "__main__":
+ setup()
diff --git a/tests/test_dfa.py b/tests/test_dfa.py
index 75e268a6..764049fa 100644
--- a/tests/test_dfa.py
+++ b/tests/test_dfa.py
@@ -3,6 +3,7 @@
import os
import os.path
+import random
import tempfile
import types
from itertools import product
@@ -1519,26 +1520,55 @@ def test_show_diagram_initial_final_different(self) -> None:
is not a final state.
"""
graph = self.dfa.show_diagram()
- self.assertEqual(
- {node.get_name() for node in graph.get_nodes()}, {"q0", "q1", "q2"}
- )
- self.assertEqual(graph.get_node("q0")[0].get_style(), "filled")
- self.assertEqual(graph.get_node("q1")[0].get_peripheries(), 2)
- self.assertEqual(graph.get_node("q2")[0].get_peripheries(), None)
- self.assertEqual(
- {
- (edge.get_source(), edge.get_label(), edge.get_destination())
- for edge in graph.get_edges()
- },
- {
- ("q0", "0", "q0"),
- ("q0", "1", "q1"),
- ("q1", "0", "q0"),
- ("q1", "1", "q2"),
- ("q2", "0", "q2"),
- ("q2", "1", "q1"),
- },
- )
+ node_names = {node.get_name() for node in graph.nodes()}
+ self.assertTrue(set(self.dfa.states).issubset(node_names))
+ self.assertEqual(len(self.dfa.states) + 1, len(node_names))
+
+ for state in self.dfa.states:
+ node = graph.get_node(state)
+ expected_shape = (
+ "doublecircle" if state in self.dfa.final_states else "circle"
+ )
+ self.assertEqual(node.attr["shape"], expected_shape)
+
+ expected_transitions = {
+ ("q0", "0", "q0"),
+ ("q0", "1", "q1"),
+ ("q1", "0", "q0"),
+ ("q1", "1", "q2"),
+ ("q2", "0", "q2"),
+ ("q2", "1", "q1"),
+ }
+ 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.dfa.initial_state)
+ self.assertTrue(source not in self.dfa.states)
+
+ def test_show_diagram_read_input(self) -> None:
+ """
+ Should construct the diagram for a DFA reading input.
+ """
+ input_strings = ["0111", "001", "01110011", "001011001", "1100", ""]
+
+ for input_string in input_strings:
+ graph = self.dfa.show_diagram(input_str=input_string)
+
+ # Get edges corresponding to input path
+ colored_edges = [
+ edge for edge in graph.edges() if "color" in dict(edge.attr)
+ ]
+ colored_edges.sort(key=lambda edge: edge.attr["label"][2:])
+
+ edge_pairs = [
+ edge[0:2] for edge in self.dfa._get_input_path(input_string)[0]
+ ]
+ self.assertEqual(edge_pairs, colored_edges)
def test_show_diagram_initial_final_same(self) -> None:
"""
@@ -1558,28 +1588,34 @@ def test_show_diagram_initial_final_same(self) -> None:
initial_state="q0",
final_states={"q0", "q1"},
)
+
graph = dfa.show_diagram()
- self.assertEqual(
- {node.get_name() for node in graph.get_nodes()}, {"q0", "q1", "q2"}
- )
- self.assertEqual(graph.get_node("q0")[0].get_style(), "filled")
- self.assertEqual(graph.get_node("q0")[0].get_peripheries(), 2)
- self.assertEqual(graph.get_node("q1")[0].get_peripheries(), 2)
- self.assertEqual(graph.get_node("q2")[0].get_peripheries(), None)
- self.assertEqual(
- {
- (edge.get_source(), edge.get_label(), edge.get_destination())
- for edge in graph.get_edges()
- },
- {
- ("q0", "0", "q0"),
- ("q0", "1", "q1"),
- ("q1", "0", "q0"),
- ("q1", "1", "q2"),
- ("q2", "0", "q2"),
- ("q2", "1", "q2"),
- },
- )
+ node_names = {node.get_name() for node in graph.nodes()}
+ self.assertTrue(set(dfa.states).issubset(node_names))
+ self.assertEqual(len(dfa.states) + 1, len(node_names))
+
+ for state in self.dfa.states:
+ node = graph.get_node(state)
+ expected_shape = "doublecircle" if state in dfa.final_states else "circle"
+ self.assertEqual(node.attr["shape"], expected_shape)
+
+ expected_transitions = {
+ ("q0", "0", "q0"),
+ ("q0", "1", "q1"),
+ ("q1", "0", "q0"),
+ ("q1", "1", "q2"),
+ ("q2", "0,1", "q2"),
+ }
+ 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, dfa.initial_state)
+ self.assertTrue(source not in dfa.states)
def test_show_diagram_write_file(self) -> None:
"""
@@ -1596,6 +1632,38 @@ def test_show_diagram_write_file(self) -> None:
self.assertTrue(os.path.exists(diagram_path))
os.remove(diagram_path)
+ def test_repr_mimebundle_same(self) -> None:
+ """
+ Check that the mimebundle is the same.
+ """
+
+ random.seed(42)
+ first_repr = self.dfa._repr_mimebundle_()
+ random.seed(42)
+ second_repr = self.dfa.show_diagram()._repr_mimebundle_()
+ self.assertEqual(first_repr, second_repr)
+
+ def test_show_diagram_orientations(self) -> None:
+ graph = self.dfa.show_diagram()
+ self.assertEqual(graph.graph_attr["rankdir"], "LR")
+ graph = self.dfa.show_diagram(horizontal=False)
+ self.assertEqual(graph.graph_attr["rankdir"], "TB")
+ graph = self.dfa.show_diagram(reverse_orientation=True)
+ self.assertEqual(graph.graph_attr["rankdir"], "RL")
+ graph = self.dfa.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.dfa.show_diagram(fig_size=(1.1, 2))
+ self.assertEqual(graph.graph_attr["size"], "1.1, 2")
+
+ graph = self.dfa.show_diagram(fig_size=(3.3,))
+ self.assertEqual(graph.graph_attr["size"], "3.3")
+
def test_minimal_finite_language(self):
"""Should compute the minimal DFA accepting the given finite language"""
diff --git a/tests/test_fa.py b/tests/test_fa.py
index 7b7f3af5..8871389f 100644
--- a/tests/test_fa.py
+++ b/tests/test_fa.py
@@ -4,6 +4,7 @@
import unittest
from automata.fa.dfa import DFA
+from automata.fa.fa import FA
from automata.fa.gnfa import GNFA
from automata.fa.nfa import NFA
@@ -56,3 +57,45 @@ def setUp(self) -> None:
initial_state="q_in",
final_state="q_f",
)
+
+
+class TestFAAbstract(unittest.TestCase):
+ def test_abstract_methods_not_implemented(self) -> None:
+ """Should raise NotImplementedError when calling abstract methods."""
+
+ with self.assertRaises(NotImplementedError):
+ getattr(FA, "_get_input_path")(FA, "")
+
+ def test_get_state_name(self) -> None:
+ """Tests get_state_name function."""
+
+ self.assertEqual(FA.get_state_name(""), "λ")
+ self.assertEqual(FA.get_state_name("abc"), "abc")
+ self.assertEqual(FA.get_state_name(123), "123")
+ self.assertEqual(FA.get_state_name(frozenset()), "∅")
+
+ original_sets = [frozenset("abc"), frozenset("dcgf")]
+
+ for original_set in original_sets:
+ set_output = frozenset(
+ FA.get_state_name(original_set)
+ .replace("{", "")
+ .replace("}", "")
+ .replace("'", "")
+ .replace(" ", "")
+ .split(",")
+ )
+ self.assertEqual(set_output, original_set)
+
+ original_tuple = tuple("abc")
+
+ tuple_output = tuple(
+ FA.get_state_name(original_tuple)
+ .replace("(", "")
+ .replace(")", "")
+ .replace("'", "")
+ .replace(" ", "")
+ .split(",")
+ )
+
+ self.assertEqual(tuple_output, original_tuple)
diff --git a/tests/test_gnfa.py b/tests/test_gnfa.py
index 54d1ac81..45d269f4 100644
--- a/tests/test_gnfa.py
+++ b/tests/test_gnfa.py
@@ -16,6 +16,16 @@ class TestGNFA(test_fa.TestFA):
temp_dir_path = tempfile.gettempdir()
+ def test_methods_not_implemented(self) -> None:
+ """Should raise NotImplementedError when calling non-implemented methods."""
+ abstract_methods = {
+ "_get_input_path": (GNFA, ""),
+ "read_input_stepwise": (GNFA, ""),
+ }
+ for method_name, method_args in abstract_methods.items():
+ with self.assertRaises(NotImplementedError):
+ getattr(GNFA, method_name)(*method_args)
+
def test_init_gnfa(self) -> None:
"""Should copy GNFA if passed into NFA constructor."""
new_gnfa = self.gnfa.copy()
@@ -372,69 +382,43 @@ def test_to_regex(self) -> None:
# Test equality through DFA regex conversion
self.assertEqual(dfa_1, dfa_2)
- def test_show_diagram_showNone(self) -> None:
- """
- Should construct the diagram for a GNFA when show_None = False
- """
-
- gnfa = self.gnfa
-
- graph = gnfa.show_diagram(show_None=False)
- self.assertEqual({node.get_name() for node in graph.get_nodes()}, gnfa.states)
- self.assertEqual(graph.get_node(gnfa.initial_state)[0].get_style(), "filled")
- self.assertEqual(graph.get_node(gnfa.final_state)[0].get_peripheries(), 2)
- self.assertEqual(graph.get_node("q2")[0].get_peripheries(), None)
- self.assertEqual(
- {
- (edge.get_source(), edge.get_label(), edge.get_destination())
- for edge in graph.get_edges()
- },
- {
- ("q0", "a", "q1"),
- ("q1", "a", "q1"),
- ("q1", "", "q2"),
- ("q1", "", "q_f"),
- ("q2", "b", "q0"),
- ("q_in", "", "q0"),
- },
- )
-
def test_show_diagram(self) -> None:
"""
- Should construct the diagram for a GNFA when show_None = True
+ Should construct the diagram for a GNFA.
"""
- gnfa = self.gnfa
+ graph = self.gnfa.show_diagram()
- graph = gnfa.show_diagram()
- self.assertEqual({node.get_name() for node in graph.get_nodes()}, gnfa.states)
- self.assertEqual(graph.get_node(gnfa.initial_state)[0].get_style(), "filled")
- self.assertEqual(graph.get_node(gnfa.final_state)[0].get_peripheries(), 2)
- self.assertEqual(graph.get_node("q2")[0].get_peripheries(), None)
- self.assertEqual(
- {
- (edge.get_source(), edge.get_label(), edge.get_destination())
- for edge in graph.get_edges()
- },
- {
- ("q_in", "", "q0"),
- ("q0", "ø", "q2"),
- ("q1", "", "q2"),
- ("q0", "ø", "q_f"),
- ("q1", "", "q_f"),
- ("q_in", "ø", "q2"),
- ("q_in", "ø", "q1"),
- ("q1", "a", "q1"),
- ("q2", "b", "q0"),
- ("q2", "ø", "q2"),
- ("q_in", "ø", "q_f"),
- ("q2", "ø", "q1"),
- ("q0", "ø", "q0"),
- ("q2", "ø", "q_f"),
- ("q0", "a", "q1"),
- ("q1", "ø", "q0"),
- },
- )
+ node_names = {node.get_name() for node in graph.nodes()}
+ self.assertTrue(set(self.gnfa.states).issubset(node_names))
+ self.assertEqual(len(self.gnfa.states) + 1, len(node_names))
+
+ for state in self.dfa.states:
+ node = graph.get_node(state)
+ expected_shape = (
+ "doublecircle" if state in self.gnfa.final_states else "circle"
+ )
+ self.assertEqual(node.attr["shape"], expected_shape)
+
+ expected_transitions = {
+ ("q_in", "ε", "q0"),
+ ("q1", "ε", "q2"),
+ ("q1", "ε", "q_f"),
+ ("q1", "a", "q1"),
+ ("q2", "b", "q0"),
+ ("q0", "a", "q1"),
+ }
+ 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.gnfa.initial_state)
+ self.assertTrue(source not in self.gnfa.states)
def test_show_diagram_write_file(self) -> None:
"""
diff --git a/tests/test_nfa.py b/tests/test_nfa.py
index 53699c1a..ff3b4ad0 100644
--- a/tests/test_nfa.py
+++ b/tests/test_nfa.py
@@ -584,32 +584,56 @@ def test_show_diagram_initial_final_same(self) -> None:
is also a final state.
"""
- nfa = NFA(
- states={"q0", "q1", "q2"},
- input_symbols={"a", "b"},
- transitions={
- "q0": {"a": {"q1"}},
- "q1": {"a": {"q1"}, "": {"q2"}},
- "q2": {"b": {"q0"}},
- },
- initial_state="q0",
- final_states={"q0", "q1"},
- )
- graph = nfa.show_diagram()
- self.assertEqual(
- {node.get_name() for node in graph.get_nodes()}, {"q0", "q1", "q2"}
- )
- self.assertEqual(graph.get_node("q0")[0].get_style(), "filled")
- self.assertEqual(graph.get_node("q0")[0].get_peripheries(), 2)
- self.assertEqual(graph.get_node("q1")[0].get_peripheries(), 2)
- self.assertEqual(graph.get_node("q2")[0].get_peripheries(), None)
- self.assertEqual(
- {
- (edge.get_source(), edge.get_label(), edge.get_destination())
- for edge in graph.get_edges()
- },
- {("q0", "a", "q1"), ("q1", "a", "q1"), ("q1", "", "q2"), ("q2", "b", "q0")},
- )
+ graph = self.nfa.show_diagram()
+ node_names = {node.get_name() for node in graph.nodes()}
+ self.assertTrue(set(self.nfa.states).issubset(node_names))
+ self.assertEqual(len(self.nfa.states) + 1, len(node_names))
+
+ for state in self.dfa.states:
+ node = graph.get_node(state)
+ expected_shape = (
+ "doublecircle" if state in self.nfa.final_states else "circle"
+ )
+ self.assertEqual(node.attr["shape"], expected_shape)
+
+ expected_transitions = {
+ ("q0", "a", "q1"),
+ ("q1", "a", "q1"),
+ ("q1", "ε", "q2"),
+ ("q2", "b", "q0"),
+ }
+ 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.nfa.initial_state)
+ self.assertTrue(source not in self.nfa.states)
+
+ def test_show_diagram_read_input(self) -> None:
+ """
+ Should construct the diagram for a NFA reading input.
+ """
+ input_strings = ["ababa", "bba", "aabba", "baaab", "bbaab", ""]
+
+ for input_string in input_strings:
+ graph = self.nfa.show_diagram(input_str=input_string)
+
+ # Get edges corresponding to input path
+ colored_edges = [
+ edge for edge in graph.edges() if "color" in dict(edge.attr)
+ ]
+ colored_edges.sort(key=lambda edge: edge.attr["label"][2:])
+
+ edge_pairs = [
+ edge[0:2] for edge in self.nfa._get_input_path(input_string)[0]
+ ]
+
+ self.assertEqual(edge_pairs, colored_edges)
def test_show_diagram_write_file(self) -> None:
"""
@@ -626,6 +650,71 @@ def test_show_diagram_write_file(self) -> None:
self.assertTrue(os.path.exists(diagram_path))
os.remove(diagram_path)
+ def test_get_input_path(self) -> None:
+ nfa2 = NFA(
+ states={"q0", "q1", "q2"},
+ input_symbols={"a", "b"},
+ transitions={
+ "q0": {"": {"q1"}, "b": {"q0"}},
+ "q1": {"a": {"q2"}, "": {"q0"}},
+ "q2": {"b": {"q2"}, "a": {"q1"}},
+ },
+ initial_state="q0",
+ final_states={"q1"},
+ )
+ nfa3 = NFA.from_regex("(abab|aabba)*bba*bb")
+
+ input_strings = [
+ "ababa",
+ "bba",
+ "aabba",
+ "baaab",
+ "bbaab",
+ "",
+ "bbbb",
+ "bbaabb",
+ ]
+ nfas = [self.nfa, nfa2, nfa3]
+
+ for input_str, nfa in product(input_strings, nfas):
+ input_path, was_accepted = nfa._get_input_path(input_str)
+ self.assertEqual(was_accepted, nfa.accepts_input(input_str))
+
+ last_vtx = None
+
+ for start_vtx, end_vtx, symbol in input_path:
+ last_vtx = end_vtx
+ self.assertIn(end_vtx, nfa.transitions[start_vtx][symbol])
+
+ self.assertEqual(last_vtx in nfa.final_states, was_accepted)
+
+ def test_input_path_optimality(self) -> None:
+ """
+ A test case for optimality of path found.
+ Checks path length doesn't use the extra epsilon transition.
+ """
+
+ nfa = NFA(
+ states=set(range(6)),
+ input_symbols=set("01"),
+ transitions={
+ 0: {"0": {1, 2}},
+ 1: {"": {3}},
+ 2: {"1": {4}},
+ 3: {"1": {4}},
+ 4: {"": {5}},
+ 5: {},
+ },
+ initial_state=0,
+ final_states={5},
+ )
+
+ input_str = "01"
+
+ input_path, was_accepted = nfa._get_input_path(input_str)
+ self.assertEqual(was_accepted, nfa.accepts_input(input_str))
+ self.assertEqual(len(input_path), 3)
+
def test_add_new_state_type_integrity(self) -> None:
"""
Should properly add new state of different type than original states;
diff --git a/tests/test_pdastack.py b/tests/test_pdastack.py
index a1eea107..fce672a3 100644
--- a/tests/test_pdastack.py
+++ b/tests/test_pdastack.py
@@ -8,14 +8,21 @@
class TestPDAStack(test_pda.TestPDA):
"""A test class for testing stacks of pushdown automata."""
- def test_stack_hashability(self):
- self.assertEqual(hash(PDAStack(["a", "b"])), hash(PDAStack(["a", "b"])))
+ def setUp(self) -> None:
+ self.stack = PDAStack(["a", "b"])
- def test_stack_iter(self):
+ def test_stack_hashability(self) -> None:
+ self.assertEqual(hash(self.stack), hash(PDAStack(["a", "b"])))
+
+ def test_stack_iter(self) -> None:
"""Should loop through the PDA stack in some manner."""
- self.assertEqual(list(PDAStack(["a", "b"])), ["a", "b"])
+ self.assertEqual(list(self.stack), ["a", "b"])
+
+ def test_stack_get(self) -> None:
+ """Should retrieve indices in the PDA stack in some manner."""
+ self.assertEqual(self.stack[0], "a")
+ self.assertEqual(self.stack[1], "b")
- def test_stack_repr(self):
+ def test_stack_repr(self) -> None:
"""Should create proper string representation of PDA stack."""
- stack = PDAStack(["a", "b"])
- self.assertEqual(repr(stack), "PDAStack(('a', 'b'))")
+ self.assertEqual(repr(self.stack), "PDAStack(('a', 'b'))")