aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-07-01 17:50:29 +0530
committerGitHub <[email protected]>2026-07-01 17:50:29 +0530
commitc757ea1fea447df3ed69e74b407ca9710a9f85e0 (patch)
treea710f73b1a8b7b0117f41a18c9accd01127b033e
parentad4eb75ddcadf263391bba8eed837cce3b860cf9 (diff)
parente0d5e70a0d1a35a1e9311aa8200fb5c5f2ca0854 (diff)
downloadedify-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__.py2
-rw-r--r--edify/builder/mixins/terminals.py41
-rw-r--r--edify/result/__init__.py3
-rw-r--r--edify/result/regex.py98
-rw-r--r--tests/builder/builder.test.py2
-rw-r--r--tests/builder/flags.test.py72
-rw-r--r--tests/builder/use.test.py2
-rw-r--r--tests/pattern/composition.test.py2
-rw-r--r--tests/result/regex.test.py97
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