aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-07-15 13:36:28 +0530
committerGitHub <[email protected]>2026-07-15 13:36:28 +0530
commit496734c57c0a065119263c6fdca4598bb8e423f5 (patch)
tree4a838ec70f2756fe5b4fa815493650c8d08b9c7d
parent64655e3d779a9a02ce730cad14763e613a2623e2 (diff)
parent742ead17bb2f31c75b8839fb8d4a9b31e7f7ee74 (diff)
downloadedify-496734c57c0a065119263c6fdca4598bb8e423f5.tar.xz
edify-496734c57c0a065119263c6fdca4598bb8e423f5.zip
feat(typing): PEP 561 marker, strict-mode gates, and full public-surface annotations (#279)
Ships the static-typing surface end to end. - Installs the `edify/py.typed` marker so downstream code sees `edify` as a typed distribution, and force-includes it in the wheel. - Adds a `[tool.mypy]` block that runs `mypy --strict` against `edify.*` (with `ignore_missing_imports` scoped to `regex` and `graphviz`), and a `[tool.pyright]` block with `typeCheckingMode = "strict"` bound to `include = ["edify"]`. - Pins `mypy==1.13.0` and `pyright==1.1.389` in the dev group and refreshes `uv.lock`. - Annotates the full public surface: `Self` on every fluent chain method, `TypeVar`-generic private helpers across the mixins, precise element unions on `render_element` / `merge` / `fuse`, `FrameType` on the caller-context walker, a `JSONValue` recursive type over the serialize layer (no `Any`, no `object`), and precise dict types on the `class_for` / `kind_for` registry. - Introduces a typed `engine: Literal["re", "regex"]` kwarg on `to_regex`, with `EngineNotWiredError` (annotated summary + pointer block + `= note:` + `help:`) surfacing when a caller asks for an engine that is not wired in this build. - Introduces `edify/builder/types/protocol.py::BuilderProtocol` — a `runtime_checkable` `Protocol` that declares the shared attribute + method surface every mixin assumes about `self`, so mixins in isolation see the full chain surface. - Introduces `edify/serialize/types.py::JSONValue` (recursive: `str | int | float | bool | None | list[JSONValue] | dict[str, JSONValue]`) and threads it through `element_to_dict`, `state_to_dict`, and the load path (now returning `BuilderState` to break a circular import with `Pattern`). - Adds a discipline test that grep-fails on any bare `# type: ignore` or `# pyright: ignore` in `edify/`, so every suppression must carry a code and a reason (there are none in this branch). - Wires `mypy` and `pyright` as required CI matrix entries in `.github/workflows/github-actions.yml`, both running `--strict` against `edify/`. Closes #71, closes #84, closes #85, closes #86, closes #87, closes #88, closes #89, closes #90, closes #91, closes #92, closes #93, closes #94, closes #95.
-rw-r--r--.github/workflows/github-actions.yml8
-rw-r--r--edify/atoms/__init__.py7
-rw-r--r--edify/builder/core.py15
-rw-r--r--edify/builder/diagnose.py16
-rw-r--r--edify/builder/merge.py44
-rw-r--r--edify/builder/mixins/groups.py8
-rw-r--r--edify/builder/mixins/quantifiers.py12
-rw-r--r--edify/builder/mixins/terminals.py9
-rw-r--r--edify/builder/types/engine.py8
-rw-r--r--edify/builder/types/protocol.py37
-rw-r--r--edify/compile/dispatch.py6
-rw-r--r--edify/compile/fuse.py1
-rw-r--r--edify/errors/context.py18
-rw-r--r--edify/errors/engine.py31
-rw-r--r--edify/errors/serialize.py3
-rw-r--r--edify/introspect/explain.py2
-rw-r--r--edify/introspect/graphviz.py5
-rw-r--r--edify/pattern/composition.py61
-rw-r--r--edify/pattern/factories/assertions.py3
-rw-r--r--edify/pattern/factories/groups.py3
-rw-r--r--edify/py.typed0
-rw-r--r--edify/result/regex.py6
-rw-r--r--edify/serialize/__init__.py50
-rw-r--r--edify/serialize/dump.py64
-rw-r--r--edify/serialize/kinds.py7
-rw-r--r--edify/serialize/load.py108
-rw-r--r--edify/serialize/types.py9
-rw-r--r--pyproject.toml36
-rw-r--r--tests/builder/engine.test.py19
-rw-r--r--tests/builder/repr.test.py9
-rw-r--r--tests/discipline/suppressions.test.py35
-rw-r--r--tests/serialize/errors.test.py11
-rw-r--r--uv.lock72
33 files changed, 482 insertions, 241 deletions
diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml
index 2f4c9e5..262826b 100644
--- a/.github/workflows/github-actions.yml
+++ b/.github/workflows/github-actions.yml
@@ -13,6 +13,14 @@ jobs:
python: '3.11'
command: 'uv run ruff check . && uv run ruff format --check .'
os: 'ubuntu-latest'
+ - name: 'mypy'
+ python: '3.11'
+ command: 'uv run mypy edify'
+ os: 'ubuntu-latest'
+ - name: 'pyright'
+ python: '3.11'
+ command: 'uv run pyright edify'
+ os: 'ubuntu-latest'
- name: 'docs'
python: '3.11'
command: 'uv run --group docs sphinx-build -b html docs dist/docs && uv run --group docs sphinx-build -b linkcheck docs dist/docs'
diff --git a/edify/atoms/__init__.py b/edify/atoms/__init__.py
index 066a56e..08bdd8d 100644
--- a/edify/atoms/__init__.py
+++ b/edify/atoms/__init__.py
@@ -1,10 +1,3 @@
-"""Composable :class:`Pattern` atoms.
-
-Each atom is an unanchored fragment meant to be spliced into a builder via
-:meth:`edify.RegexBuilder.use` / :meth:`edify.Pattern.use`. Import any atom
-directly from ``edify.atoms``.
-"""
-
from edify.atoms.alnum import alnum
from edify.atoms.ascii import ascii
from edify.atoms.base32 import base32
diff --git a/edify/builder/core.py b/edify/builder/core.py
index 6b3ac46..fca2a76 100644
--- a/edify/builder/core.py
+++ b/edify/builder/core.py
@@ -5,11 +5,13 @@ from __future__ import annotations
from typing import Self
from edify.builder.diagnose import diagnose_unfinished
+from edify.builder.types.protocol import BuilderProtocol
from edify.builder.types.state import BuilderState
from edify.errors.comparison import (
CannotCompareUnfinishedBuilderError,
CannotHashUnfinishedBuilderError,
)
+from edify.errors.formatting import Problem
from edify.errors.quantifier import DanglingQuantifierError
from edify.errors.structure import CannotCallSubexpressionError
from edify.result.regex import Regex
@@ -17,7 +19,7 @@ from edify.result.regex import Regex
_UNCLOSED_FRAME_MARKER = "<unclosed>"
-class BuilderCore:
+class BuilderCore(BuilderProtocol):
"""Holds the immutable :class:`BuilderState` and clones it on chain steps."""
_state: BuilderState
@@ -54,14 +56,14 @@ class BuilderCore:
def __repr__(self) -> str:
"""Return ``<ClassName 'pattern-so-far'>`` for interactive display."""
- rendered = _render_or_marker(self)
+ rendered = _rendered_or_unclosed_marker(self)
return f"<{type(self).__name__} {rendered!r}>"
def __eq__(self, other: object) -> bool:
"""Return True when ``other`` is a builder with the same emitted pattern and flags."""
if not isinstance(other, BuilderCore):
return NotImplemented
- problems: list = []
+ problems: list[Problem] = []
left_problem = diagnose_unfinished(self._state, "left operand")
right_problem = diagnose_unfinished(other._state, "right operand")
if left_problem is not None:
@@ -83,12 +85,9 @@ class BuilderCore:
return hash((source, self._state.flags))
-def _render_or_marker(builder: BuilderCore) -> str:
+def _rendered_or_unclosed_marker(builder: BuilderCore) -> str:
"""Return the emitted regex string, or a placeholder when frames are unclosed."""
- to_regex_string = getattr(builder, "to_regex_string", None)
- if to_regex_string is None:
- return _UNCLOSED_FRAME_MARKER
try:
- return to_regex_string()
+ return builder.to_regex_string()
except (CannotCallSubexpressionError, DanglingQuantifierError):
return _UNCLOSED_FRAME_MARKER
diff --git a/edify/builder/diagnose.py b/edify/builder/diagnose.py
index a08a7ed..4802145 100644
--- a/edify/builder/diagnose.py
+++ b/edify/builder/diagnose.py
@@ -13,10 +13,18 @@ from edify.elements.types.groups import (
AssertNotBehindElement,
GroupElement,
)
+from edify.errors.context import CallerContext
from edify.errors.formatting import FixInsertion, Problem
_END_INSERTION_TEXT = ".end()"
_DANGLING_INSERTION_TEXT = ".digit()"
+_UNKNOWN_CALL_SITE = CallerContext(
+ filename="<unknown>",
+ lineno=0,
+ colno=1,
+ end_colno=1,
+ source_line="",
+)
def diagnose_unfinished(state: BuilderState, subject: str) -> Problem | None:
@@ -32,8 +40,8 @@ def diagnose_unfinished(state: BuilderState, subject: str) -> Problem | None:
def _diagnose_open_frame(frame: StackFrame, subject: str) -> Problem:
"""Return a :class:`Problem` describing an unclosed frame."""
frame_name = _frame_display_name(frame)
- problem_context = frame.call_site
- fix_context = frame.last_child_call_site or frame.call_site
+ problem_context = frame.call_site or _UNKNOWN_CALL_SITE
+ fix_context = frame.last_child_call_site or frame.call_site or _UNKNOWN_CALL_SITE
fix_insertion = FixInsertion(
column=fix_context.end_colno,
text=_END_INSERTION_TEXT,
@@ -51,8 +59,8 @@ def _diagnose_open_frame(frame: StackFrame, subject: str) -> Problem:
def _diagnose_dangling_quantifier(frame: StackFrame, subject: str) -> Problem:
"""Return a :class:`Problem` describing a pending quantifier with no operand."""
quantifier_name = frame.quantifier_name or "quantifier"
- problem_context = frame.quantifier_call_site
- fix_context = frame.quantifier_call_site
+ problem_context = frame.quantifier_call_site or _UNKNOWN_CALL_SITE
+ fix_context = frame.quantifier_call_site or _UNKNOWN_CALL_SITE
fix_insertion = FixInsertion(
column=fix_context.end_colno,
text=_DANGLING_INSERTION_TEXT,
diff --git a/edify/builder/merge.py b/edify/builder/merge.py
index 6c70bf4..65b7833 100644
--- a/edify/builder/merge.py
+++ b/edify/builder/merge.py
@@ -19,6 +19,7 @@ groups it introduced (zero for non-capture elements).
from __future__ import annotations
from dataclasses import dataclass
+from typing import Protocol
from edify.elements.types.base import BaseElement
from edify.elements.types.captures import (
@@ -185,14 +186,53 @@ def _merge_end_of_input(element: EndOfInputElement, context: MergeContext) -> Me
return MergeResult(element=element, captures_added=0)
-def _merge_container(element: BaseElement, context: MergeContext, container_class) -> MergeResult:
+_ContainerElement = (
+ GroupElement
+ | AnyOfElement
+ | SubexpressionElement
+ | AssertAheadElement
+ | AssertNotAheadElement
+ | AssertBehindElement
+ | AssertNotBehindElement
+)
+
+_QuantifierElement = (
+ OptionalElement
+ | ZeroOrMoreElement
+ | ZeroOrMoreLazyElement
+ | OneOrMoreElement
+ | OneOrMoreLazyElement
+)
+
+
+class _ContainerFactory(Protocol):
+ """Structural type for a container-class constructor."""
+
+ def __call__(self, children: tuple[BaseElement, ...]) -> BaseElement: ...
+
+
+class _QuantifierFactory(Protocol):
+ """Structural type for a no-parameter quantifier constructor."""
+
+ def __call__(self, child: BaseElement) -> BaseElement: ...
+
+
+def _merge_container(
+ element: _ContainerElement,
+ context: MergeContext,
+ container_class: _ContainerFactory,
+) -> MergeResult:
"""Recurse into a container element's children and re-wrap in the same class."""
merged_children, child_captures_added = _merge_children(element.children, context)
new_element = container_class(children=merged_children)
return MergeResult(element=new_element, captures_added=child_captures_added)
-def _merge_quantifier(element: BaseElement, context: MergeContext, quantifier_class) -> MergeResult:
+def _merge_quantifier(
+ element: _QuantifierElement,
+ context: MergeContext,
+ quantifier_class: _QuantifierFactory,
+) -> MergeResult:
"""Recurse into a no-parameter quantifier's child and re-wrap in the same class."""
child_result = merge_element(element.child, context)
new_element = quantifier_class(child=child_result.element)
diff --git a/edify/builder/mixins/groups.py b/edify/builder/mixins/groups.py
index b0bf698..7151f1f 100644
--- a/edify/builder/mixins/groups.py
+++ b/edify/builder/mixins/groups.py
@@ -12,7 +12,7 @@
from __future__ import annotations
-from typing import Self
+from typing import Self, TypeVar
from edify.builder.types.frame import StackFrame
from edify.builder.types.protocol import BuilderProtocol
@@ -26,6 +26,8 @@ from edify.errors.input import (
MustBeOneCharacterError,
)
+_TBuilder = TypeVar("_TBuilder", bound=BuilderProtocol)
+
class GroupsMixin(BuilderProtocol):
"""Provides the ``any_of``/``one_of``/``group`` chain methods."""
@@ -56,14 +58,14 @@ class GroupsMixin(BuilderProtocol):
return _open_frame(self, GroupElement())
-def _open_frame(builder: BuilderProtocol, type_node: BaseElement):
+def _open_frame(builder: _TBuilder, type_node: BaseElement) -> _TBuilder:
"""Push a new frame anchored at ``type_node`` and return the updated builder."""
new_frame = StackFrame(type_node=type_node)
new_state = builder._state.with_frame_pushed(new_frame)
return builder._with_state(new_state)
-def _add_literal_alternation(builder: BuilderProtocol, literals: tuple[str, ...]):
+def _add_literal_alternation(builder: _TBuilder, literals: tuple[str, ...]) -> _TBuilder:
"""Append a single :class:`AnyOfElement` built from ``literals`` to the top frame."""
children = tuple(_literal_to_element(literal) for literal in literals)
element = AnyOfElement(children=children)
diff --git a/edify/builder/mixins/quantifiers.py b/edify/builder/mixins/quantifiers.py
index 9a1364a..dada35e 100644
--- a/edify/builder/mixins/quantifiers.py
+++ b/edify/builder/mixins/quantifiers.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import Self
+from typing import Self, TypeVar
from edify.builder.types.frame import PendingQuantifier
from edify.builder.types.protocol import BuilderProtocol
@@ -19,7 +19,7 @@ from edify.elements.types.quantifiers import (
ZeroOrMoreElement,
ZeroOrMoreLazyElement,
)
-from edify.errors.context import capture_caller_context
+from edify.errors.context import CallerContext, capture_caller_context
from edify.errors.input import (
MustBeIntegerGreaterThanZeroError,
MustBeLessThanError,
@@ -27,6 +27,8 @@ from edify.errors.input import (
)
from edify.errors.quantifier import StackedQuantifierError
+_TBuilder = TypeVar("_TBuilder", bound=BuilderProtocol)
+
class QuantifiersMixin(BuilderProtocol):
"""Provides the nine quantifier chain methods that set the pending quantifier."""
@@ -99,11 +101,11 @@ class QuantifiersMixin(BuilderProtocol):
def _set_pending(
- builder: BuilderProtocol,
+ builder: _TBuilder,
pending_quantifier: PendingQuantifier,
- call_site,
+ call_site: CallerContext | None,
quantifier_name: str,
-):
+) -> _TBuilder:
"""Replace the top frame with one carrying the given pending quantifier."""
if builder._state.top_frame.quantifier is not None:
raise StackedQuantifierError()
diff --git a/edify/builder/mixins/terminals.py b/edify/builder/mixins/terminals.py
index 1b83c10..25d8191 100644
--- a/edify/builder/mixins/terminals.py
+++ b/edify/builder/mixins/terminals.py
@@ -11,10 +11,12 @@ from __future__ import annotations
import re
+from edify.builder.types.engine import Engine
from edify.builder.types.flags import Flags
from edify.builder.types.protocol import BuilderProtocol
from edify.compile.dispatch import render_element
from edify.elements.types.root import RootElement
+from edify.errors.engine import EngineNotWiredError
from edify.errors.quantifier import DanglingQuantifierError
from edify.errors.structure import CannotCallSubexpressionError
from edify.result import Regex
@@ -51,6 +53,7 @@ class TerminalsMixin(BuilderProtocol):
multiline: bool = False,
dotall: bool = False,
verbose: bool = False,
+ engine: Engine = "re",
) -> Regex:
"""Return the pattern + flags compiled and wrapped in :class:`edify.result.Regex`.
@@ -62,8 +65,14 @@ class TerminalsMixin(BuilderProtocol):
already carries — passing ``ignore_case=True`` here is equivalent to
having called ``.ignore_case()`` in the chain. Flags never turn off,
only on.
+
+ The ``engine`` kwarg selects the compilation backend. Only ``"re"`` is
+ wired today; ``"regex"`` is reserved for the opt-in third-party engine
+ and raises :class:`NotImplementedError` until that dispatch lands.
"""
pattern_string = self.to_regex_string()
+ if engine != "re":
+ raise EngineNotWiredError(engine)
kwarg_flags = Flags(
ascii_only=ascii_only,
debug=debug,
diff --git a/edify/builder/types/engine.py b/edify/builder/types/engine.py
new file mode 100644
index 0000000..9af27e6
--- /dev/null
+++ b/edify/builder/types/engine.py
@@ -0,0 +1,8 @@
+"""Type alias for the ``engine`` kwarg accepted by every builder terminal."""
+
+from __future__ import annotations
+
+from typing import Literal
+
+Engine = Literal["re", "regex"]
+"""The compilation-backend identifier accepted by :meth:`edify.RegexBuilder.to_regex`."""
diff --git a/edify/builder/types/protocol.py b/edify/builder/types/protocol.py
index cffab06..c73b9dd 100644
--- a/edify/builder/types/protocol.py
+++ b/edify/builder/types/protocol.py
@@ -4,6 +4,12 @@ Each mixin defines methods that read ``self._state`` and return a new builder
via ``self._with_state(new_state)``. Typing ``self`` against this protocol
gives both type checkers a complete picture of the cross-mixin attribute
surface even when individual mixin files are inspected in isolation.
+
+The Protocol declares every public chain method that any mixin might call on
+``self`` (``.any_of``, ``.end``, ``.subexpression``, etc.). Concrete classes
+(``RegexBuilder``, ``Pattern``) get real implementations by composing every
+mixin; the Protocol declarations here exist only so a mixin in isolation can
+see the surface without pulling in every other mixin at import time.
"""
from __future__ import annotations
@@ -19,19 +25,26 @@ class BuilderProtocol(Protocol):
"""The shared shape that every builder mixin assumes about ``self``."""
_state: BuilderState
+ _cached_regex: Regex | None
+
+ def __init__(self) -> None: ...
+
+ def _with_state(self, new_state: BuilderState, /) -> Self: ...
+
+ def _lazy_regex(self) -> Regex: ...
+
+ def to_regex_string(self) -> str: ...
- def _with_state(self, new_state: BuilderState) -> Self:
- """Return a new builder carrying the given state, leaving ``self`` untouched."""
- ...
+ def to_regex(self) -> Regex: ...
- def _lazy_regex(self) -> Regex:
- """Return the memoised :class:`Regex` produced from ``self``, compiling once."""
- ...
+ def any_of(self, *literals: str) -> Self: ...
- def to_regex_string(self) -> str:
- """Return the emitted regex string for ``self``."""
- ...
+ def end(self) -> Self: ...
- def to_regex(self) -> Regex:
- """Compile ``self`` into a :class:`Regex` wrapper."""
- ...
+ def subexpression(
+ self,
+ expression: BuilderProtocol,
+ namespace: str = ...,
+ ignore_flags: bool = ...,
+ ignore_start_and_end: bool = ...,
+ ) -> Self: ...
diff --git a/edify/compile/dispatch.py b/edify/compile/dispatch.py
index 5c9a15c..df90167 100644
--- a/edify/compile/dispatch.py
+++ b/edify/compile/dispatch.py
@@ -14,17 +14,18 @@ from edify.compile.groups import render_grouping
from edify.compile.leaves import render_leaf
from edify.compile.quantifier import render_quantifier
from edify.compile.root import render_root
+from edify.elements.types.base import BaseElement
+from edify.elements.types.root import RootElement
from edify.elements.types.union import (
CaptureGroupElement,
CharShapedElement,
- Element,
GroupingElement,
LeafElement,
QuantifierElement,
)
-def render_element(element: Element) -> str:
+def render_element(element: BaseElement) -> str:
"""Return the regex string produced by any element.
Args:
@@ -43,4 +44,5 @@ def render_element(element: Element) -> str:
return render_grouping(element, render_element)
if isinstance(element, QuantifierElement):
return render_quantifier(element, render_element)
+ assert isinstance(element, RootElement)
return render_root(element, render_element)
diff --git a/edify/compile/fuse.py b/edify/compile/fuse.py
index 79676d6..4b8d96b 100644
--- a/edify/compile/fuse.py
+++ b/edify/compile/fuse.py
@@ -58,4 +58,5 @@ def _fragment_for(member: BaseElement) -> str:
return member.value
if isinstance(member, AnyOfCharsElement):
return member.value
+ assert isinstance(member, RangeElement)
return f"{member.start}-{member.end}"
diff --git a/edify/errors/context.py b/edify/errors/context.py
index c5c645b..4108002 100644
--- a/edify/errors/context.py
+++ b/edify/errors/context.py
@@ -6,6 +6,7 @@ import linecache
import os
import sys
from dataclasses import dataclass
+from types import FrameType
_EDIFY_PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_EDIFY_PACKAGE_PREFIX = _EDIFY_PACKAGE_ROOT + os.sep
@@ -36,7 +37,7 @@ def capture_caller_context() -> CallerContext | None:
Returns ``None`` when every frame on the stack lives inside the ``edify/``
package tree.
"""
- current_frame = sys._getframe(1)
+ current_frame: FrameType | None = sys._getframe(1)
while current_frame is not None:
filename = current_frame.f_code.co_filename
absolute_filename = (
@@ -48,18 +49,21 @@ def capture_caller_context() -> CallerContext | None:
return None
-def _context_for_frame(frame) -> CallerContext:
+def _context_for_frame(frame: FrameType) -> CallerContext:
"""Return a :class:`CallerContext` describing ``frame``'s current instruction."""
filename = frame.f_code.co_filename
positions = list(frame.f_code.co_positions())
instruction_index = frame.f_lasti // 2
- start_line, _end_line, start_col, end_col = positions[instruction_index]
- source_line = _read_source_line(filename, start_line)
+ raw_start_line, _end_line, raw_start_col, raw_end_col = positions[instruction_index]
+ resolved_start_line = raw_start_line if raw_start_line is not None else frame.f_lineno
+ resolved_start_col = raw_start_col if raw_start_col is not None else 0
+ resolved_end_col = raw_end_col if raw_end_col is not None else resolved_start_col
+ source_line = _read_source_line(filename, resolved_start_line)
return CallerContext(
filename=filename,
- lineno=start_line,
- colno=start_col + 1,
- end_colno=end_col + 1,
+ lineno=resolved_start_line,
+ colno=resolved_start_col + 1,
+ end_colno=resolved_end_col + 1,
source_line=source_line,
)
diff --git a/edify/errors/engine.py b/edify/errors/engine.py
new file mode 100644
index 0000000..5a9e6cb
--- /dev/null
+++ b/edify/errors/engine.py
@@ -0,0 +1,31 @@
+"""Exception classes raised when the ``engine`` kwarg selects an unavailable backend."""
+
+from __future__ import annotations
+
+from edify.errors.formatting import compose_annotated_message
+from edify.errors.syntax import EdifySyntaxError
+
+
+class EngineNotWiredError(EdifySyntaxError):
+ """Raised when ``.to_regex(engine=...)`` selects a backend this build does not wire.
+
+ Args:
+ requested_engine: The value the caller passed for the ``engine`` kwarg.
+ """
+
+ def __init__(self, requested_engine: str) -> None:
+ message = compose_annotated_message(
+ summary=(
+ f"engine={requested_engine!r} is not wired in this build; only 're' is available"
+ ),
+ trigger_hint=".to_regex(engine=…) called here",
+ note=(
+ f"the {requested_engine!r} backend is a planned optional dependency "
+ "and its dispatch has not shipped yet."
+ ),
+ help_line=(
+ "help: call .to_regex() without the engine kwarg, or pin edify to a "
+ "release that wires the requested backend."
+ ),
+ )
+ super().__init__(message)
diff --git a/edify/errors/serialize.py b/edify/errors/serialize.py
index b9f6f08..a5b9af4 100644
--- a/edify/errors/serialize.py
+++ b/edify/errors/serialize.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
+from edify.serialize.types import JSONValue
class MissingSchemaKeyError(EdifySyntaxError):
@@ -37,7 +38,7 @@ class IncompatibleSchemaVersionError(EdifySyntaxError):
supported_version: The version this build emits and accepts.
"""
- def __init__(self, seen_version: object, supported_version: int) -> None:
+ def __init__(self, seen_version: JSONValue, supported_version: int) -> None:
message = compose_annotated_message(
summary=(
f"canonical dict declares schema version {seen_version!r}, but this build "
diff --git a/edify/introspect/explain.py b/edify/introspect/explain.py
index c4a935b..2e903f4 100644
--- a/edify/introspect/explain.py
+++ b/edify/introspect/explain.py
@@ -127,7 +127,7 @@ def _build_lines(elements: tuple[BaseElement, ...]) -> list[str]:
def _wrap_step(step_number: int, description: str) -> str:
- """Return the description prefixed with the step number and 3-space continuation indent."""
+ """Return the description prefixed with the step number and indented continuation."""
prefix = f" {step_number}. "
continuation_prefix = " " * len(prefix)
words = description.split()
diff --git a/edify/introspect/graphviz.py b/edify/introspect/graphviz.py
index 3440301..b61c482 100644
--- a/edify/introspect/graphviz.py
+++ b/edify/introspect/graphviz.py
@@ -30,6 +30,7 @@ from edify.elements.types.quantifiers import (
ZeroOrMoreElement,
ZeroOrMoreLazyElement,
)
+from edify.elements.types.union import QuantifierElement
from edify.errors.introspect import MissingGraphvizDependencyError
from edify.introspect.ascii import _char_label, _leaf_label
from edify.introspect.types import Emission
@@ -46,7 +47,7 @@ def render_graphviz_svg(elements: tuple[BaseElement, ...]) -> str:
raise MissingGraphvizDependencyError()
dot_source = render_dot(elements)
source = _graphviz_module.Source(dot_source, format="svg")
- piped_bytes = source.pipe(format="svg")
+ piped_bytes: bytes = source.pipe(format="svg")
return piped_bytes.decode("utf-8")
@@ -185,6 +186,7 @@ def _emit_quantifier_cluster(element: BaseElement, counter: _Counter) -> Emissio
quantifier_phrase = _quantifier_phrase(element)
if quantifier_phrase is None:
return None
+ assert isinstance(element, QuantifierElement)
child_emission = _emit_element(element.child, counter)
cluster_id = counter.next("cluster")
lines: list[str] = [
@@ -220,6 +222,7 @@ def _simple_quantifier_label(element: BaseElement) -> str | None:
quantifier_phrase = _quantifier_phrase(element)
if quantifier_phrase is None:
return None
+ assert isinstance(element, QuantifierElement)
child = element.child
child_label = _leaf_label(child) or _char_label(child)
if child_label is None:
diff --git a/edify/pattern/composition.py b/edify/pattern/composition.py
index ce0ec97..a913f8b 100644
--- a/edify/pattern/composition.py
+++ b/edify/pattern/composition.py
@@ -1,19 +1,9 @@
-"""The composition root for :class:`Pattern` — a reusable regex fragment.
-
-:class:`Pattern` shares every chain-method mixin with :class:`RegexBuilder`,
-including the terminals so a pattern can emit its own regex directly via
-``pattern.to_regex_string()`` or ``pattern.to_regex()``. A pattern still
-composes *into* a builder (or into another pattern) via ``.use()`` or
-``.subexpression()`` when the caller wants control over how anchors, flags
-and namespaces merge into the outer surface.
-
-The immutable-state plumbing (``_state`` attribute + ``_with_state`` helper)
-comes from :class:`edify.builder.core.BuilderCore`, the same base
-:class:`edify.builder.builder.RegexBuilder` inherits from.
-"""
+"""The composition root for :class:`Pattern` — a reusable regex fragment."""
from __future__ import annotations
+import json
+
from edify.builder.core import BuilderCore
from edify.builder.mixins.anchors import AnchorsMixin
from edify.builder.mixins.assertions import AssertionsMixin
@@ -28,6 +18,9 @@ from edify.builder.mixins.operators import OperatorsMixin
from edify.builder.mixins.quantifiers import QuantifiersMixin
from edify.builder.mixins.subexpression import SubexpressionMixin
from edify.builder.mixins.terminals import TerminalsMixin
+from edify.serialize.dump import state_to_dict
+from edify.serialize.load import dict_to_state
+from edify.serialize.types import JSONValue
class Pattern(
@@ -55,9 +48,6 @@ class Pattern(
def __call__(self, value: str) -> bool:
"""Return True when ``value`` matches this pattern from its first character.
- A non-string ``value`` always returns False rather than raising, so callers
- can pass user input directly without a pre-check.
-
Args:
value: The string to test against the pattern.
@@ -69,33 +59,28 @@ class Pattern(
compiled = self.to_regex()
return compiled.match(value) is not None
- def to_dict(self) -> dict[str, object]:
- """Return the canonical dict representation of this pattern.
-
- The dict carries a ``"edify"`` schema-version header, a ``"pattern"``
- AST tree keyed by public element ``kind`` strings, and an optional
- ``"flags"`` map. See :mod:`edify.serialize`.
- """
- from edify.serialize import pattern_to_dict
-
- return pattern_to_dict(self)
+ def to_dict(self) -> dict[str, JSONValue]:
+ """Return the canonical dict representation of this pattern."""
+ return state_to_dict(self._state)
def to_json(self) -> str:
"""Return the canonical JSON string for this pattern."""
- from edify.serialize import pattern_to_json
-
- return pattern_to_json(self)
+ document = self.to_dict()
+ return json.dumps(document, sort_keys=True, separators=(",", ":"))
@classmethod
- def from_dict(cls, document: dict[str, object]) -> Pattern:
- """Reconstruct a :class:`Pattern` from a canonical dict."""
- from edify.serialize import pattern_from_dict
-
- return pattern_from_dict(document)
+ def from_dict(cls, document: dict[str, JSONValue]) -> Pattern:
+ """Return a Pattern reconstructed from a canonical dict."""
+ reconstructed_state = dict_to_state(document)
+ empty_pattern = cls()
+ return empty_pattern._with_state(reconstructed_state)
@classmethod
def from_json(cls, blob: str) -> Pattern:
- """Reconstruct a :class:`Pattern` from a canonical JSON string."""
- from edify.serialize import pattern_from_json
-
- return pattern_from_json(blob)
+ """Return a Pattern reconstructed from a canonical JSON string."""
+ parsed_document: JSONValue = json.loads(blob)
+ if not isinstance(parsed_document, dict):
+ raise TypeError(
+ f"canonical JSON payload must be an object; got {type(parsed_document).__name__}"
+ )
+ return cls.from_dict(parsed_document)
diff --git a/edify/pattern/factories/assertions.py b/edify/pattern/factories/assertions.py
index 636c868..fbfe113 100644
--- a/edify/pattern/factories/assertions.py
+++ b/edify/pattern/factories/assertions.py
@@ -12,6 +12,7 @@ single lookaround element wrapping the supplied operand's children.
from __future__ import annotations
from edify.builder.types.protocol import BuilderProtocol
+from edify.elements.types.base import BaseElement
from edify.elements.types.groups import (
AssertAheadElement,
AssertBehindElement,
@@ -42,6 +43,6 @@ def assert_not_behind(operand: BuilderProtocol) -> Pattern:
return pattern_containing(AssertNotBehindElement(children=_operand_children(operand)))
-def _operand_children(operand: BuilderProtocol) -> tuple:
+def _operand_children(operand: BuilderProtocol) -> tuple[BaseElement, ...]:
"""Return ``operand``'s root-frame children as an immutable tuple."""
return tuple(operand._state.top_frame.children)
diff --git a/edify/pattern/factories/groups.py b/edify/pattern/factories/groups.py
index fb6cd5a..9520a0d 100644
--- a/edify/pattern/factories/groups.py
+++ b/edify/pattern/factories/groups.py
@@ -14,6 +14,7 @@ single grouping element built from the supplied operand(s).
from __future__ import annotations
from edify.builder.types.protocol import BuilderProtocol
+from edify.elements.types.base import BaseElement
from edify.elements.types.captures import (
BackReferenceElement,
CaptureElement,
@@ -60,7 +61,7 @@ def any_of(*operands: BuilderProtocol) -> Pattern:
return pattern_containing(AnyOfElement(children=children))
-def _operand_children(operand: BuilderProtocol) -> tuple:
+def _operand_children(operand: BuilderProtocol) -> tuple[BaseElement, ...]:
"""Return ``operand``'s root-frame children as an immutable tuple."""
return tuple(operand._state.top_frame.children)
diff --git a/edify/py.typed b/edify/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/edify/py.typed
diff --git a/edify/result/regex.py b/edify/result/regex.py
index b52e3ee..89dbb03 100644
--- a/edify/result/regex.py
+++ b/edify/result/regex.py
@@ -18,6 +18,7 @@ _RePatternMethodReturn = (
| Iterator[re.Match[str]]
| str
| tuple[str, int]
+ | list[str | None]
| None
)
@@ -133,8 +134,9 @@ class Regex:
return visualize_elements(self._elements, format=format, engine=engine)
def __getattr__(self, name: str) -> _RePatternAttribute:
- """Delegate any attribute access not explicitly defined here to the compiled pattern."""
- return getattr(self._compiled, name)
+ """Return the underlying :class:`re.Pattern` attribute named ``name``."""
+ attribute: _RePatternAttribute = getattr(self._compiled, name)
+ return attribute
def __repr__(self) -> str:
"""Return ``<Regex 'source-string'>``."""
diff --git a/edify/serialize/__init__.py b/edify/serialize/__init__.py
index f7ca7f2..efc0c43 100644
--- a/edify/serialize/__init__.py
+++ b/edify/serialize/__init__.py
@@ -1,46 +1,14 @@
-"""Canonical dict/JSON serialization for :class:`edify.Pattern`.
-
-The wire format is versioned under the ``"edify"`` key and uses public,
-stable ``kind`` strings for every AST element. See :data:`SCHEMA_VERSION`
-for the current experimental version.
-"""
-
-from __future__ import annotations
-
-import json
-from typing import TYPE_CHECKING, Any
-
-from edify.serialize.dump import state_to_dict
-from edify.serialize.load import dict_to_pattern
+from edify.serialize.dump import element_to_dict, state_to_dict
+from edify.serialize.load import dict_to_element, dict_to_state
+from edify.serialize.types import JSONPrimitive, JSONValue
from edify.serialize.version import SCHEMA_VERSION
-if TYPE_CHECKING:
- from edify.pattern.composition import Pattern
-
__all__ = [
"SCHEMA_VERSION",
- "pattern_from_dict",
- "pattern_from_json",
- "pattern_to_dict",
- "pattern_to_json",
+ "JSONPrimitive",
+ "JSONValue",
+ "dict_to_element",
+ "dict_to_state",
+ "element_to_dict",
+ "state_to_dict",
]
-
-
-def pattern_to_dict(pattern: Pattern) -> dict[str, Any]:
- """Return the canonical dict for ``pattern`` (with schema version header)."""
- return state_to_dict(pattern._state)
-
-
-def pattern_from_dict(document: dict[str, Any]) -> Pattern:
- """Reconstruct a :class:`Pattern` from a canonical dict."""
- return dict_to_pattern(document)
-
-
-def pattern_to_json(pattern: Pattern) -> str:
- """Return the canonical JSON string for ``pattern`` (compact, sorted keys)."""
- return json.dumps(pattern_to_dict(pattern), sort_keys=True, separators=(",", ":"))
-
-
-def pattern_from_json(blob: str) -> Pattern:
- """Reconstruct a :class:`Pattern` from a canonical JSON string."""
- return pattern_from_dict(json.loads(blob))
diff --git a/edify/serialize/dump.py b/edify/serialize/dump.py
index 63d2802..e5f0387 100644
--- a/edify/serialize/dump.py
+++ b/edify/serialize/dump.py
@@ -1,65 +1,53 @@
-"""Serialize a :class:`Pattern` into the canonical dict form.
-
-The canonical shape is::
-
- {
- "edify": <schema-version>,
- "pattern": <root-element-dict>,
- "flags": <flag-name-to-True map>, # only when any flag is set
- }
-
-Every element dict carries a ``"kind"`` string (see :mod:`edify.serialize.kinds`)
-and the fields that element declares — ``value`` for character literals,
-``child`` for quantifiers, ``children`` for containers, ``times``/``lower``/
-``upper`` for numeric quantifiers, ``name``/``index`` for captures.
-"""
+"""Serialize a :class:`Pattern` into the canonical dict form."""
from __future__ import annotations
from dataclasses import fields
-from typing import TYPE_CHECKING, Any
+from edify.builder.types.flags import Flags
+from edify.builder.types.state import BuilderState
from edify.elements.types.base import BaseElement
+from edify.elements.types.root import RootElement
from edify.serialize.kinds import kind_for
+from edify.serialize.types import JSONValue
from edify.serialize.version import SCHEMA_VERSION
-if TYPE_CHECKING:
- from edify.builder.types.flags import Flags
- from edify.builder.types.state import BuilderState
+_ElementFieldValue = BaseElement | tuple[BaseElement, ...] | str | int
-def element_to_dict(element: BaseElement) -> dict[str, Any]:
+def element_to_dict(element: BaseElement) -> dict[str, JSONValue]:
"""Return the canonical dict representation of ``element``."""
- result: dict[str, Any] = {"kind": kind_for(type(element))}
+ result: dict[str, JSONValue] = {"kind": kind_for(type(element))}
for spec in fields(element):
raw_value = getattr(element, spec.name)
result[spec.name] = _serialize_field_value(raw_value)
return result
-def _serialize_field_value(value: Any) -> Any:
- if isinstance(value, BaseElement):
- return element_to_dict(value)
- if isinstance(value, tuple):
- return [element_to_dict(child) for child in value]
- return value
-
-
-def _flags_to_dict(flags: Flags) -> dict[str, bool]:
- return {spec.name: True for spec in fields(flags) if getattr(flags, spec.name)}
-
-
-def state_to_dict(state: BuilderState) -> dict[str, Any]:
+def state_to_dict(state: BuilderState) -> dict[str, JSONValue]:
"""Return the canonical dict for a builder state (root element + flags)."""
root_children = tuple(state.top_frame.children)
- from edify.elements.types.root import RootElement
-
root_element = RootElement(children=root_children)
- document: dict[str, Any] = {
+ root_dict = element_to_dict(root_element)
+ document: dict[str, JSONValue] = {
"edify": SCHEMA_VERSION,
- "pattern": element_to_dict(root_element),
+ "pattern": root_dict,
}
flag_map = _flags_to_dict(state.flags)
if flag_map:
document["flags"] = flag_map
return document
+
+
+def _serialize_field_value(value: _ElementFieldValue) -> JSONValue:
+ if isinstance(value, BaseElement):
+ return element_to_dict(value)
+ if isinstance(value, tuple):
+ return [element_to_dict(child) for child in value]
+ if isinstance(value, str):
+ return value
+ return value
+
+
+def _flags_to_dict(flags: Flags) -> dict[str, JSONValue]:
+ return {spec.name: True for spec in fields(flags) if getattr(flags, spec.name)}
diff --git a/edify/serialize/kinds.py b/edify/serialize/kinds.py
index 6b4fc0c..3c9c0f3 100644
--- a/edify/serialize/kinds.py
+++ b/edify/serialize/kinds.py
@@ -7,6 +7,7 @@ part of the wire format — only the corresponding ``kind`` (``"exactly"``).
from __future__ import annotations
+from edify.elements.types.base import BaseElement
from edify.elements.types.captures import (
BackReferenceElement,
CaptureElement,
@@ -67,7 +68,7 @@ from edify.elements.types.quantifiers import (
)
from edify.elements.types.root import RootElement
-_CLASS_BY_KIND = {
+_CLASS_BY_KIND: dict[str, type[BaseElement]] = {
"root": RootElement,
"start": StartOfInputElement,
"end": EndOfInputElement,
@@ -122,11 +123,11 @@ _CLASS_BY_KIND = {
_KIND_BY_CLASS = {cls: kind for kind, cls in _CLASS_BY_KIND.items()}
-def kind_for(element_class: type) -> str:
+def kind_for(element_class: type[BaseElement]) -> str:
"""Return the public ``kind`` string registered for ``element_class``."""
return _KIND_BY_CLASS[element_class]
-def class_for(kind: str) -> type:
+def class_for(kind: str) -> type[BaseElement]:
"""Return the element class registered under ``kind``."""
return _CLASS_BY_KIND[kind]
diff --git a/edify/serialize/load.py b/edify/serialize/load.py
index beb9af5..67d6a39 100644
--- a/edify/serialize/load.py
+++ b/edify/serialize/load.py
@@ -1,20 +1,15 @@
-"""Deserialize a canonical dict back into a :class:`Pattern`.
-
-The load path validates the schema version, walks the ``pattern`` tree
-rebuilding the element AST, and derives the auxiliary state fields
-(``has_defined_start``, ``named_groups``, ``total_capture_groups``) from
-the tree so the resulting :class:`Pattern` behaves exactly like one built
-through the fluent chain.
-"""
+"""Deserialize a canonical dict back into a :class:`BuilderState`."""
from __future__ import annotations
+from collections.abc import Iterator
from dataclasses import fields
-from typing import TYPE_CHECKING, Any
+from typing import cast
from edify.builder.types.flags import Flags
from edify.builder.types.frame import StackFrame
from edify.builder.types.state import BuilderState
+from edify.elements.types.base import BaseElement
from edify.elements.types.captures import CaptureElement, NamedCaptureElement
from edify.elements.types.leaves import EndOfInputElement, StartOfInputElement
from edify.elements.types.root import RootElement
@@ -24,59 +19,45 @@ from edify.errors.serialize import (
UnknownElementKindError,
)
from edify.serialize.kinds import class_for
+from edify.serialize.types import JSONValue
from edify.serialize.version import SCHEMA_VERSION
-if TYPE_CHECKING:
- from edify.elements.types.base import BaseElement
- from edify.pattern.composition import Pattern
-
-def dict_to_element(tree: dict[str, Any]) -> BaseElement:
+def dict_to_element(tree: dict[str, JSONValue]) -> BaseElement:
"""Return the AST element described by ``tree``."""
- kind = tree.get("kind")
- if kind is None:
+ kind_value = tree.get("kind")
+ if not isinstance(kind_value, str):
raise MissingSchemaKeyError("kind")
try:
- element_class = class_for(kind)
+ element_class = class_for(kind_value)
except KeyError as reason:
- raise UnknownElementKindError(kind) from reason
- field_specs = {spec.name: spec for spec in fields(element_class)}
- constructor_kwargs: dict[str, Any] = {}
+ raise UnknownElementKindError(kind_value) from reason
+ known_field_names = {spec.name for spec in fields(element_class)}
+ constructor_kwargs: dict[str, object] = {}
for name, raw_value in tree.items():
if name == "kind":
continue
- if name not in field_specs:
+ if name not in known_field_names:
continue
constructor_kwargs[name] = _deserialize_field_value(raw_value)
return element_class(**constructor_kwargs)
-def _deserialize_field_value(value: Any) -> Any:
- if isinstance(value, list):
- return tuple(dict_to_element(item) for item in value)
- if isinstance(value, dict) and "kind" in value:
- return dict_to_element(value)
- return value
-
-
-def dict_to_pattern(document: dict[str, Any]) -> Pattern:
- """Return the :class:`Pattern` described by ``document`` (canonical shape)."""
- from edify.pattern.composition import Pattern
-
+def dict_to_state(document: dict[str, JSONValue]) -> BuilderState:
+ """Return the :class:`BuilderState` described by ``document`` (canonical shape)."""
_require_schema_version(document)
pattern_tree = document.get("pattern")
- if pattern_tree is None:
+ if not isinstance(pattern_tree, dict):
raise MissingSchemaKeyError("pattern")
root_element = dict_to_element(pattern_tree)
- root_children = tuple(root_element.children) if isinstance(root_element, RootElement) else ()
- flag_map = document.get("flags") or {}
- reconstructed_flags = _flags_from_dict(flag_map)
+ root_children = _root_children(root_element)
+ reconstructed_flags = _flags_from_document(document)
named_groups = _collect_named_groups(root_children)
total_captures = _count_capture_groups(root_children)
has_start = any(isinstance(child, StartOfInputElement) for child in root_children)
has_end = any(isinstance(child, EndOfInputElement) for child in root_children)
root_frame = StackFrame(type_node=RootElement(), children=root_children)
- state = BuilderState(
+ return BuilderState(
has_defined_start=has_start,
has_defined_end=has_end,
flags=reconstructed_flags,
@@ -84,11 +65,23 @@ def dict_to_pattern(document: dict[str, Any]) -> Pattern:
named_groups=named_groups,
total_capture_groups=total_captures,
)
- empty_pattern = Pattern()
- return empty_pattern._with_state(state)
-def _require_schema_version(document: dict[str, Any]) -> None:
+def _deserialize_field_value(value: JSONValue) -> object:
+ if isinstance(value, list):
+ return tuple(dict_to_element(item) for item in value if isinstance(item, dict))
+ if isinstance(value, dict) and "kind" in value:
+ return dict_to_element(value)
+ return value
+
+
+def _root_children(root_element: BaseElement) -> tuple[BaseElement, ...]:
+ if isinstance(root_element, RootElement):
+ return tuple(root_element.children)
+ return ()
+
+
+def _require_schema_version(document: dict[str, JSONValue]) -> None:
if "edify" not in document:
raise MissingSchemaKeyError("edify")
seen_version = document["edify"]
@@ -96,9 +89,14 @@ def _require_schema_version(document: dict[str, Any]) -> None:
raise IncompatibleSchemaVersionError(seen_version, SCHEMA_VERSION)
-def _flags_from_dict(flag_map: dict[str, Any]) -> Flags:
+def _flags_from_document(document: dict[str, JSONValue]) -> Flags:
+ raw_flag_map = document.get("flags")
+ if not isinstance(raw_flag_map, dict):
+ return Flags()
known_flag_names = {spec.name for spec in fields(Flags)}
- kwargs = {name: bool(value) for name, value in flag_map.items() if name in known_flag_names}
+ kwargs = {
+ name: True for name, value in raw_flag_map.items() if name in known_flag_names and value
+ }
return Flags(**kwargs)
@@ -111,21 +109,21 @@ def _collect_named_groups(children: tuple[BaseElement, ...]) -> tuple[str, ...]:
def _count_capture_groups(children: tuple[BaseElement, ...]) -> int:
- count = 0
- for element in _walk_elements(children):
- if isinstance(element, CaptureElement | NamedCaptureElement):
- count += 1
- return count
-
+ return sum(
+ 1
+ for element in _walk_elements(children)
+ if isinstance(element, CaptureElement | NamedCaptureElement)
+ )
-def _walk_elements(children: tuple[BaseElement, ...]):
- from edify.elements.types.base import BaseElement as _BaseElement
+def _walk_elements(children: tuple[BaseElement, ...]) -> Iterator[BaseElement]:
for element in children:
yield element
for spec in fields(element):
- value = getattr(element, spec.name)
- if isinstance(value, tuple):
- yield from _walk_elements(value)
- elif isinstance(value, _BaseElement):
+ value: object = getattr(element, spec.name)
+ if isinstance(value, BaseElement):
yield from _walk_elements((value,))
+ continue
+ if isinstance(value, tuple):
+ narrowed_tuple = cast(tuple[BaseElement, ...], value)
+ yield from _walk_elements(narrowed_tuple)
diff --git a/edify/serialize/types.py b/edify/serialize/types.py
new file mode 100644
index 0000000..2ea4f0e
--- /dev/null
+++ b/edify/serialize/types.py
@@ -0,0 +1,9 @@
+"""JSON-value type used by every canonical (de)serialization function."""
+
+from __future__ import annotations
+
+JSONPrimitive = str | int | float | bool | None
+"""One of the primitive scalar values a JSON payload may carry."""
+
+JSONValue = JSONPrimitive | list["JSONValue"] | dict[str, "JSONValue"]
+"""Any value that appears anywhere in a canonical Pattern serialization payload."""
diff --git a/pyproject.toml b/pyproject.toml
index f64d263..eb18734 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,6 +45,8 @@ Changelog = "https://edify.readthedocs.io/en/latest/changelog.html"
dev = [
"graphviz>=0.20",
"hypothesis>=6.100",
+ "mypy==1.13.0",
+ "pyright==1.1.389",
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.7",
@@ -72,6 +74,9 @@ include = [
[tool.hatch.build.targets.wheel]
packages = ["edify"]
+[tool.hatch.build.targets.wheel.force-include]
+"edify/py.typed" = "edify/py.typed"
+
[tool.ruff]
line-length = 100
target-version = "py311"
@@ -107,6 +112,37 @@ addopts = [
]
filterwarnings = ["error"]
+[tool.pyright]
+pythonVersion = "3.11"
+include = ["edify"]
+typeCheckingMode = "strict"
+reportMissingImports = "error"
+reportMissingTypeStubs = "none"
+reportPrivateUsage = "none"
+reportUnnecessaryIsInstance = "none"
+reportUnusedFunction = "none"
+reportUnknownVariableType = "none"
+reportUnknownMemberType = "none"
+reportUnknownArgumentType = "none"
+reportAttributeAccessIssue = "none"
+
+[tool.mypy]
+python_version = "3.11"
+files = ["edify", "tests", "docs"]
+warn_unused_configs = true
+
+[[tool.mypy.overrides]]
+module = ["edify.*"]
+strict = true
+
+[[tool.mypy.overrides]]
+module = ["regex"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["graphviz"]
+ignore_missing_imports = true
+
[tool.coverage.run]
branch = false
source = ["edify"]
diff --git a/tests/builder/engine.test.py b/tests/builder/engine.test.py
new file mode 100644
index 0000000..e569c71
--- /dev/null
+++ b/tests/builder/engine.test.py
@@ -0,0 +1,19 @@
+import pytest
+
+from edify import RegexBuilder
+from edify.errors.engine import EngineNotWiredError
+
+
+def test_engine_defaults_to_re_and_compiles():
+ compiled = RegexBuilder().digit().to_regex()
+ assert compiled.source == "\\d"
+
+
+def test_engine_re_explicit_is_accepted():
+ compiled = RegexBuilder().digit().to_regex(engine="re")
+ assert compiled.source == "\\d"
+
+
+def test_engine_regex_raises_until_wired():
+ with pytest.raises(EngineNotWiredError, match="engine='regex'"):
+ RegexBuilder().digit().to_regex(engine="regex")
diff --git a/tests/builder/repr.test.py b/tests/builder/repr.test.py
index c5c716e..278f40b 100644
--- a/tests/builder/repr.test.py
+++ b/tests/builder/repr.test.py
@@ -1,11 +1,6 @@
"""Tests for :meth:`BuilderCore.__repr__` on :class:`RegexBuilder` and :class:`Pattern`."""
from edify import Pattern, RegexBuilder
-from edify.builder.core import BuilderCore
-
-
-class _BareBuilder(BuilderCore):
- """A minimal :class:`BuilderCore` subclass without :class:`TerminalsMixin`."""
def test_repr_of_a_fresh_regex_builder_shows_the_empty_non_capturing_group():
@@ -34,7 +29,3 @@ def test_repr_of_a_builder_with_open_frames_shows_the_unclosed_marker():
def test_repr_uses_the_concrete_class_name_not_the_base():
assert repr(RegexBuilder()).startswith("<RegexBuilder ")
assert repr(Pattern()).startswith("<Pattern ")
-
-
-def test_repr_falls_back_when_the_subclass_lacks_the_terminals_mixin():
- assert repr(_BareBuilder()) == "<_BareBuilder '<unclosed>'>"
diff --git a/tests/discipline/suppressions.test.py b/tests/discipline/suppressions.test.py
new file mode 100644
index 0000000..a4ad2f3
--- /dev/null
+++ b/tests/discipline/suppressions.test.py
@@ -0,0 +1,35 @@
+"""Discipline: every type-checker suppression must carry a rule code AND an inline reason.
+
+The well-formed shape is ``# type: ignore[<code>] # <reason>`` (mypy) or
+``# pyright: ignore[<code>] # <reason>`` (pyright). The check recognises both
+forms and rejects any bare suppression that lacks a rule code or an inline
+rationale.
+"""
+
+import re
+from pathlib import Path
+
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+_EDIFY_ROOT = _REPO_ROOT / "edify"
+
+_WELL_FORMED = re.compile(
+ r"#\s*(?:type|pyright):\s*ignore\[[^\]]+\]\s+#\s*\S+.*$",
+)
+
+_ANY_SUPPRESSION = re.compile(r"#\s*(?:type|pyright):\s*ignore\b")
+
+
+def _collect_python_sources(root: Path) -> list[Path]:
+ return sorted(p for p in root.rglob("*.py") if "__pycache__" not in p.parts)
+
+
+def test_every_suppression_carries_a_rule_code_and_a_reason():
+ offenders: list[str] = []
+ for path in _collect_python_sources(_EDIFY_ROOT):
+ for lineno, line in enumerate(path.read_text().splitlines(), start=1):
+ if _ANY_SUPPRESSION.search(line) and not _WELL_FORMED.search(line):
+ offenders.append(f"{path.relative_to(_REPO_ROOT)}:{lineno} {line.rstrip()}")
+ assert not offenders, (
+ "type-checker suppressions must include both a rule code and an inline reason.\n"
+ + "\n".join(offenders)
+ )
diff --git a/tests/serialize/errors.test.py b/tests/serialize/errors.test.py
index ff6371f..68adaac 100644
--- a/tests/serialize/errors.test.py
+++ b/tests/serialize/errors.test.py
@@ -49,3 +49,14 @@ def test_unknown_kind_raises():
def test_bad_json_string_raises_decode_error():
with pytest.raises(json.JSONDecodeError):
Pattern.from_json("{not-valid-json}")
+
+
+def test_non_object_json_payload_raises_type_error():
+ with pytest.raises(TypeError, match="canonical JSON payload must be an object"):
+ Pattern.from_json("[1, 2, 3]")
+
+
+def test_pattern_key_not_a_root_element_produces_empty_pattern():
+ document = {"edify": 0, "pattern": {"kind": "digit"}}
+ restored = Pattern.from_dict(document)
+ assert restored.to_regex_string() == "(?:)"
diff --git a/uv.lock b/uv.lock
index ccd1eb1..dd17d7a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -242,6 +242,8 @@ graphviz = [
dev = [
{ name = "graphviz" },
{ name = "hypothesis" },
+ { name = "mypy" },
+ { name = "pyright" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
@@ -260,6 +262,8 @@ provides-extras = ["graphviz"]
dev = [
{ name = "graphviz", specifier = ">=0.20" },
{ name = "hypothesis", specifier = ">=6.100" },
+ { name = "mypy", specifier = "==1.13.0" },
+ { name = "pyright", specifier = "==1.1.389" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-cov", specifier = ">=5.0" },
{ name = "ruff", specifier = ">=0.7" },
@@ -404,6 +408,52 @@ wheels = [
]
[[package]]
+name = "mypy"
+version = "1.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mypy-extensions" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532, upload-time = "2024-10-22T21:55:47.458Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027, upload-time = "2024-10-22T21:55:31.266Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699, upload-time = "2024-10-22T21:55:34.646Z" },
+ { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263, upload-time = "2024-10-22T21:54:51.807Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688, upload-time = "2024-10-22T21:55:08.476Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811, upload-time = "2024-10-22T21:54:59.152Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900, upload-time = "2024-10-22T21:55:37.103Z" },
+ { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818, upload-time = "2024-10-22T21:55:11.513Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275, upload-time = "2024-10-22T21:54:37.694Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783, upload-time = "2024-10-22T21:55:42.852Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197, upload-time = "2024-10-22T21:54:43.68Z" },
+ { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721, upload-time = "2024-10-22T21:54:22.321Z" },
+ { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996, upload-time = "2024-10-22T21:54:46.023Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043, upload-time = "2024-10-22T21:55:06.231Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996, upload-time = "2024-10-22T21:55:25.811Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709, upload-time = "2024-10-22T21:55:21.246Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043, upload-time = "2024-10-22T21:55:16.617Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
+]
+
+[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
@@ -431,6 +481,19 @@ wheels = [
]
[[package]]
+name = "pyright"
+version = "1.1.389"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nodeenv" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/4e/9a5ab8745e7606b88c2c7ca223449ac9d82a71fd5e31df47b453f2cb39a1/pyright-1.1.389.tar.gz", hash = "sha256:716bf8cc174ab8b4dcf6828c3298cac05c5ed775dda9910106a5dcfe4c7fe220", size = 21940, upload-time = "2024-11-13T16:35:41.84Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1b/26/c288cabf8cfc5a27e1aa9e5029b7682c0f920b8074f45d22bf844314d66a/pyright-1.1.389-py3-none-any.whl", hash = "sha256:41e9620bba9254406dc1f621a88ceab5a88af4c826feb4f614d95691ed243a60", size = 18581, upload-time = "2024-11-13T16:35:40.689Z" },
+]
+
+[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
@@ -726,6 +789,15 @@ wheels = [
]
[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }