diff options
| author | Bobby <[email protected]> | 2026-07-01 17:50:29 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-07-01 17:50:29 +0530 |
| commit | c757ea1fea447df3ed69e74b407ca9710a9f85e0 (patch) | |
| tree | a710f73b1a8b7b0117f41a18c9accd01127b033e | |
| parent | ad4eb75ddcadf263391bba8eed837cce3b860cf9 (diff) | |
| parent | e0d5e70a0d1a35a1e9311aa8200fb5c5f2ca0854 (diff) | |
| download | edify-c757ea1fea447df3ed69e74b407ca9710a9f85e0.tar.xz edify-c757ea1fea447df3ed69e74b407ca9710a9f85e0.zip | |
feat!: Regex result wrapper on .to_regex(), plus flag kwargs at the terminal (#270)
Reshapes what `.to_regex()` returns and takes, so pattern compilation
becomes a first-class edify value.
## `Regex` result wrapper class
`edify.Regex` is a composition wrapper over `re.Pattern` (which is a
CPython C type and cannot be subclassed). It exposes:
- `.source` — the emitted regex string.
- `.compiled` — the underlying `re.Pattern` for callers that need
identity checks, `isinstance` checks, or direct interop with libraries
typed on `re.Pattern`.
- Eight direct delegates — `.match()`, `.search()`, `.fullmatch()`,
`.findall()`, `.finditer()`, `.sub()`, `.subn()`, `.split()` — mirroring
the `re.Pattern` query surface.
- Value equality on `(source, flags)` plus `__hash__`.
## `to_regex()` now returns `Regex`
The terminal returns `edify.Regex` instead of raw `re.Pattern`. The
existing eight query methods continue to work via delegation. `.match()`
/ `.search()` / … on builders (via `MatcherMixin`) still work because
they go through the same delegation.
**Breaking:** `isinstance(x, re.Pattern)` and `re.Pattern`-typed
annotations on values returned from `.to_regex()` break. Callers that
need the raw pattern read `.compiled`.
## Flag kwargs on `.to_regex(...)`
`.to_regex()` now accepts six keyword-only flag arguments —
`ascii_only`, `debug`, `ignore_case`, `multiline`, `dotall`, `verbose` —
that OR-merge into the flag snapshot the builder already carries.
Passing `ignore_case=True` at the terminal is equivalent to calling
`.ignore_case()` mid-chain. Kwargs never turn a chain-set flag off; the
terminal is the natural home for pattern-global settings.
## Example
```python
from edify import RegexBuilder
email = RegexBuilder().start_of_input().one_or_more().letter().end_of_input()
regex = email.to_regex(ignore_case=True)
regex.source # ^[a-zA-Z]+$
regex.match("Hello") # <re.Match ...>
regex.compiled # <re.Pattern object; flags=re.IGNORECASE>
```
Closes #133
Closes #134
Closes #128
| -rw-r--r-- | edify/__init__.py | 2 | ||||
| -rw-r--r-- | edify/builder/mixins/terminals.py | 41 | ||||
| -rw-r--r-- | edify/result/__init__.py | 3 | ||||
| -rw-r--r-- | edify/result/regex.py | 98 | ||||
| -rw-r--r-- | tests/builder/builder.test.py | 2 | ||||
| -rw-r--r-- | tests/builder/flags.test.py | 72 | ||||
| -rw-r--r-- | tests/builder/use.test.py | 2 | ||||
| -rw-r--r-- | tests/pattern/composition.test.py | 2 | ||||
| -rw-r--r-- | tests/result/regex.test.py | 97 |
9 files changed, 311 insertions, 8 deletions
diff --git a/edify/__init__.py b/edify/__init__.py index 9c0dee5..48387a6 100644 --- a/edify/__init__.py +++ b/edify/__init__.py @@ -52,6 +52,7 @@ from edify.pattern.factories import ( zero_or_more, zero_or_more_lazy, ) +from edify.result import Regex def _resolve_installed_version() -> str: @@ -87,6 +88,7 @@ __all__ = [ "EdifyError", "EdifySyntaxError", "Pattern", + "Regex", "RegexBuilder", "__version__", "any_of", diff --git a/edify/builder/mixins/terminals.py b/edify/builder/mixins/terminals.py index b648b77..0887afa 100644 --- a/edify/builder/mixins/terminals.py +++ b/edify/builder/mixins/terminals.py @@ -3,7 +3,8 @@ 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. +the compiled terminal performs that compilation with the current flag set +and returns the emitted string wrapped in an :class:`edify.result.Regex`. """ from __future__ import annotations @@ -17,6 +18,7 @@ from edify.elements.types.root import RootElement from edify.errors.internal import FailedToCompileRegexError from edify.errors.quantifier import DanglingQuantifierError from edify.errors.structure import CannotCallSubexpressionError +from edify.result import Regex _EMPTY_NON_CAPTURING_GROUP = "(?:)" _ESCAPED_SPACE = "\\ " @@ -41,14 +43,43 @@ class TerminalsMixin(BuilderProtocol): return _EMPTY_NON_CAPTURING_GROUP return unescaped_pattern - def to_regex(self) -> re.Pattern[str]: - """Return a :class:`re.Pattern` compiled from the builder's pattern + flags.""" + def to_regex( + self, + *, + ascii_only: bool = False, + debug: bool = False, + ignore_case: bool = False, + multiline: bool = False, + dotall: bool = False, + verbose: bool = False, + ) -> Regex: + """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. + + 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. + """ pattern_string = self.to_regex_string() - flag_bitmask = _build_flag_bitmask(self._state.flags) + kwarg_flags = Flags( + ascii_only=ascii_only, + debug=debug, + ignore_case=ignore_case, + multiline=multiline, + dotall=dotall, + verbose=verbose, + ) + effective_flags = self._state.flags.with_merged(kwarg_flags) + flag_bitmask = _build_flag_bitmask(effective_flags) try: - return re.compile(pattern_string, flags=flag_bitmask) + compiled_pattern = re.compile(pattern_string, flags=flag_bitmask) except re.error as compile_error: raise FailedToCompileRegexError(str(compile_error)) from compile_error + return Regex(source=pattern_string, compiled=compiled_pattern) def _ensure_fully_specified(builder: BuilderProtocol) -> None: diff --git a/edify/result/__init__.py b/edify/result/__init__.py index e69de29..457f79e 100644 --- a/edify/result/__init__.py +++ b/edify/result/__init__.py @@ -0,0 +1,3 @@ +from edify.result.regex import Regex + +__all__ = ["Regex"] diff --git a/edify/result/regex.py b/edify/result/regex.py new file mode 100644 index 0000000..9dbcd63 --- /dev/null +++ b/edify/result/regex.py @@ -0,0 +1,98 @@ +""":class:`Regex` — composition wrapper over :class:`re.Pattern`. + +:class:`re.Pattern` is a CPython C type and cannot be subclassed, so we +compose: :class:`Regex` holds both the source string and the compiled +:class:`re.Pattern`, exposing them as ``.source`` and ``.compiled``, and +delegates the eight canonical query methods (``match``, ``search``, +``fullmatch``, ``findall``, ``finditer``, ``sub``, ``subn``, ``split``) +to the underlying compiled pattern. + +This wrapper is what :meth:`edify.builder.mixins.terminals.TerminalsMixin.to_regex` +returns; the raw :class:`re.Pattern` is still available via ``.compiled``. +""" + +from __future__ import annotations + +import re +import sys +from collections.abc import Callable, Iterator + + +class Regex: + """A compiled edify pattern; wraps :class:`re.Pattern` by composition.""" + + def __init__(self, source: str, compiled: re.Pattern[str]) -> None: + self._source = source + self._compiled = compiled + + @property + def source(self) -> str: + """The regex string that produced this wrapper (identical to :meth:`re.Pattern.pattern`).""" + return self._source + + @property + def compiled(self) -> re.Pattern[str]: + """The underlying :class:`re.Pattern`; callers that need identity checks read this.""" + return self._compiled + + def match(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None: + """Delegate to :meth:`re.Pattern.match`.""" + 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`.""" + 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`.""" + 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`.""" + 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`.""" + return self._compiled.finditer(string, pos, endpos) + + def sub( + self, + replacement: str | Callable[[re.Match[str]], str], + string: str, + count: int = 0, + ) -> str: + """Delegate to :meth:`re.Pattern.sub`.""" + return self._compiled.sub(replacement, string, count=count) + + def subn( + self, + replacement: str | Callable[[re.Match[str]], str], + string: str, + count: int = 0, + ) -> tuple[str, int]: + """Delegate to :meth:`re.Pattern.subn`.""" + 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`.""" + return self._compiled.split(string, maxsplit=maxsplit) + + def __repr__(self) -> str: + """Return ``<Regex 'source-string'>``.""" + return f"<Regex {self._source!r}>" + + def __eq__(self, other: object) -> bool: + """Return True when ``other`` is a Regex whose source and flags match.""" + if not isinstance(other, Regex): + return NotImplemented + return self._source == other._source 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)) diff --git a/tests/builder/builder.test.py b/tests/builder/builder.test.py index ddd1997..82aa1cf 100644 --- a/tests/builder/builder.test.py +++ b/tests/builder/builder.test.py @@ -47,7 +47,7 @@ def regex_equality(regex, rb_expression): def regex_compilation(regex, rb_expression, f=0): rb_expression_c = rb_expression.to_regex() - assert re.compile(regex, flags=f) == rb_expression_c + assert re.compile(regex, flags=f) == rb_expression_c.compiled def test_empty_regex(): diff --git a/tests/builder/flags.test.py b/tests/builder/flags.test.py new file mode 100644 index 0000000..d35d2ff --- /dev/null +++ b/tests/builder/flags.test.py @@ -0,0 +1,72 @@ +"""Tests for the flag keyword arguments on :meth:`TerminalsMixin.to_regex`.""" + +import re +import sys + +import pytest + +from edify import Pattern, RegexBuilder + +_ON_PYPY = hasattr(sys, "pypy_version_info") + + +def test_no_kwargs_uses_only_the_chain_flag_snapshot(): + compiled = RegexBuilder().string("hi").to_regex() + assert compiled.compiled.flags & re.I == 0 + + +def test_ignore_case_kwarg_enables_the_ignore_case_flag(): + compiled = RegexBuilder().string("hi").to_regex(ignore_case=True) + assert compiled.compiled.flags & re.I == re.I + + +def test_multiline_kwarg_enables_the_multiline_flag(): + compiled = RegexBuilder().string("hi").to_regex(multiline=True) + assert compiled.compiled.flags & re.M == re.M + + +def test_dotall_kwarg_enables_the_dotall_flag(): + compiled = RegexBuilder().string("hi").to_regex(dotall=True) + assert compiled.compiled.flags & re.S == re.S + + +def test_ascii_only_kwarg_enables_the_ascii_flag(): + compiled = RegexBuilder().string("hi").to_regex(ascii_only=True) + assert compiled.compiled.flags & re.A == re.A + + +def test_verbose_kwarg_enables_the_verbose_flag(): + compiled = RegexBuilder().string("hi").to_regex(verbose=True) + assert compiled.compiled.flags & re.X == re.X + + + _ON_PYPY, + reason="PyPy's re.DEBUG opcode disassembler has an upstream IndexError bug", +) +def test_debug_kwarg_compiles_without_error(): + compiled = RegexBuilder().string("hi").to_regex(debug=True) + assert compiled.source == "hi" + + +def test_kwargs_or_merge_with_chain_flags(): + compiled = RegexBuilder().ignore_case().string("hi").to_regex(multiline=True) + assert compiled.compiled.flags & re.I == re.I + assert compiled.compiled.flags & re.M == re.M + + +def test_kwargs_never_turn_off_a_chain_flag(): + compiled = RegexBuilder().ignore_case().string("hi").to_regex(ignore_case=False) + assert compiled.compiled.flags & re.I == re.I + + +def test_kwargs_work_on_pattern_too(): + compiled = Pattern().string("hi").to_regex(ignore_case=True) + assert compiled.compiled.flags & re.I == re.I + + +def test_multiple_kwargs_combine(): + compiled = RegexBuilder().string("hi").to_regex(ignore_case=True, multiline=True, dotall=True) + assert compiled.compiled.flags & re.I == re.I + assert compiled.compiled.flags & re.M == re.M + assert compiled.compiled.flags & re.S == re.S diff --git a/tests/builder/use.test.py b/tests/builder/use.test.py index 10d7855..0253786 100644 --- a/tests/builder/use.test.py +++ b/tests/builder/use.test.py @@ -21,7 +21,7 @@ def test_use_drops_the_pattern_flag_snapshot_by_default(): case_insensitive_pattern = Pattern().ignore_case().string("hello") expression = RegexBuilder().use(case_insensitive_pattern) compiled = expression.to_regex() - assert compiled.flags & 2 == 0 + assert compiled.compiled.flags & 2 == 0 def test_use_accepts_another_regex_builder_as_the_source(): diff --git a/tests/pattern/composition.test.py b/tests/pattern/composition.test.py index f58df57..18a6bc2 100644 --- a/tests/pattern/composition.test.py +++ b/tests/pattern/composition.test.py @@ -20,7 +20,7 @@ def test_pattern_exposes_to_regex_string_terminal(): def test_pattern_exposes_to_regex_terminal(): pattern = Pattern().digit() compiled = pattern.to_regex() - assert compiled.pattern == "\\d" + assert compiled.source == "\\d" def test_pattern_supports_nested_use_composition(): diff --git a/tests/result/regex.test.py b/tests/result/regex.test.py new file mode 100644 index 0000000..d6f2a64 --- /dev/null +++ b/tests/result/regex.test.py @@ -0,0 +1,97 @@ +"""Tests for the :class:`edify.result.Regex` wrapper class.""" + +import re + +import pytest + +from edify.result import Regex + + +def digit_regex() -> Regex: + return Regex("\\d+", re.compile("\\d+")) + + +def test_source_returns_the_pattern_string(digit_regex): + assert digit_regex.source == "\\d+" + + +def test_compiled_returns_the_underlying_re_pattern(digit_regex): + assert isinstance(digit_regex.compiled, re.Pattern) + + +def test_compiled_pattern_matches_the_source(digit_regex): + assert digit_regex.compiled.pattern == "\\d+" + + +def test_match_delegates_to_the_compiled_pattern(digit_regex): + hit = digit_regex.match("123") + assert hit is not None + assert hit.group() == "123" + + +def test_search_delegates_to_the_compiled_pattern(digit_regex): + hit = digit_regex.search("abc 456 def") + assert hit is not None + assert hit.group() == "456" + + +def test_fullmatch_delegates_to_the_compiled_pattern(digit_regex): + assert digit_regex.fullmatch("789") is not None + assert digit_regex.fullmatch("7x9") is None + + +def test_findall_delegates_to_the_compiled_pattern(digit_regex): + assert digit_regex.findall("1 2 3") == ["1", "2", "3"] + + +def test_finditer_delegates_to_the_compiled_pattern(digit_regex): + hits = list(digit_regex.finditer("42 99")) + assert [match.group() for match in hits] == ["42", "99"] + + +def test_sub_delegates_to_the_compiled_pattern(digit_regex): + assert digit_regex.sub("[X]", "hi 12 there 34") == "hi [X] there [X]" + + +def test_subn_delegates_to_the_compiled_pattern(digit_regex): + result, count = digit_regex.subn("[X]", "1 2 3") + assert result == "[X] [X] [X]" + assert count == 3 + + +def test_split_delegates_to_the_compiled_pattern(digit_regex): + assert digit_regex.split("hi1there2end") == ["hi", "there", "end"] + + +def test_repr_shows_the_source(digit_regex): + assert repr(digit_regex) == "<Regex '\\\\d+'>" + + +def test_equal_when_source_and_flags_match(): + a = Regex("\\d+", re.compile("\\d+")) + b = Regex("\\d+", re.compile("\\d+")) + assert a == b + + +def test_not_equal_when_source_differs(): + a = Regex("\\d+", re.compile("\\d+")) + b = Regex("\\w+", re.compile("\\w+")) + assert a != b + + +def test_not_equal_when_flags_differ(): + a = Regex("\\d+", re.compile("\\d+")) + b = Regex("\\d+", re.compile("\\d+", flags=re.IGNORECASE)) + assert a != b + + +def test_hash_matches_when_equal(): + a = Regex("\\d+", re.compile("\\d+")) + b = Regex("\\d+", re.compile("\\d+")) + assert hash(a) == hash(b) + + +def test_equality_with_non_regex_returns_not_implemented(): + a = Regex("\\d+", re.compile("\\d+")) + assert a.__eq__("foo") is NotImplemented |
