Repository: maxfischer2781/bootpeg

Issue:
Deprecate Actions wrapper
The ``Actions`` type only serves as a means to bundle names and post actions. Practically, the only use is for various helpers to *unbundle* ``Actions`` immediately. The expected usability improvement of re-using such pairs does not match my experience in practice: the many cases that don't need `post` are cumbersome, and often only one of ``names``/``post`` was reusable.

Every function taking ``Actions`` should just take ``names`` and ``post`` separately. This also allows to use dict via unpacking in case bundling is desired.

Discussion:

Patch:
diff --git a/bootpeg/__init__.py b/bootpeg/__init__.py
index 8752b60..64c425e 100644
--- a/bootpeg/__init__.py
+++ b/bootpeg/__init__.py
@@ -1,6 +1,6 @@
 """bootpeg – the bootstrapping PEG parser"""
-from .api import Actions, parse, create_parser, import_parser
+from .api import parse, create_parser, import_parser
 
-__all__ = ["Actions", "parse", "create_parser", "import_parser"]
+__all__ = ["parse", "create_parser", "import_parser"]
 
-__version__ = "0.0.2"
+__version__ = "0.6.0"
diff --git a/bootpeg/api.py b/bootpeg/api.py
index 2f372ed..8ccf94d 100644
--- a/bootpeg/api.py
+++ b/bootpeg/api.py
@@ -1,5 +1,4 @@
-from typing import Sequence, Mapping, Generic, TypeVar, Union
-from typing_extensions import Protocol
+from typing import Mapping, Callable
 from functools import partial
 
 import importlib_resources
@@ -7,77 +6,69 @@ import importlib_resources
 from .pika.peg import Parser, Clause
 from .pika.act import transform
 from .pika.boot import namespace as pika_namespace
+from .typing import R, TR, D, BootPegParser
 
 
-#: Parser domain: The input type for parsing, such as str or bytes
-D = TypeVar("D", covariant=True, bound=Sequence)
-#: Transform domain/result: The internal input type and output type of transforming,
-#: such as str, tuple, or ast.AST
-T = TypeVar("T")
-R = TypeVar("R", contravariant=True)
-
-
-class Action(Protocol[D, R]):
-    def __call__(self, *args: D, **kwargs: D) -> R:
-        ...
-
-
-def identity(x: T) -> T:
+def identity(x: TR) -> TR:
     return x
 
 
-class Actions(Generic[D, T, R]):
-    def __init__(
-        self,
-        names: Mapping[str, Union[Action[D, D], Action[D, T], Action[T, T]]],
-        post: Action[T, R] = identity,
-    ):
-        self.names = names
-        self.post = post
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({self.names!r}, {self.post!r})"
+bootpeg_actions: Mapping[str, Callable[..., Clause]] = pika_namespace
 
 
-#: :py:class:`~.Actions` to construct a Pika parser
-PikaActions: Actions[str, Union[str, Clause[str]], Parser] = Actions(
-    names=pika_namespace,
-    post=lambda *args, top="top", **kwargs: Parser(
-        top,
-        **{name: clause for name, clause in args[0]},
-    ),
-)
+def bootpeg_post(*args, top="top"):
+    return Parser(top, **{name: clause for name, clause in args[0]})
 
 
-def parse(source: D, parser: Parser[D], actions: Actions[D, T, R], **kwargs) -> T:
+def parse(
+    source: D,
+    parser: Parser[D],
+    actions: Mapping[str, Callable],
+    post: Callable[..., R] = identity,
+    **kwargs,
+) -> R:
     """Parse a ``source`` with a given ``parser`` and ``actions``"""
     head, memo = parser.parse(source)
     assert head.length == len(source), f"matched {head.length} of {len(source)}"
-    pos_captures, kw_captures = transform(head, memo, actions.names)
-    return actions.post(*pos_captures, **kw_captures, **kwargs)
+    pos_captures, kw_captures = transform(head, memo, actions)
+    return post(*pos_captures, **kw_captures, **kwargs)
 
 
-def create_parser(source: str, actions: Actions, dialect, **kwargs):
+def create_parser(
+    source: str,
+    dialect,
+    actions: Mapping[str, Callable],
+    post: Callable[..., R] = identity,
+    **kwargs,
+) -> BootPegParser[D, R]:
     """
-    Create a parser with specific `actions` from a `source` grammar
+    Create a parser from a `source` grammar
 
     :param source: a textual grammar
-    :param actions: the actions to use for the new parser
     :param dialect: the `bootpeg` parser compatible with the grammar
+    :param actions: the actions to use for the new parser
+    :param post: the postprocessing action to use for the new parser
     :param kwargs: any keyword arguments for the `dialect`'s post processing
     """
     dialect = dialect if not hasattr(dialect, "parse") else dialect.parse
     parser = dialect(source, **kwargs)
