aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-15 14:24:05 +0530
committernatsuoto <[email protected]>2026-07-15 14:24:05 +0530
commite8e9e49469f81e296abd98ff040914361107d891 (patch)
treebb27a0f2179920a9aa91ab28ca8c522f7095e006
parentb396a5bb3655a2f1a9250ef46beb574445512328 (diff)
downloadedify-e8e9e49469f81e296abd98ff040914361107d891.tar.xz
edify-e8e9e49469f81e296abd98ff040914361107d891.zip
feat(result): Match wrapper exposes named captures as attributes (m.username, m.captures.username)
-rw-r--r--edify/builder/mixins/matcher.py14
-rw-r--r--edify/result/__init__.py3
-rw-r--r--edify/result/match.py59
-rw-r--r--edify/result/regex.py49
-rw-r--r--tests/result/match.test.py117
5 files changed, 220 insertions, 22 deletions
diff --git a/edify/builder/mixins/matcher.py b/edify/builder/mixins/matcher.py
index 001d46f..6f35f24 100644
--- a/edify/builder/mixins/matcher.py
+++ b/edify/builder/mixins/matcher.py
@@ -14,11 +14,11 @@ The surface is intentionally limited to ``test``, ``match``, ``search``, ``finda
from __future__ import annotations
-import re
import sys
from collections.abc import Callable
from edify.builder.types.protocol import BuilderProtocol
+from edify.result.match import Match
class MatcherMixin(BuilderProtocol):
@@ -35,8 +35,8 @@ class MatcherMixin(BuilderProtocol):
endpos: int = sys.maxsize,
*,
timeout: float | None = None,
- ) -> re.Match[str] | None:
- """Delegate to :meth:`re.Pattern.match`."""
+ ) -> Match | None:
+ """Delegate to :meth:`re.Pattern.match`, returning an edify :class:`Match`."""
return self._lazy_regex().match(string, pos, endpos, timeout=timeout)
def search(
@@ -46,8 +46,8 @@ class MatcherMixin(BuilderProtocol):
endpos: int = sys.maxsize,
*,
timeout: float | None = None,
- ) -> re.Match[str] | None:
- """Delegate to :meth:`re.Pattern.search`."""
+ ) -> Match | None:
+ """Delegate to :meth:`re.Pattern.search`, returning an edify :class:`Match`."""
return self._lazy_regex().search(string, pos, endpos, timeout=timeout)
def findall(
@@ -63,11 +63,11 @@ class MatcherMixin(BuilderProtocol):
def sub(
self,
- replacement: str | Callable[[re.Match[str]], str],
+ replacement: str | Callable[[Match], str],
string: str,
count: int = 0,
*,
timeout: float | None = None,
) -> str:
- """Delegate to :meth:`re.Pattern.sub`."""
+ """Delegate to :meth:`re.Pattern.sub`; callables receive an edify :class:`Match`."""
return self._lazy_regex().sub(replacement, string, count=count, timeout=timeout)
diff --git a/edify/result/__init__.py b/edify/result/__init__.py
index 457f79e..858003e 100644
--- a/edify/result/__init__.py
+++ b/edify/result/__init__.py
@@ -1,3 +1,4 @@
+from edify.result.match import Match, NamedCaptures
from edify.result.regex import Regex
-__all__ = ["Regex"]
+__all__ = ["Match", "NamedCaptures", "Regex"]
diff --git a/edify/result/match.py b/edify/result/match.py
new file mode 100644
index 0000000..247ee77
--- /dev/null
+++ b/edify/result/match.py
@@ -0,0 +1,59 @@
+"""The :class:`Match` composition wrapper — attribute-access for named captures."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Callable
+from typing import Any
+
+
+class NamedCaptures:
+ """A namespace exposing every named capture group as an attribute."""
+
+ def __init__(self, wrapped_match: re.Match[str]) -> None:
+ self._wrapped_match = wrapped_match
+
+ def __getattr__(self, name: str) -> str | None:
+ """Return the substring captured by the named group ``name``."""
+ group_index_map = self._wrapped_match.re.groupindex
+ if name not in group_index_map:
+ raise AttributeError(
+ f"named capture group {name!r} does not exist on this pattern; "
+ f"declared groups are {sorted(group_index_map)}"
+ )
+ return self._wrapped_match.group(name)
+
+ def __dir__(self) -> list[str]:
+ """Return every declared named group so ``dir()`` and IDE completion see them."""
+ return sorted(self._wrapped_match.re.groupindex)
+
+
+class Match:
+ """A wrapped :class:`re.Match` with attribute-access for named captures."""
+
+ def __init__(self, wrapped_match: re.Match[str]) -> None:
+ self._wrapped_match: re.Match[str] = wrapped_match
+ self._captures: NamedCaptures = NamedCaptures(wrapped_match)
+
+ @property
+ def wrapped(self) -> re.Match[str]:
+ """The underlying :class:`re.Match` for direct interop with :mod:`re`."""
+ return self._wrapped_match
+
+ @property
+ def captures(self) -> NamedCaptures:
+ """A namespace of named groups; access captures via ``m.captures.<name>``."""
+ return self._captures
+
+ def __getattr__(self, name: str) -> Any:
+ """Return the named-group substring or delegate to the underlying re.Match."""
+ wrapped = self._wrapped_match
+ group_index_map = wrapped.re.groupindex
+ if name in group_index_map:
+ return wrapped.group(name)
+ attribute: str | Callable[..., Any] = getattr(wrapped, name)
+ return attribute
+
+ def __repr__(self) -> str:
+ """Return ``<Match 'span'-'text'>`` for interactive display."""
+ return f"<Match {self._wrapped_match.span()} {self._wrapped_match.group()!r}>"
diff --git a/edify/result/regex.py b/edify/result/regex.py
index 75cf3e3..d718580 100644
--- a/edify/result/regex.py
+++ b/edify/result/regex.py
@@ -13,12 +13,13 @@ from edify.errors.backend import TimeoutNotSupportedByEngineError
from edify.introspect.explain import explain_elements
from edify.introspect.verbose import verbose_elements
from edify.introspect.visualize import visualize_elements
+from edify.result.match import Match
_MethodReturn = (
- re.Match[str]
+ Match
| list[str]
| list[tuple[str, ...]]
- | Iterator[re.Match[str]]
+ | Iterator[Match]
| str
| tuple[str, int]
| list[str | None]
@@ -70,9 +71,10 @@ class Regex:
endpos: int = sys.maxsize,
*,
timeout: float | None = None,
- ) -> re.Match[str] | None:
+ ) -> Match | None:
"""Delegate to the compiled pattern's ``match`` method."""
- return self._compiled.match(string, pos, endpos, **_timeout_kwargs(self._engine, timeout))
+ raw = self._compiled.match(string, pos, endpos, **_timeout_kwargs(self._engine, timeout))
+ return Match(raw) if raw is not None else None
def search(
self,
@@ -81,9 +83,10 @@ class Regex:
endpos: int = sys.maxsize,
*,
timeout: float | None = None,
- ) -> re.Match[str] | None:
+ ) -> Match | None:
"""Delegate to the compiled pattern's ``search`` method."""
- return self._compiled.search(string, pos, endpos, **_timeout_kwargs(self._engine, timeout))
+ raw = self._compiled.search(string, pos, endpos, **_timeout_kwargs(self._engine, timeout))
+ return Match(raw) if raw is not None else None
def fullmatch(
self,
@@ -92,11 +95,12 @@ class Regex:
endpos: int = sys.maxsize,
*,
timeout: float | None = None,
- ) -> re.Match[str] | None:
+ ) -> Match | None:
"""Delegate to the compiled pattern's ``fullmatch`` method."""
- return self._compiled.fullmatch(
+ raw = self._compiled.fullmatch(
string, pos, endpos, **_timeout_kwargs(self._engine, timeout)
)
+ return Match(raw) if raw is not None else None
def findall(
self,
@@ -116,36 +120,40 @@ class Regex:
endpos: int = sys.maxsize,
*,
timeout: float | None = None,
- ) -> Iterator[re.Match[str]]:
+ ) -> Iterator[Match]:
"""Delegate to the compiled pattern's ``finditer`` method."""
- return self._compiled.finditer(
+ raw_iter = self._compiled.finditer(
string, pos, endpos, **_timeout_kwargs(self._engine, timeout)
)
+ for raw in raw_iter:
+ yield Match(raw)
def sub(
self,
- replacement: str | Callable[[re.Match[str]], str],
+ replacement: str | Callable[[Match], str],
string: str,
count: int = 0,
*,
timeout: float | None = None,
) -> str:
"""Delegate to the compiled pattern's ``sub`` method."""
+ adapter = _wrap_replacement(replacement)
return self._compiled.sub(
- replacement, string, count=count, **_timeout_kwargs(self._engine, timeout)
+ adapter, string, count=count, **_timeout_kwargs(self._engine, timeout)
)
def subn(
self,
- replacement: str | Callable[[re.Match[str]], str],
+ replacement: str | Callable[[Match], str],
string: str,
count: int = 0,
*,
timeout: float | None = None,
) -> tuple[str, int]:
"""Delegate to the compiled pattern's ``subn`` method."""
+ adapter = _wrap_replacement(replacement)
return self._compiled.subn(
- replacement, string, count=count, **_timeout_kwargs(self._engine, timeout)
+ adapter, string, count=count, **_timeout_kwargs(self._engine, timeout)
)
def split(
@@ -222,3 +230,16 @@ def _timeout_kwargs(engine: Engine, timeout: float | None) -> Mapping[str, float
if engine == "re":
raise TimeoutNotSupportedByEngineError()
return {"timeout": timeout}
+
+
+def _wrap_replacement(
+ replacement: str | Callable[[Match], str],
+) -> str | Callable[[re.Match[str]], str]:
+ if isinstance(replacement, str):
+ return replacement
+ caller = replacement
+
+ def adapter(raw_match: re.Match[str]) -> str:
+ return caller(Match(raw_match))
+
+ return adapter
diff --git a/tests/result/match.test.py b/tests/result/match.test.py
new file mode 100644
index 0000000..b7f7514
--- /dev/null
+++ b/tests/result/match.test.py
@@ -0,0 +1,117 @@
+"""Tests for the :class:`Match` wrapper — attribute-access for named captures."""
+
+import pytest
+
+from edify import Pattern, RegexBuilder
+from edify.result import Match, NamedCaptures
+
+
+def _username_pattern():
+ return RegexBuilder().string("@").named_capture("username").one_or_more().letter().end()
+
+
+def test_search_returns_a_wrapped_match():
+ result = _username_pattern().search("hi @alice")
+ assert isinstance(result, Match)
+
+
+def test_search_returns_none_when_no_match():
+ result = RegexBuilder().string("zzz").search("hi @alice")
+ assert result is None
+
+
+def test_match_attribute_access_returns_named_group_substring():
+ result = _username_pattern().search("hi @alice")
+ assert result is not None
+ assert result.username == "alice"
+
+
+def test_captures_namespace_returns_named_group_substring():
+ result = _username_pattern().search("hi @bob")
+ assert result is not None
+ assert result.captures.username == "bob"
+
+
+def test_captures_namespace_dir_lists_every_declared_group():
+ result = _username_pattern().search("hi @carol")
+ assert result is not None
+ assert dir(result.captures) == ["username"]
+
+
+def test_captures_namespace_attribute_error_names_valid_groups():
+ result = _username_pattern().search("hi @dave")
+ assert result is not None
+ with pytest.raises(AttributeError, match="does not exist"):
+ _ = result.captures.nonexistent
+
+
+def test_match_delegates_re_match_methods_via_getattr():
+ result = _username_pattern().search("hi @erin")
+ assert result is not None
+ assert result.group() == "@erin"
+ assert result.group("username") == "erin"
+ assert result.groupdict() == {"username": "erin"}
+ assert result.span("username") == (4, 8)
+
+
+def test_match_wrapped_property_returns_the_underlying_re_match():
+ result = _username_pattern().search("hi @frank")
+ assert result is not None
+ wrapped = result.wrapped
+ assert wrapped.group("username") == "frank"
+
+
+def test_match_repr_contains_span_and_text():
+ result = _username_pattern().search("hi @greta")
+ assert result is not None
+ assert "greta" in repr(result)
+
+
+def test_finditer_yields_wrapped_matches():
+ pattern = _username_pattern().to_regex()
+ hits = list(pattern.finditer("hi @heidi and @ivan"))
+ assert all(isinstance(item, Match) for item in hits)
+ assert [item.captures.username for item in hits] == ["heidi", "ivan"]
+
+
+def test_sub_callable_receives_wrapped_match():
+ pattern = _username_pattern().to_regex()
+ result = pattern.sub(lambda m: m.captures.username.upper(), "hi @jane")
+ assert result == "hi JANE"
+
+
+def test_subn_callable_receives_wrapped_match():
+ pattern = _username_pattern().to_regex()
+ result, count = pattern.subn(lambda m: m.captures.username.upper(), "hi @jane and @jim")
+ assert result == "hi JANE and JIM"
+ assert count == 2
+
+
+def test_sub_still_accepts_a_plain_string_replacement():
+ pattern = _username_pattern().to_regex()
+ assert pattern.sub("[X]", "hi @jane") == "hi [X]"
+
+
+def test_named_captures_instance_is_a_namespace():
+ pattern = _username_pattern().to_regex()
+ result = pattern.search("hi @karen")
+ assert result is not None
+ assert isinstance(result.captures, NamedCaptures)
+
+
+def test_pattern_match_via_pattern_class_returns_wrapped_match():
+ pattern = Pattern().named_capture("word").one_or_more().letter().end()
+ assert pattern.match("hello") is not None
+ assert pattern.match("hello").captures.word == "hello"
+
+
+def test_fullmatch_returns_wrapped_match_when_full_input_matches():
+ pattern = _username_pattern().to_regex()
+ result = pattern.fullmatch("@leo")
+ assert result is not None
+ assert result.captures.username == "leo"
+
+
+def test_fullmatch_returns_none_when_input_is_only_partial():
+ pattern = _username_pattern().to_regex()
+ assert pattern.fullmatch("hi @leo") is None