aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-15 13:51:38 +0530
committernatsuoto <[email protected]>2026-07-15 13:51:38 +0530
commit34694646345ce2a43c12df6fcb214371f3ee06c0 (patch)
tree65614e6da771dce243d16bac7440fa7591d9a1e0
parent414c548570df1c1e19b0982473e59b589802847c (diff)
downloadedify-34694646345ce2a43c12df6fcb214371f3ee06c0.tar.xz
edify-34694646345ce2a43c12df6fcb214371f3ee06c0.zip
feat(engine): route to_regex(engine=re|regex) through a backend layer; regex is a deferred-import extra
-rw-r--r--edify/builder/mixins/terminals.py44
-rw-r--r--edify/compile/backend.py80
-rw-r--r--edify/errors/backend.py21
-rw-r--r--edify/errors/engine.py31
-rw-r--r--edify/result/regex.py61
-rw-r--r--tests/builder/engine.test.py42
-rw-r--r--tests/builder/flags.test.py45
7 files changed, 234 insertions, 90 deletions
diff --git a/edify/builder/mixins/terminals.py b/edify/builder/mixins/terminals.py
index 25d8191..de230db 100644
--- a/edify/builder/mixins/terminals.py
+++ b/edify/builder/mixins/terminals.py
@@ -4,19 +4,18 @@ Both methods require the builder to be fully specified: every opened frame
must have been closed with ``.end()`` so only the root frame remains. The
string terminal returns exactly the pattern you'd hand to ``re.compile``;
the compiled terminal performs that compilation with the current flag set
-and returns the emitted string wrapped in an :class:`edify.result.Regex`.
+via the selected engine backend, returning the emitted string wrapped in
+an :class:`edify.result.Regex`.
"""
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.backend import compile_pattern
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
@@ -58,21 +57,22 @@ class TerminalsMixin(BuilderProtocol):
"""Return the pattern + flags compiled and wrapped in :class:`edify.result.Regex`.
The wrapper exposes the pattern string as ``.source`` and the underlying
- :class:`re.Pattern` as ``.compiled``, plus the eight :mod:`re` query
- methods as direct delegates.
+ compiled pattern as ``.compiled``, plus the eight :mod:`re` query methods
+ as direct delegates against the selected engine backend.
Keyword arguments are OR-merged into the flag snapshot the builder
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.
+ The ``engine`` kwarg selects the compilation backend. ``"re"`` (default)
+ uses the stdlib :mod:`re` module. ``"regex"`` uses the third-party
+ ``regex`` module, which unlocks constructs the stdlib does not accept
+ (variable-width lookbehind, per-call timeouts). ``"regex"`` requires
+ ``pip install edify[regex]``; a clean :class:`MissingRegexBackendError`
+ surfaces when the extra is not installed.
"""
pattern_string = self.to_regex_string()
- if engine != "re":
- raise EngineNotWiredError(engine)
kwarg_flags = Flags(
ascii_only=ascii_only,
debug=debug,
@@ -82,12 +82,12 @@ class TerminalsMixin(BuilderProtocol):
verbose=verbose,
)
effective_flags = self._state.flags.with_merged(kwarg_flags)
- flag_bitmask = _build_flag_bitmask(effective_flags)
- compiled_pattern = re.compile(pattern_string, flags=flag_bitmask)
+ compiled_pattern = compile_pattern(pattern_string, engine, effective_flags)
return Regex(
source=pattern_string,
compiled=compiled_pattern,
elements=tuple(self._state.top_frame.children),
+ engine=engine,
)
@@ -104,21 +104,3 @@ def _ensure_no_dangling_quantifier(builder: BuilderProtocol) -> None:
for frame in builder._state.stack:
if frame.quantifier is not None:
raise DanglingQuantifierError()
-
-
-def _build_flag_bitmask(flags: Flags) -> int:
- """Combine the True-valued fields of ``flags`` into a single :mod:`re` bitmask."""
- bitmask = 0
- if flags.ascii_only:
- bitmask = bitmask | re.A
- if flags.debug:
- bitmask = bitmask | re.DEBUG
- if flags.ignore_case:
- bitmask = bitmask | re.I
- if flags.multiline:
- bitmask = bitmask | re.M
- if flags.dotall:
- bitmask = bitmask | re.S
- if flags.verbose:
- bitmask = bitmask | re.X
- return bitmask
diff --git a/edify/compile/backend.py b/edify/compile/backend.py
new file mode 100644
index 0000000..581c4ff
--- /dev/null
+++ b/edify/compile/backend.py
@@ -0,0 +1,80 @@
+"""Engine backends for compiling a rendered pattern string.
+
+The stdlib :mod:`re` backend is always available. The third-party ``regex``
+module is loaded lazily so ``import edify`` never imports it. Callers that
+select ``engine="regex"`` reach this module and receive a clean
+:class:`edify.errors.backend.MissingRegexBackendError` when the extra is
+not installed.
+"""
+
+from __future__ import annotations
+
+import re
+from importlib import import_module
+from types import ModuleType
+from typing import cast
+
+from edify.builder.types.engine import Engine
+from edify.builder.types.flags import Flags
+from edify.errors.backend import MissingRegexBackendError
+
+
+def load_regex_module() -> ModuleType:
+ """Return the third-party ``regex`` module, importing it on first call.
+
+ Raises:
+ MissingRegexBackendError: when the ``regex`` extra is not installed.
+ """
+ try:
+ return import_module("regex")
+ except ImportError as reason:
+ raise MissingRegexBackendError() from reason
+
+
+def compile_pattern(pattern: str, engine: Engine, flags: Flags) -> re.Pattern[str]:
+ """Compile ``pattern`` with the selected engine, applying ``flags`` as a bitmask.
+
+ The return type is declared as :class:`re.Pattern` so downstream call sites
+ stay strongly typed. Under ``engine="regex"`` the value is a ``regex.Pattern``
+ that mirrors :class:`re.Pattern`'s method surface.
+ """
+ if engine == "regex":
+ regex_module = load_regex_module()
+ return cast(
+ re.Pattern[str], regex_module.compile(pattern, _regex_flag_bitmask(regex_module, flags))
+ )
+ return re.compile(pattern, flags=_re_flag_bitmask(flags))
+
+
+def _re_flag_bitmask(flags: Flags) -> int:
+ bitmask = 0
+ if flags.ascii_only:
+ bitmask = bitmask | re.A
+ if flags.debug:
+ bitmask = bitmask | re.DEBUG
+ if flags.ignore_case:
+ bitmask = bitmask | re.I
+ if flags.multiline:
+ bitmask = bitmask | re.M
+ if flags.dotall:
+ bitmask = bitmask | re.S
+ if flags.verbose:
+ bitmask = bitmask | re.X
+ return bitmask
+
+
+def _regex_flag_bitmask(regex_module: ModuleType, flags: Flags) -> int:
+ bitmask = 0
+ if flags.ascii_only:
+ bitmask = bitmask | regex_module.A
+ if flags.debug:
+ bitmask = bitmask | regex_module.DEBUG
+ if flags.ignore_case:
+ bitmask = bitmask | regex_module.I
+ if flags.multiline:
+ bitmask = bitmask | regex_module.M
+ if flags.dotall:
+ bitmask = bitmask | regex_module.S
+ if flags.verbose:
+ bitmask = bitmask | regex_module.X
+ return bitmask
diff --git a/edify/errors/backend.py b/edify/errors/backend.py
new file mode 100644
index 0000000..376354e
--- /dev/null
+++ b/edify/errors/backend.py
@@ -0,0 +1,21 @@
+"""Exception raised when a selected engine's backend module is not installed."""
+
+from __future__ import annotations
+
+from edify.errors.formatting import compose_annotated_message
+from edify.errors.syntax import EdifySyntaxError
+
+
+class MissingRegexBackendError(EdifySyntaxError):
+ """Raised when ``.to_regex(engine="regex")`` runs without the ``regex`` module installed."""
+
+ def __init__(self) -> None:
+ message = compose_annotated_message(
+ summary="engine='regex' requires the 'regex' module, which is not installed",
+ trigger_hint=".to_regex(engine='regex') called here",
+ note=(
+ "the 'regex' engine is an opt-in extra so pip install edify stays dependency-free."
+ ),
+ help_line="help: install the extra with `pip install edify[regex]` and retry.",
+ )
+ super().__init__(message)
diff --git a/edify/errors/engine.py b/edify/errors/engine.py
deleted file mode 100644
index 5a9e6cb..0000000
--- a/edify/errors/engine.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""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/result/regex.py b/edify/result/regex.py
index 89dbb03..2627729 100644
--- a/edify/result/regex.py
+++ b/edify/result/regex.py
@@ -1,17 +1,19 @@
-"""The :class:`Regex` composition wrapper over :class:`re.Pattern`."""
+"""The :class:`Regex` composition wrapper over the compiled backend pattern."""
from __future__ import annotations
import re
import sys
from collections.abc import Callable, Iterator, Mapping
+from typing import Any, cast
+from edify.builder.types.engine import Engine
from edify.elements.types.base import BaseElement
from edify.introspect.explain import explain_elements
from edify.introspect.verbose import verbose_elements
from edify.introspect.visualize import visualize_elements
-_RePatternMethodReturn = (
+_MethodReturn = (
re.Match[str]
| list[str]
| list[tuple[str, ...]]
@@ -22,21 +24,23 @@ _RePatternMethodReturn = (
| None
)
-_RePatternAttribute = int | str | Mapping[str, int] | Callable[..., _RePatternMethodReturn]
+_PatternAttribute = int | str | Mapping[str, int] | Callable[..., _MethodReturn]
class Regex:
- """A compiled edify pattern; wraps :class:`re.Pattern` by composition."""
+ """A compiled edify pattern; wraps the selected engine's ``Pattern`` by composition."""
def __init__(
self,
source: str,
- compiled: re.Pattern[str],
+ compiled: Any,
elements: tuple[BaseElement, ...] = (),
+ engine: Engine = "re",
) -> None:
- self._source = source
- self._compiled = compiled
- self._elements = elements
+ self._source: str = source
+ self._compiled: re.Pattern[str] = cast(re.Pattern[str], compiled)
+ self._elements: tuple[BaseElement, ...] = elements
+ self._engine: Engine = engine
@property
def source(self) -> str:
@@ -45,7 +49,7 @@ class Regex:
@property
def compiled(self) -> re.Pattern[str]:
- """The underlying :class:`re.Pattern` for direct interop with :mod:`re`."""
+ """The underlying compiled ``Pattern`` for direct interop with the engine module."""
return self._compiled
@property
@@ -53,30 +57,35 @@ class Regex:
"""The AST elements produced by the builder, in emission order."""
return self._elements
+ @property
+ def engine(self) -> Engine:
+ """The engine identifier this pattern was compiled with."""
+ return self._engine
+
def match(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None:
- """Delegate to :meth:`re.Pattern.match`."""
+ """Delegate to the compiled pattern's ``match`` method."""
return self._compiled.match(string, pos, endpos)
def search(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None:
- """Delegate to :meth:`re.Pattern.search`."""
+ """Delegate to the compiled pattern's ``search`` method."""
return self._compiled.search(string, pos, endpos)
def fullmatch(
self, string: str, pos: int = 0, endpos: int = sys.maxsize
) -> re.Match[str] | None:
- """Delegate to :meth:`re.Pattern.fullmatch`."""
+ """Delegate to the compiled pattern's ``fullmatch`` method."""
return self._compiled.fullmatch(string, pos, endpos)
def findall(
self, string: str, pos: int = 0, endpos: int = sys.maxsize
) -> list[str] | list[tuple[str, ...]]:
- """Delegate to :meth:`re.Pattern.findall`."""
+ """Delegate to the compiled pattern's ``findall`` method."""
return self._compiled.findall(string, pos, endpos)
def finditer(
self, string: str, pos: int = 0, endpos: int = sys.maxsize
) -> Iterator[re.Match[str]]:
- """Delegate to :meth:`re.Pattern.finditer`."""
+ """Delegate to the compiled pattern's ``finditer`` method."""
return self._compiled.finditer(string, pos, endpos)
def sub(
@@ -85,7 +94,7 @@ class Regex:
string: str,
count: int = 0,
) -> str:
- """Delegate to :meth:`re.Pattern.sub`."""
+ """Delegate to the compiled pattern's ``sub`` method."""
return self._compiled.sub(replacement, string, count=count)
def subn(
@@ -94,11 +103,11 @@ class Regex:
string: str,
count: int = 0,
) -> tuple[str, int]:
- """Delegate to :meth:`re.Pattern.subn`."""
+ """Delegate to the compiled pattern's ``subn`` method."""
return self._compiled.subn(replacement, string, count=count)
def split(self, string: str, maxsplit: int = 0) -> list[str | None]:
- """Delegate to :meth:`re.Pattern.split`."""
+ """Delegate to the compiled pattern's ``split`` method."""
return self._compiled.split(string, maxsplit=maxsplit)
def explain(self) -> str:
@@ -133,9 +142,9 @@ class Regex:
"""
return visualize_elements(self._elements, format=format, engine=engine)
- def __getattr__(self, name: str) -> _RePatternAttribute:
- """Return the underlying :class:`re.Pattern` attribute named ``name``."""
- attribute: _RePatternAttribute = getattr(self._compiled, name)
+ def __getattr__(self, name: str) -> _PatternAttribute:
+ """Return the underlying compiled pattern's attribute named ``name``."""
+ attribute: _PatternAttribute = getattr(self._compiled, name)
return attribute
def __repr__(self) -> str:
@@ -143,11 +152,15 @@ class Regex:
return f"<Regex {self._source!r}>"
def __eq__(self, other: object) -> bool:
- """Return True when ``other`` is a Regex whose source and flags match."""
+ """Return True when ``other`` is a Regex whose source, engine, and flags match."""
if not isinstance(other, Regex):
return NotImplemented
- return self._source == other._source and self._compiled.flags == other._compiled.flags
+ return (
+ self._source == other._source
+ and self._engine == other._engine
+ and self._compiled.flags == other._compiled.flags
+ )
def __hash__(self) -> int:
- """Return a hash derived from the source and compiled flags."""
- return hash((self._source, self._compiled.flags))
+ """Return a hash derived from the source, engine, and compiled flags."""
+ return hash((self._source, self._engine, self._compiled.flags))
diff --git a/tests/builder/engine.test.py b/tests/builder/engine.test.py
index e569c71..c6a32d1 100644
--- a/tests/builder/engine.test.py
+++ b/tests/builder/engine.test.py
@@ -1,19 +1,53 @@
+import sys
+
import pytest
from edify import RegexBuilder
-from edify.errors.engine import EngineNotWiredError
+from edify.errors.backend import MissingRegexBackendError
def test_engine_defaults_to_re_and_compiles():
compiled = RegexBuilder().digit().to_regex()
assert compiled.source == "\\d"
+ assert compiled.engine == "re"
-def test_engine_re_explicit_is_accepted():
+def test_engine_re_explicit_compiles_via_stdlib():
compiled = RegexBuilder().digit().to_regex(engine="re")
assert compiled.source == "\\d"
+ assert compiled.engine == "re"
+ assert compiled.compiled.__class__.__module__ == "re"
+
+
+def test_engine_regex_compiles_via_third_party_module():
+ compiled = RegexBuilder().digit().to_regex(engine="regex")
+ assert compiled.source == "\\d"
+ assert compiled.engine == "regex"
+ assert compiled.compiled.__class__.__module__.endswith("regex")
+
+
+def test_engine_regex_and_engine_re_are_not_equal_for_the_same_source():
+ left = RegexBuilder().digit().to_regex(engine="re")
+ right = RegexBuilder().digit().to_regex(engine="regex")
+ assert left != right
-def test_engine_regex_raises_until_wired():
- with pytest.raises(EngineNotWiredError, match="engine='regex'"):
+def test_engine_regex_raises_clean_import_error_without_the_extra(monkeypatch):
+ monkeypatch.setitem(sys.modules, "regex", None)
+ with pytest.raises(MissingRegexBackendError, match="engine='regex'") as excinfo:
RegexBuilder().digit().to_regex(engine="regex")
+ text = str(excinfo.value)
+ assert "pip install edify[regex]" in text
+ assert "= note:" in text
+
+
+def test_missing_regex_backend_error_chains_from_underlying_import_error(monkeypatch):
+ monkeypatch.setitem(sys.modules, "regex", None)
+ with pytest.raises(MissingRegexBackendError) as excinfo:
+ RegexBuilder().digit().to_regex(engine="regex")
+ assert isinstance(excinfo.value.__cause__, ImportError)
+
+
+def test_regex_engine_flags_propagate():
+ compiled = RegexBuilder().string("ABC").to_regex(engine="regex", ignore_case=True)
+ assert bool(compiled.search("abc"))
diff --git a/tests/builder/flags.test.py b/tests/builder/flags.test.py
index d35d2ff..02e875e 100644
--- a/tests/builder/flags.test.py
+++ b/tests/builder/flags.test.py
@@ -70,3 +70,48 @@ def test_multiple_kwargs_combine():
assert compiled.compiled.flags & re.I == re.I
assert compiled.compiled.flags & re.M == re.M
assert compiled.compiled.flags & re.S == re.S
+
+
+def _regex_module():
+ import regex
+
+ return regex
+
+
+def test_regex_engine_ignore_case_kwarg_enables_the_flag():
+ regex = _regex_module()
+ compiled = RegexBuilder().string("hi").to_regex(engine="regex", ignore_case=True)
+ assert compiled.compiled.flags & regex.I == regex.I
+
+
+def test_regex_engine_multiline_kwarg_enables_the_flag():
+ regex = _regex_module()
+ compiled = RegexBuilder().string("hi").to_regex(engine="regex", multiline=True)
+ assert compiled.compiled.flags & regex.M == regex.M
+
+
+def test_regex_engine_dotall_kwarg_enables_the_flag():
+ regex = _regex_module()
+ compiled = RegexBuilder().string("hi").to_regex(engine="regex", dotall=True)
+ assert compiled.compiled.flags & regex.S == regex.S
+
+
+def test_regex_engine_ascii_only_kwarg_enables_the_flag():
+ regex = _regex_module()
+ compiled = RegexBuilder().string("hi").to_regex(engine="regex", ascii_only=True)
+ assert compiled.compiled.flags & regex.A == regex.A
+
+
+def test_regex_engine_verbose_kwarg_enables_the_flag():
+ regex = _regex_module()
+ compiled = RegexBuilder().string("hi").to_regex(engine="regex", verbose=True)
+ assert compiled.compiled.flags & regex.X == regex.X
+
+
+ _ON_PYPY,
+ reason="PyPy's regex.DEBUG opcode disassembler shares the same upstream disassembler bug",
+)
+def test_regex_engine_debug_kwarg_compiles_without_error():
+ compiled = RegexBuilder().string("hi").to_regex(engine="regex", debug=True)
+ assert compiled.source == "hi"