-    return partial(parse, parser=parser, actions=actions)
+    return partial(parse, parser=parser, actions=actions, post=post)
 
 
-def import_parser(location: str, actions: Actions, dialect, **kwargs):
+def import_parser(
+    location: str,
+    dialect,
+    actions: Mapping[str, Callable],
+    post: Callable[..., R] = identity,
+    **kwargs,
+) -> BootPegParser[D, R]:
     """
-    Import a parser with specific `actions` from a grammar at a `location`
+    Import a parser from a grammar at a `location`
 
     :param location: a module or module-like name
-    :param actions: the actions to use for the new parser
     :param dialect: the `bootpeg` parser compatible with the grammar
+    :param actions: the actions to use for the new parser
+    :param post: the postprocessing action to use for the new parser
     :param kwargs: any keyword arguments for the `dialect`'s post processing
 
     The `location` is a dotted module name; it is used to look up the grammar using the
@@ -92,4 +83,4 @@ def import_parser(location: str, actions: Actions, dialect, **kwargs):
     source = importlib_resources.read_text(package, name + ".bpeg")
     dialect = dialect if not hasattr(dialect, "parse") else dialect.parse
     parser = dialect(source, **kwargs)
-    return partial(parse, parser=parser, actions=actions)
+    return partial(parse, parser=parser, actions=actions, post=post)
diff --git a/bootpeg/grammars/bpeg.py b/bootpeg/grammars/bpeg.py
index f9057aa..f2de621 100644
--- a/bootpeg/grammars/bpeg.py
+++ b/bootpeg/grammars/bpeg.py
@@ -20,7 +20,8 @@ from ..pika.peg import (
 from ..pika.act import Capture, Rule
 from ..pika.front import Range, Delimited
 from ..pika.boot import bootpeg, boot
-from ..api import PikaActions, import_parser
+from ..api import bootpeg_actions, bootpeg_post, import_parser
+from ..typing import BootPegParser
 
 
 @singledispatch
@@ -105,4 +106,9 @@ def boot_dialect(source):
     return boot(bootpeg(), source)
 
 
-parse = import_parser(__name__, actions=PikaActions, dialect=boot_dialect)
+parse: BootPegParser[str, BootPegParser] = import_parser(
+    __name__,
+    dialect=boot_dialect,
+    actions=bootpeg_actions,
+    post=bootpeg_post,
+)
diff --git a/bootpeg/grammars/peg.py b/bootpeg/grammars/peg.py
index b5462eb..74e24cd 100644
--- a/bootpeg/grammars/peg.py
+++ b/bootpeg/grammars/peg.py
@@ -16,7 +16,8 @@ from ..pika.peg import (
 )
 from ..pika.act import Capture, Rule
 from ..pika.front import Range, Delimited
-from ..api import PikaActions, import_parser
+from ..api import bootpeg_actions, bootpeg_post, import_parser
+from ..typing import BootPegParser
 from . import bpeg
 
 
@@ -100,4 +101,9 @@ def unparse_delimited(clause: Delimited, top=True) -> str:
     return f"({children})" if not top else children
 
 
-parse = import_parser(__name__, actions=PikaActions, dialect=bpeg)
+parse: BootPegParser[str, BootPegParser] = import_parser(
+    __name__,
+    dialect=bpeg,
+    actions=bootpeg_actions,
+    post=bootpeg_post,
+)
diff --git a/bootpeg/pika/act.py b/bootpeg/pika/act.py
index 9287155..716292e 100644
--- a/bootpeg/pika/act.py
+++ b/bootpeg/pika/act.py
@@ -12,7 +12,7 @@ from ..utility import mono, cache_hash
 R = TypeVar("R", contravariant=True)
 
 
-class Debug(Clause[D]):
+class Debug(Clause[str]):
     def __init__(self, sub_clause, name: str = None):
         self.sub_clauses = (sub_clause,)
         self.name = name or str(sub_clause)
@@ -21,7 +21,7 @@ class Debug(Clause[D]):
     def maybe_zero(self):
         return self.sub_clauses[0].maybe_zero
 
-    def match(self, source: D, at: int, memo: MemoTable):
+    def match(self, source: str, at: int, memo: MemoTable):
         try:
             parent_match = memo[MemoKey(at, self.sub_clauses[0])]
         except KeyError:
@@ -253,7 +253,7 @@ class NamedFailure(NamedTuple):
     """Information on all failures of a :py:class:`~.Reference` match"""
 
     name: str
-    failures: Tuple[CommitFailure]
+    failures: Tuple[CommitFailure, ...]
 
 
 def leaves(failure):
@@ -324,7 +324,7 @@ def postorder_transform(
         if Commit.failed(match):
             failures = (*failures, CommitFailure(position, clause))
     elif isinstance(clause, Reference):
-        new_failures = tuple(
+        new_failures: Tuple[CommitFailure, ...] = tuple(
             failure for failure in failures if not isinstance(failure, NamedFailure)
         )
         if new_failures:
@@ -341,9 +341,11 @@ def postorder_transform(
         )
         return (), captures, failures
     elif isinstance(clause, Rule):
-        matches = matches if matches else source[position : position + match.length]
+        rule_matches: Union[Tuple[Any, ...], D] = (
+            matches if matches else source[position : position + match.length]
+        )
         try:
-            result = clause.action(namespace, matches, **captures)
+            result = clause.action(namespace, rule_matches, **captures)
         except ActionCaptureError:
             raise
         except Exception as exc:
diff --git a/bootpeg/pika/boot.py b/bootpeg/pika/boot.py
index defc556..2fafaa8 100644
--- a/bootpeg/pika/boot.py
+++ b/bootpeg/pika/boot.py
@@ -1,4 +1,4 @@
-from typing import Optional
+from typing import Optional, Dict, Mapping, Callable
 
 import string
 import time
@@ -7,6 +7,7 @@ import pathlib
 from ..utility import ascii_escapes
 from .peg import postorder_dfs, ParseFailure, MemoTable, Match
 from .front import (
+    Clause,
     Literal,
     Sequence,
     Choice,
@@ -63,9 +64,9 @@ def range_parse(source: str, parser: Parser):
     )
 
 
-def report_matches(memo: MemoTable):
+def report_matches(memo: MemoTable[str]):
     """Show matched range(s) by a parsed memo table"""
-    longest_matches = {}
+    longest_matches: Dict[int, Match] = {}
     for match in memo.matches.values():
         length, _, position, *_ = match
         if isinstance(match.clause, Repeat) and isinstance(
@@ -97,11 +98,10 @@ def report_matches(memo: MemoTable):
 def display(parser: Parser):
     """Display a parser in PEG form"""
     try:
-        parser._prepare()
+        clauses, _, priorities = parser._parser
     except Exception:
         pass
     else:
-        clauses, _, priorities = parser._compiled_parser
         print("Total Rules:", len(list(postorder_dfs(clauses[parser.top]))))
         print(f"{'named rule':<10} <- Prio -", "Clause")
         for name, clause in sorted(clauses.items(), key=lambda n_c: priorities[n_c[1]]):
@@ -110,7 +110,7 @@ def display(parser: Parser):
                 print("Debugs:", clause.sub_clauses[0])
 
 
-namespace = {
+namespace: Mapping[str, Callable[..., Clause]] = {
     expression.__name__: expression
     for expression in (
         Literal,
@@ -137,7 +137,7 @@ namespace = {
 }
 
 
-end_line = Reference("end_line")  # Literal("\n")
+end_line: Reference[str] = Reference("end_line")  # Literal("\n")
 
 #: Minimal parser required to bootstrap the actual parser
 min_parser = Parser(
diff --git a/bootpeg/pika/front.py b/bootpeg/pika/front.py
index 9d7e416..9cdb972 100644
--- a/bootpeg/pika/front.py
+++ b/bootpeg/pika/front.py
@@ -149,7 +149,7 @@ class Delimited(Clause[D]):
         self.sub_clauses = start, stop
 
     @property
-    def triggers(self) -> "Tuple[Clause[D]]":
+    def triggers(self) -> "Tuple[Clause[D], ...]":
         return self.sub_clauses[:1]
 
     def match(self, source: D, at: int, memo: MemoTable) -> Optional[Match]:
diff --git a/bootpeg/pika/peg.py b/bootpeg/pika/peg.py
index 6e5a6f9..3b1f0c4 100644
--- a/bootpeg/pika/peg.py
+++ b/bootpeg/pika/peg.py
@@ -6,10 +6,8 @@ Based on https://arxiv.org/pdf/2005.06444.pdf, 2020 by Luke A. D. Hutchison
 from typing import (
     NamedTuple,
     Generic,
-    TypeVar,
     Dict,
     Tuple,
-    Sequence,
     Optional,
     NoReturn,
     Iterable,
@@ -20,13 +18,10 @@ import copy
 import heapq
 import functools
 
+from ..typing import D
 from ..utility import cache_hash
 
 
-#: Parser domain: The input type for parsing, such as str or bytes
-D = TypeVar("D", covariant=True, bound=Sequence)
-
-
 # Ascending memoization
 class MemoKey(NamedTuple):
     position: int
@@ -60,11 +55,11 @@ class Match(NamedTuple):
 class MemoTable(Generic[D]):
     __slots__ = ("matches", "source")
 
-    def __init__(self, source):
+    def __init__(self, source: D):
         self.source = source
         self.matches: Dict[MemoKey, Match] = {}
 
-    def __getitem__(self, item: MemoKey):
+    def __getitem__(self, item: MemoKey) -> Match:
         try:
             return self.matches[item]
         except KeyError:
@@ -286,7 +281,7 @@ class Sequence(Clause[D]):
         self._maybe_zero = None
 
     def match(self, source: D, at: int, memo: MemoTable):
-        offset, matches = at, ()
+        offset, matches = at, ()  # type: int, Tuple[Match, ...]
         try:
             for sub_clause in self.sub_clauses:
                 sub_match = memo[MemoKey(offset, sub_clause)]
@@ -364,11 +359,11 @@ class Repeat(Clause[D]):
         return self._sub_clause.maybe_zero
 
     @property
-    def sub_clauses(self) -> Tuple[Clause[D]]:
+    def sub_clauses(self) -> Tuple[Clause[D], ...]:
         return (self._sub_clause,)
 
     @sub_clauses.setter
-    def sub_clauses(self, values: Tuple[Clause[D]]):
+    def sub_clauses(self, values: Tuple[Clause[D], ...]):
         (self._sub_clause,) = values
 
     def __init__(self, sub_clause: Clause[D]):
@@ -414,11 +409,11 @@ class Not(Clause[D]):
     maybe_zero = True
 
     @property
-    def sub_clauses(self) -> Tuple[Clause[D]]:
+    def sub_clauses(self) -> Tuple[Clause[D], ...]:
         return (self._sub_clause,)
 
     @sub_clauses.setter
-    def sub_clauses(self, value: Tuple[Clause[D]]):
+    def sub_clauses(self, value: Tuple[Clause[D], ...]):
         (self._sub_clause,) = value
 
     @property
@@ -463,7 +458,7 @@ class And(Clause[D]):
         return False
 
     @property
-    def sub_clauses(self) -> Tuple[Clause[D]]:
+    def sub_clauses(self) -> Tuple[Clause[D], ...]:
         return (self._sub_clause,)
 
     @sub_clauses.setter
@@ -532,7 +527,7 @@ class Reference(Clause[D]):
 
     def __init__(self, target: str):
         self.target = target
-        self._sub_clause = None
+        self._sub_clause: Optional[Clause[D]] = None
         # TODO: Correct?
         self._maybe_zero: Optional[bool] = None
 
@@ -613,12 +608,16 @@ class Parser(Generic[D]):
         if self._compiled_parser is None:
             self._compiled_parser = self._compile(self.top, self.clauses)
 
-    def parse(self, source: D):
-        """Parse a ``source`` sequence"""
+    @property
+    def _parser(self):
         if self._compiled_parser is None:
             self._compiled_parser = self._compile(self.top, self.clauses)
-        owned_clauses, triggers, priorities = self._compiled_parser
-        terminals: List[Tuple[int, Clause[D]], ...] = [
+        return self._compiled_parser
+
+    def parse(self, source: D):
+        """Parse a ``source`` sequence"""
+        owned_clauses, triggers, priorities = self._parser
+        terminals: List[Tuple[int, Clause[D]]] = [
             (-i, clause)
             for i, clause in enumerate(priorities)
             if isinstance(clause, Terminal) and not clause.maybe_zero
@@ -659,7 +658,9 @@ class Parser(Generic[D]):
     def _compile_triggers(
         top_clause: Clause[D],
     ) -> Dict[Clause[D], Tuple[Clause[D], ...]]:
-        triggers = {trigger: () for trigger in postorder_dfs(top_clause)}
+        triggers: Dict[Clause[D], Tuple[Clause[D], ...]] = {
+            trigger: () for trigger in postorder_dfs(top_clause)
+        }
         for parent in postorder_dfs(top_clause):
             for trigger in parent.triggers:
                 triggers[trigger] += (parent,)
diff --git a/bootpeg/typing.py b/bootpeg/typing.py
new file mode 100644
index 0000000..f56a6ea
--- /dev/null
+++ b/bootpeg/typing.py
@@ -0,0 +1,44 @@
+from typing import TypeVar
+from typing_extensions import Protocol
+
+
+# Generic input/output type variables
+T = TypeVar("T", contravariant=True)
+R = TypeVar("R", covariant=True)
+TR = TypeVar("TR")
+
+#: Parser domain: The input type for parsing, such as `str` or `bytes`
+D = TypeVar("D", bound="Domain")
+D_contra = TypeVar("D_contra", bound="Domain", contravariant=True)
+
+
+class Domain(Protocol):
+    """
+    Protocol for types parse-able by :py:mod:`bootpeg`
+
+    Requires a length, slicing and ordering (this implies equality).
+    For example, this matches :py:class:`str` and :py:class:`bytes`,
+    as well as :py:class:`tuple` and :py:class:`list` if their
+    elements support ordering.
+    """
+
+    def __len__(self) -> int:
+        raise NotImplementedError
+
+    def __getitem__(self: D, item: slice) -> D:
+        raise NotImplementedError
+
+    def __le__(self: D, other: D) -> bool:
+        raise NotImplementedError
+
+
+class BootPegParser(Protocol[D_contra, R]):
+    """
+    Protocol for all :py:mod:`bootpeg` parsers creating parsers
+    """
+
+    def __call__(self, source: D_contra, top: str = "top") -> R:
+        """
+        Parse a parser ``source`` with a known ``top`` rule
+        """
+        raise NotImplementedError
diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst
index 4ebbfa1..30679ba 100644
--- a/docs/source/getting_started.rst
+++ b/docs/source/getting_started.rst
@@ -43,17 +43,19 @@ and how it is to be interpreted:
 
 The *Actions* are callables and constants needed to transform input.
 You can define actions freely using any code you like;
-the ``Actions`` type serves to translate from the matched input to your actions:
+``bootpeg`` merely expects them in a mapping from each
+of the grammar's names to the respective action:
 
 .. code:: python3
 
-    >>> from bootpeg import Actions
-    >>> example_actions = Actions(
-    ...     # map from names used in the grammar to callables
-    ...     names={'Integer': int}
-    ...     # an optional `post` action allows to directly handle results
-    ...     # post=print
-    ... )
+    >>> # map from names used in the grammar to callables
+    >>> example_actions = {
+    ...     # use the builtin `int` to interpret any matched Integer
+    ...     'Integer': int
+    ... }
+
+In addition, ``bootpeg`` allows to apply a ``post``\ processing action.
+By default, this just returns the result of parsing as-is.
 
 Boot the PEG
 ============
@@ -74,7 +76,7 @@ Simply pass in the grammar, the actions defined, and the dialect
 
 .. code:: python3
 
-    >>> from bootpeg import create_parser, Actions
+    >>> from bootpeg import create_parser
     >>> from bootpeg.grammars import bpeg
     >>> example_grammar = """\
     ... integer:
