diff options
| author | natsuoto <[email protected]> | 2026-07-15 13:51:38 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-15 13:51:38 +0530 |
| commit | 34694646345ce2a43c12df6fcb214371f3ee06c0 (patch) | |
| tree | 65614e6da771dce243d16bac7440fa7591d9a1e0 | |
| parent | 414c548570df1c1e19b0982473e59b589802847c (diff) | |
| download | edify-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.py | 44 | ||||
| -rw-r--r-- | edify/compile/backend.py | 80 | ||||
| -rw-r--r-- | edify/errors/backend.py | 21 | ||||
| -rw-r--r-- | edify/errors/engine.py | 31 | ||||
| -rw-r--r-- | edify/result/regex.py | 61 | ||||
| -rw-r--r-- | tests/builder/engine.test.py | 42 | ||||
| -rw-r--r-- | tests/builder/flags.test.py | 45 |
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" |
