diff options
| author | natsuoto <[email protected]> | 2026-07-10 16:19:01 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-10 16:19:01 +0530 |
| commit | 1ca22160898b7c9bcc945ac8d59d71b72f8a7116 (patch) | |
| tree | 15ce457d4d7fd243c153d4b8e60e6e75bd714988 | |
| parent | 74d6b084993feb9c9dc1a72abeb3cf2497ac9171 (diff) | |
| download | edify-1ca22160898b7c9bcc945ac8d59d71b72f8a7116.tar.xz edify-1ca22160898b7c9bcc945ac8d59d71b72f8a7116.zip | |
feat(builder): lazy Regex compile cache and __eq__/__hash__ diagnose unfinished builders (#127, #137)
| -rw-r--r-- | edify/builder/core.py | 61 | ||||
| -rw-r--r-- | edify/builder/diagnose.py | 106 | ||||
| -rw-r--r-- | edify/builder/mixins/matcher.py | 25 | ||||
| -rw-r--r-- | tests/builder/cache.test.py | 69 | ||||
| -rw-r--r-- | tests/builder/equality.test.py | 87 |
5 files changed, 316 insertions, 32 deletions
diff --git a/edify/builder/core.py b/edify/builder/core.py index 333c7ad..6b3ac46 100644 --- a/edify/builder/core.py +++ b/edify/builder/core.py @@ -1,19 +1,18 @@ -"""Shared immutable-state plumbing for :class:`RegexBuilder` and :class:`Pattern`. - -Both fluent surfaces carry the same :class:`BuilderState` and use the same -clone-and-replace pattern for chain methods. :class:`BuilderCore` provides -the state attribute, the constructor, the ``_with_state`` helper that -mixins call to produce new instances, the interactive ``__repr__`` -that shows the pattern-so-far, and value-based ``__eq__`` / ``__hash__`` -that make two builders equal when their underlying immutable state matches. -""" +"""Shared immutable-state plumbing for :class:`RegexBuilder` and :class:`Pattern`.""" from __future__ import annotations from typing import Self +from edify.builder.diagnose import diagnose_unfinished from edify.builder.types.state import BuilderState +from edify.errors.comparison import ( + CannotCompareUnfinishedBuilderError, + CannotHashUnfinishedBuilderError, +) +from edify.errors.quantifier import DanglingQuantifierError from edify.errors.structure import CannotCallSubexpressionError +from edify.result.regex import Regex _UNCLOSED_FRAME_MARKER = "<unclosed>" @@ -22,24 +21,31 @@ class BuilderCore: """Holds the immutable :class:`BuilderState` and clones it on chain steps.""" _state: BuilderState + _cached_regex: Regex | None def __init__(self) -> None: self._state = BuilderState() + self._cached_regex = None def _with_state(self, new_state: BuilderState) -> Self: """Return a fresh instance of the same concrete type carrying ``new_state``.""" concrete_class = type(self) new_instance = concrete_class.__new__(concrete_class) new_instance._state = new_state + new_instance._cached_regex = None return new_instance - def fork(self) -> Self: - """Return a fresh builder with the same immutable state. + def _lazy_regex(self) -> Regex: + """Return the memoised :class:`Regex` for this builder, compiling once on first call.""" + cached = self._cached_regex + if cached is None: + to_regex = self.to_regex + cached = to_regex() + self._cached_regex = cached + return cached - Chain methods already return new instances so implicit forking works - (``root.digit()`` and ``root.word()`` share nothing after the split); - this method makes the intent explicit and discoverable via autocomplete. - """ + def fork(self) -> Self: + """Return a fresh builder with the same immutable state.""" return self._with_state(self._state) def copy(self) -> Self: @@ -52,14 +58,29 @@ class BuilderCore: return f"<{type(self).__name__} {rendered!r}>" def __eq__(self, other: object) -> bool: - """Return True when ``other`` is a builder whose immutable state matches ``self``.""" + """Return True when ``other`` is a builder with the same emitted pattern and flags.""" if not isinstance(other, BuilderCore): return NotImplemented - return self._state == other._state + problems: list = [] + left_problem = diagnose_unfinished(self._state, "left operand") + right_problem = diagnose_unfinished(other._state, "right operand") + if left_problem is not None: + problems.append(left_problem) + if right_problem is not None: + problems.append(right_problem) + if problems: + raise CannotCompareUnfinishedBuilderError(problems) + self_source = self.to_regex_string() + other_source = other.to_regex_string() + return self_source == other_source and self._state.flags == other._state.flags def __hash__(self) -> int: - """Return a hash derived from the immutable state; matches :meth:`__eq__`.""" - return hash(self._state) + """Return a hash derived from ``(emitted_pattern, flags)``.""" + problem = diagnose_unfinished(self._state, "builder") + if problem is not None: + raise CannotHashUnfinishedBuilderError(problem) + source = self.to_regex_string() + return hash((source, self._state.flags)) def _render_or_marker(builder: BuilderCore) -> str: @@ -69,5 +90,5 @@ def _render_or_marker(builder: BuilderCore) -> str: return _UNCLOSED_FRAME_MARKER try: return to_regex_string() - except CannotCallSubexpressionError: + except (CannotCallSubexpressionError, DanglingQuantifierError): return _UNCLOSED_FRAME_MARKER diff --git a/edify/builder/diagnose.py b/edify/builder/diagnose.py new file mode 100644 index 0000000..60a5f5a --- /dev/null +++ b/edify/builder/diagnose.py @@ -0,0 +1,106 @@ +"""Diagnostic helpers that describe why a builder is unfinished.""" + +from __future__ import annotations + +from edify.builder.types.frame import StackFrame +from edify.builder.types.state import BuilderState +from edify.elements.types.captures import CaptureElement, NamedCaptureElement +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, +) +from edify.errors.context import CallerContext +from edify.errors.formatting import FixInsertion, Problem + +_END_INSERTION_TEXT = ".end()" +_DANGLING_INSERTION_TEXT = ".digit()" + + +def diagnose_unfinished(state: BuilderState, subject: str) -> Problem | None: + """Return a :class:`Problem` for the first unfinished thing in ``state``, or ``None``.""" + if len(state.stack) > 1: + return _diagnose_open_frame(state.top_frame, subject) + for frame in state.stack: + if frame.quantifier is not None: + return _diagnose_dangling_quantifier(frame, subject) + return None + + +def _diagnose_open_frame(frame: StackFrame, subject: str) -> Problem: + """Return a :class:`Problem` describing an unclosed frame.""" + frame_name = _frame_display_name(frame) + problem_context = _fallback(frame.call_site) + fix_context = _fallback(frame.last_child_call_site or frame.call_site) + fix_insertion = FixInsertion( + column=fix_context.end_colno, + text=_END_INSERTION_TEXT, + ) + return Problem( + description=f"`{subject}` has an open `{frame_name}` frame", + problem_context=problem_context, + problem_hint="frame opened here, never closed", + help_summary=f"close the frame with `{_END_INSERTION_TEXT}`", + fix_context=fix_context, + fix_insertion=fix_insertion, + ) + + +def _diagnose_dangling_quantifier(frame: StackFrame, subject: str) -> Problem: + """Return a :class:`Problem` describing a pending quantifier with no operand.""" + quantifier_name = frame.quantifier_name or "quantifier" + problem_context = _fallback(frame.quantifier_call_site) + fix_context = _fallback(frame.quantifier_call_site) + fix_insertion = FixInsertion( + column=fix_context.end_colno, + text=_DANGLING_INSERTION_TEXT, + ) + return Problem( + description=f"`{subject}` has a pending `.{quantifier_name}` quantifier", + problem_context=problem_context, + problem_hint="quantifier set here — no element follows", + help_summary=( + f"append an element to repeat, e.g. `{_DANGLING_INSERTION_TEXT}` " + '(or `.word()`, `.char("x")`)' + ), + fix_context=fix_context, + fix_insertion=fix_insertion, + ) + + +def _frame_display_name(frame: StackFrame) -> str: + """Return the human-facing name of the chain call that opened ``frame``.""" + type_node = frame.type_node + if isinstance(type_node, AnyOfElement): + return "any_of()" + if isinstance(type_node, GroupElement): + return "group()" + if isinstance(type_node, AssertAheadElement): + return "assert_ahead()" + if isinstance(type_node, AssertNotAheadElement): + return "assert_not_ahead()" + if isinstance(type_node, AssertBehindElement): + return "assert_behind()" + if isinstance(type_node, AssertNotBehindElement): + return "assert_not_behind()" + if isinstance(type_node, CaptureElement): + return "capture()" + if isinstance(type_node, NamedCaptureElement): + return f'named_capture("{type_node.name}")' + return type(type_node).__name__ + + +def _fallback(context: CallerContext | None) -> CallerContext: + """Return ``context`` if not ``None``, else a placeholder context marking unknown location.""" + if context is not None: + return context + return CallerContext( + filename="<unknown>", + lineno=0, + colno=1, + end_colno=1, + source_line="", + ) diff --git a/edify/builder/mixins/matcher.py b/edify/builder/mixins/matcher.py index b4106af..3ed74fa 100644 --- a/edify/builder/mixins/matcher.py +++ b/edify/builder/mixins/matcher.py @@ -1,8 +1,9 @@ """The :class:`MatcherMixin` — direct :mod:`re` method proxies on any fluent surface. -Compiles ``self`` lazily via ``self.to_regex()`` on every call and delegates -to the returned :class:`re.Pattern`. Adding this mixin to a class lets users -write ``pattern.match("10001")`` instead of ``pattern.to_regex().match("10001")``. +Compiles ``self`` on first use via :meth:`edify.builder.core.BuilderCore._lazy_regex` +and reuses the cached :class:`edify.result.regex.Regex` on every subsequent call, +so ``pattern.test("a")`` followed by ``pattern.match("b")`` compiles the underlying +regex exactly once. The signatures mirror :class:`re.Pattern` exactly so IDE autocomplete and type inference stay unchanged. :meth:`test` is the one non-:mod:`re` method: @@ -24,33 +25,33 @@ class MatcherMixin(BuilderProtocol): def test(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> bool: """Return ``True`` when the pattern matches anywhere in ``string``, else ``False``.""" - return self.to_regex().search(string, pos, endpos) is not None + return self._lazy_regex().search(string, pos, endpos) is not None def match(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None: """Delegate to :meth:`re.Pattern.match`.""" - return self.to_regex().match(string, pos, endpos) + return self._lazy_regex().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.to_regex().search(string, pos, endpos) + return self._lazy_regex().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.to_regex().fullmatch(string, pos, endpos) + return self._lazy_regex().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.to_regex().findall(string, pos, endpos) + return self._lazy_regex().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.to_regex().finditer(string, pos, endpos) + return self._lazy_regex().finditer(string, pos, endpos) def sub( self, @@ -59,7 +60,7 @@ class MatcherMixin(BuilderProtocol): count: int = 0, ) -> str: """Delegate to :meth:`re.Pattern.sub`.""" - return self.to_regex().sub(replacement, string, count=count) + return self._lazy_regex().sub(replacement, string, count=count) def subn( self, @@ -68,8 +69,8 @@ class MatcherMixin(BuilderProtocol): count: int = 0, ) -> tuple[str, int]: """Delegate to :meth:`re.Pattern.subn`.""" - return self.to_regex().subn(replacement, string, count=count) + return self._lazy_regex().subn(replacement, string, count=count) def split(self, string: str, maxsplit: int = 0) -> list[str | None]: """Delegate to :meth:`re.Pattern.split`.""" - return self.to_regex().split(string, maxsplit=maxsplit) + return self._lazy_regex().split(string, maxsplit=maxsplit) diff --git a/tests/builder/cache.test.py b/tests/builder/cache.test.py new file mode 100644 index 0000000..80862d3 --- /dev/null +++ b/tests/builder/cache.test.py @@ -0,0 +1,69 @@ +"""Tests for the lazy :class:`Regex` cache on :class:`BuilderCore`.""" + +from edify import Pattern, RegexBuilder + + +def test_repeated_lazy_regex_calls_return_the_same_instance(): + builder = RegexBuilder().digit() + first = builder._lazy_regex() + second = builder._lazy_regex() + third = builder._lazy_regex() + assert first is second + assert second is third + + +def test_repeated_matcher_calls_reuse_the_cached_regex(): + builder = RegexBuilder().digit() + builder.match("1") + cached = builder._cached_regex + builder.search("2") + builder.findall("3") + builder.test("4") + assert builder._cached_regex is cached + + +def test_pattern_lazy_regex_is_memoised_too(): + pattern = Pattern().word() + first = pattern._lazy_regex() + second = pattern._lazy_regex() + assert first is second + + +def test_a_forked_builder_gets_a_fresh_cache_slot(): + original = RegexBuilder().digit() + original.match("1") + assert original._cached_regex is not None + forked = original.fork() + assert forked._cached_regex is None + + +def test_a_copied_builder_gets_a_fresh_cache_slot(): + original = RegexBuilder().digit() + original.match("1") + copied = original.copy() + assert copied._cached_regex is None + + +def test_a_chain_extension_gets_a_fresh_cache_slot(): + original = RegexBuilder().digit() + original.match("1") + extended = original.word() + assert extended._cached_regex is None + + +def test_a_fresh_builder_starts_without_a_cached_regex(): + builder = RegexBuilder() + assert builder._cached_regex is None + + +def test_calling_to_regex_directly_does_not_populate_the_cache(): + builder = RegexBuilder().digit() + _ = builder.to_regex() + assert builder._cached_regex is None + + +def test_lazy_regex_produces_a_regex_that_matches_the_pattern(): + builder = RegexBuilder().digit() + result = builder._lazy_regex() + assert result.source == "\\d" + assert result.match("7") is not None diff --git a/tests/builder/equality.test.py b/tests/builder/equality.test.py index 717d1ae..8482b51 100644 --- a/tests/builder/equality.test.py +++ b/tests/builder/equality.test.py @@ -1,6 +1,12 @@ """Tests for value-based ``__eq__`` and ``__hash__`` on :class:`BuilderCore`.""" +import pytest + from edify import Pattern, RegexBuilder +from edify.errors.comparison import ( + CannotCompareUnfinishedBuilderError, + CannotHashUnfinishedBuilderError, +) def test_two_builders_with_the_same_chain_are_equal(): @@ -48,3 +54,84 @@ def test_hash_of_two_flags_that_differ_are_distinct(): b = RegexBuilder().digit() assert a != b assert hash(a) != hash(b) + + +def test_equality_uses_emitted_pattern_not_underlying_state(): + from_chain = RegexBuilder().string("hi") + from_string_kwarg = RegexBuilder().string("hi") + assert from_chain.to_regex_string() == from_string_kwarg.to_regex_string() + assert from_chain == from_string_kwarg + + +def test_hash_uses_emitted_pattern_and_flags_tuple(): + a = RegexBuilder().string("hi") + b = RegexBuilder().string("hi") + assert hash(a) == hash((a.to_regex_string(), a._state.flags)) + assert hash(a) == hash(b) + + +def test_equality_raises_when_left_operand_has_open_frames(): + left = RegexBuilder().any_of() + right = RegexBuilder().digit() + with pytest.raises(CannotCompareUnfinishedBuilderError): + _ = left == right + + +def test_equality_raises_when_right_operand_has_open_frames(): + left = RegexBuilder().digit() + right = RegexBuilder().any_of() + with pytest.raises(CannotCompareUnfinishedBuilderError): + _ = left == right + + +def test_equality_raises_when_both_operands_are_unfinished(): + left = RegexBuilder().any_of() + right = RegexBuilder().exactly(3) + with pytest.raises(CannotCompareUnfinishedBuilderError): + _ = left == right + + +def test_hash_raises_when_frames_are_open(): + with pytest.raises(CannotHashUnfinishedBuilderError): + hash(RegexBuilder().any_of()) + + +def test_equality_raises_when_a_quantifier_is_dangling(): + left = RegexBuilder().exactly(3) + right = RegexBuilder().digit() + with pytest.raises(CannotCompareUnfinishedBuilderError): + _ = left == right + + +def test_hash_raises_when_a_quantifier_is_dangling(): + with pytest.raises(CannotHashUnfinishedBuilderError): + hash(RegexBuilder().exactly(3)) + + +def test_compare_error_message_names_the_open_frame(): + with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo: + _ = RegexBuilder().named_capture("domain") == RegexBuilder().digit() + assert 'named_capture("domain")' in str(excinfo.value) + assert "frame opened here" in str(excinfo.value) + assert ".end()" in str(excinfo.value) + + +def test_compare_error_message_names_the_dangling_quantifier(): + with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo: + _ = RegexBuilder().digit() == RegexBuilder().exactly(4) + assert "exactly(4)" in str(excinfo.value) + assert "no element follows" in str(excinfo.value) + assert ".digit()" in str(excinfo.value) + + +def test_hash_error_message_names_the_specific_problem(): + with pytest.raises(CannotHashUnfinishedBuilderError) as excinfo: + hash(RegexBuilder().at_least(2)) + assert "at_least(2)" in str(excinfo.value) + + +def test_error_message_shows_a_source_pointer_when_called_from_user_code(): + with pytest.raises(CannotHashUnfinishedBuilderError) as excinfo: + hash(RegexBuilder().any_of()) + assert "-->" in str(excinfo.value) + assert __file__ in str(excinfo.value) |
