diff options
| author | natsuoto <[email protected]> | 2026-07-15 13:55:58 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-15 13:55:58 +0530 |
| commit | 2f69c608e319edb6b5af0133b4fddb2654aec96c (patch) | |
| tree | fcde675b6970bcd12c1656d5503e72942f139a91 | |
| parent | 34694646345ce2a43c12df6fcb214371f3ee06c0 (diff) | |
| download | edify-2f69c608e319edb6b5af0133b4fddb2654aec96c.tar.xz edify-2f69c608e319edb6b5af0133b4fddb2654aec96c.zip | |
feat(engine): unlock variable-width lookbehind under engine=regex; annotate the re-engine failure with an actionable error
| -rw-r--r-- | edify/compile/backend.py | 15 | ||||
| -rw-r--r-- | edify/errors/backend.py | 25 | ||||
| -rw-r--r-- | tests/builder/lookbehind.test.py | 59 |
3 files changed, 96 insertions, 3 deletions
diff --git a/edify/compile/backend.py b/edify/compile/backend.py index 581c4ff..d2bae12 100644 --- a/edify/compile/backend.py +++ b/edify/compile/backend.py @@ -16,7 +16,7 @@ from typing import cast from edify.builder.types.engine import Engine from edify.builder.types.flags import Flags -from edify.errors.backend import MissingRegexBackendError +from edify.errors.backend import MissingRegexBackendError, VariableWidthLookbehindNotSupportedError def load_regex_module() -> ModuleType: @@ -37,13 +37,24 @@ def compile_pattern(pattern: str, engine: Engine, flags: Flags) -> re.Pattern[st 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. + + Raises: + VariableWidthLookbehindNotSupportedError: when ``engine='re'`` and the + pattern uses a variable-width lookbehind body that stdlib re rejects. + MissingRegexBackendError: when ``engine='regex'`` and the ``regex`` module + is not installed. """ 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)) + try: + return re.compile(pattern, flags=_re_flag_bitmask(flags)) + except re.error as reason: + if "look-behind" in str(reason): + raise VariableWidthLookbehindNotSupportedError() from reason + raise def _re_flag_bitmask(flags: Flags) -> int: diff --git a/edify/errors/backend.py b/edify/errors/backend.py index 376354e..cd7650e 100644 --- a/edify/errors/backend.py +++ b/edify/errors/backend.py @@ -1,4 +1,4 @@ -"""Exception raised when a selected engine's backend module is not installed.""" +"""Exceptions raised at engine-dispatch time.""" from __future__ import annotations @@ -19,3 +19,26 @@ class MissingRegexBackendError(EdifySyntaxError): help_line="help: install the extra with `pip install edify[regex]` and retry.", ) super().__init__(message) + + +class VariableWidthLookbehindNotSupportedError(EdifySyntaxError): + """Raised when ``engine='re'`` compiles a lookbehind whose body is variable-width.""" + + def __init__(self) -> None: + message = compose_annotated_message( + summary=( + "assert_behind / assert_not_behind has a variable-width body, " + "which the stdlib 're' engine does not accept" + ), + trigger_hint=".to_regex(engine='re') called here", + note=( + "stdlib re requires every lookbehind branch to be fixed-width, so a " + "quantifier like +/*/?/{m,n} or a same-frame alternation with differing " + "branch widths inside a lookbehind will fail to compile." + ), + help_line=( + "help: switch to the third-party engine with .to_regex(engine='regex'), " + "which supports variable-width lookbehind." + ), + ) + super().__init__(message) diff --git a/tests/builder/lookbehind.test.py b/tests/builder/lookbehind.test.py new file mode 100644 index 0000000..f16a6cc --- /dev/null +++ b/tests/builder/lookbehind.test.py @@ -0,0 +1,59 @@ +"""Tests for lookbehind width behavior across the ``re`` and ``regex`` engines.""" + +import pytest + +from edify import RegexBuilder +from edify.errors.backend import VariableWidthLookbehindNotSupportedError + + +def _variable_width_lookbehind_builder(): + return RegexBuilder().assert_behind().between(1, 3).string("foo").end().string("bar") + + +def _fixed_width_lookbehind_builder(): + return RegexBuilder().assert_behind().string("foo").end().string("bar") + + +def test_variable_width_lookbehind_under_re_raises_annotated_error(): + with pytest.raises(VariableWidthLookbehindNotSupportedError) as excinfo: + _variable_width_lookbehind_builder().to_regex(engine="re") + text = str(excinfo.value) + assert "assert_behind" in text + assert "engine='regex'" in text + assert "= note:" in text + assert "help:" in text + + +def test_variable_width_lookbehind_under_regex_compiles_and_matches(): + compiled = _variable_width_lookbehind_builder().to_regex(engine="regex") + assert compiled.engine == "regex" + assert compiled.search("foobar") is not None + assert compiled.search("foofoobar") is not None + assert compiled.search("foofoofoobar") is not None + assert compiled.search("bar") is None + + +def test_fixed_width_lookbehind_still_works_under_re(): + compiled = _fixed_width_lookbehind_builder().to_regex(engine="re") + assert compiled.search("foobar") is not None + assert compiled.search("bar") is None + + +def test_variable_width_lookbehind_error_chains_the_underlying_pattern_error(): + with pytest.raises(VariableWidthLookbehindNotSupportedError) as excinfo: + _variable_width_lookbehind_builder().to_regex(engine="re") + import re + + assert isinstance(excinfo.value.__cause__, re.error) + + +def test_re_engine_still_surfaces_other_pattern_errors_unchanged(monkeypatch): + import re + + def raise_other_error(_pattern, flags=0): + raise re.error("some other syntax error") + + monkeypatch.setattr(re, "compile", raise_other_error) + with pytest.raises(re.error) as excinfo: + RegexBuilder().digit().to_regex(engine="re") + assert "some other syntax error" in str(excinfo.value) |