@@ -85,8 +87,8 @@ Simply pass in the grammar, the actions defined, and the dialect
     ...     | scientific
     ...     | integer
     ... """
-    >>> example_actions = Actions(names={'Integer': int})
-    >>> parse = create_parser(example_grammar, example_actions, bpeg)
+    >>> example_actions = {'Integer': int}
+    >>> parse = create_parser(example_grammar, bpeg, example_actions)
     >>> parse("12")
     12
     >>> parse("12E6")


Test patch:
diff --git a/tests/test_grammars/test_peg.py b/tests/test_grammars/test_peg.py
index 9b4ae92..a04c78d 100644
--- a/tests/test_grammars/test_peg.py
+++ b/tests/test_grammars/test_peg.py
@@ -2,7 +2,7 @@ import sys
 
 import pytest
 
-from bootpeg import create_parser, Actions
+from bootpeg import create_parser
 from bootpeg.grammars import peg
 from bootpeg.pika.front import (
     Rule,
@@ -119,7 +119,7 @@ EndOfFile <- !.
 
 def test_parse_reference():
     """Parse the PEG reference grammar"""
-    parse = create_parser(peg_grammar, actions=Actions({}), dialect=peg, top="Grammar")
+    parse = create_parser(peg_grammar, actions={}, dialect=peg, top="Grammar")
     assert parse(peg_grammar)