diff options
| author | natsuoto <[email protected]> | 2026-07-15 14:24:05 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-15 14:24:05 +0530 |
| commit | e8e9e49469f81e296abd98ff040914361107d891 (patch) | |
| tree | bb27a0f2179920a9aa91ab28ca8c522f7095e006 | |
| parent | b396a5bb3655a2f1a9250ef46beb574445512328 (diff) | |
| download | edify-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.py | 14 | ||||
| -rw-r--r-- | edify/result/__init__.py | 3 | ||||
| -rw-r--r-- | edify/result/match.py | 59 | ||||
| -rw-r--r-- | edify/result/regex.py | 49 | ||||
| -rw-r--r-- | tests/result/match.test.py | 117 |
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 |
