From abc0d47bc7b45f6022fcfd26295cf00b738735a0 Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:00:59 +0530 Subject: feat: __repr__ shows the pattern-so-far on the builder --- edify/builder/core.py | 25 ++++++++++++++++++++++--- tests/builder/repr.test.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 tests/builder/repr.test.py diff --git a/edify/builder/core.py b/edify/builder/core.py index f6a1bd9..455be98 100644 --- a/edify/builder/core.py +++ b/edify/builder/core.py @@ -2,9 +2,9 @@ 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, and the ``_with_state`` helper that -mixins call to produce new instances; concrete classes compose it with -their mixin set. +the state attribute, the constructor, the ``_with_state`` helper that +mixins call to produce new instances, and the interactive ``__repr__`` +that shows the pattern-so-far. """ from __future__ import annotations @@ -12,6 +12,9 @@ from __future__ import annotations from typing import Self from edify.builder.types.state import BuilderState +from edify.errors.structure import CannotCallSubexpressionError + +_UNCLOSED_FRAME_MARKER = "" class BuilderCore: @@ -28,3 +31,19 @@ class BuilderCore: new_instance = concrete_class.__new__(concrete_class) new_instance._state = new_state return new_instance + + def __repr__(self) -> str: + """Return ```` for interactive display.""" + rendered = _render_or_marker(self) + return f"<{type(self).__name__} {rendered!r}>" + + +def _render_or_marker(builder: BuilderCore) -> str: + """Return the emitted regex string, or a placeholder when frames are unclosed.""" + to_regex_string = getattr(builder, "to_regex_string", None) + if to_regex_string is None: + return _UNCLOSED_FRAME_MARKER + try: + return to_regex_string() + except CannotCallSubexpressionError: + return _UNCLOSED_FRAME_MARKER diff --git a/tests/builder/repr.test.py b/tests/builder/repr.test.py new file mode 100644 index 0000000..278f40b --- /dev/null +++ b/tests/builder/repr.test.py @@ -0,0 +1,31 @@ +"""Tests for :meth:`BuilderCore.__repr__` on :class:`RegexBuilder` and :class:`Pattern`.""" + +from edify import Pattern, RegexBuilder + + +def test_repr_of_a_fresh_regex_builder_shows_the_empty_non_capturing_group(): + assert repr(RegexBuilder()) == "" + + +def test_repr_of_a_fresh_pattern_shows_the_empty_non_capturing_group(): + assert repr(Pattern()) == "" + + +def test_repr_of_a_regex_builder_shows_the_pattern_so_far(): + builder = RegexBuilder().start_of_input().exactly(4).digit().end_of_input() + assert repr(builder) == "" + + +def test_repr_of_a_pattern_shows_the_pattern_so_far(): + pattern = Pattern().one_or_more().word() + assert repr(pattern) == "" + + +def test_repr_of_a_builder_with_open_frames_shows_the_unclosed_marker(): + builder = RegexBuilder().any_of().string("cat") + assert repr(builder) == "'>" + + +def test_repr_uses_the_concrete_class_name_not_the_base(): + assert repr(RegexBuilder()).startswith(" Date: Wed, 1 Jul 2026 17:01:51 +0530 Subject: feat!: __eq__ and __hash__ value equality on the builder --- edify/builder/core.py | 15 +++++++++++-- tests/builder/equality.test.py | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 tests/builder/equality.test.py diff --git a/edify/builder/core.py b/edify/builder/core.py index 455be98..1aa1ca3 100644 --- a/edify/builder/core.py +++ b/edify/builder/core.py @@ -3,8 +3,9 @@ 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, and the interactive ``__repr__`` -that shows the pattern-so-far. +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. """ from __future__ import annotations @@ -37,6 +38,16 @@ class BuilderCore: rendered = _render_or_marker(self) 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``.""" + if not isinstance(other, BuilderCore): + return NotImplemented + return self._state == other._state + + def __hash__(self) -> int: + """Return a hash derived from the immutable state; matches :meth:`__eq__`.""" + return hash(self._state) + def _render_or_marker(builder: BuilderCore) -> str: """Return the emitted regex string, or a placeholder when frames are unclosed.""" diff --git a/tests/builder/equality.test.py b/tests/builder/equality.test.py new file mode 100644 index 0000000..6c6fce5 --- /dev/null +++ b/tests/builder/equality.test.py @@ -0,0 +1,50 @@ +"""Tests for value-based ``__eq__`` and ``__hash__`` on :class:`BuilderCore`.""" + +from edify import Pattern, RegexBuilder + + +def test_two_builders_with_the_same_chain_are_equal(): + assert RegexBuilder().digit() == RegexBuilder().digit() + + +def test_two_patterns_with_the_same_chain_are_equal(): + assert Pattern().digit() == Pattern().digit() + + +def test_builders_with_different_chains_are_not_equal(): + assert RegexBuilder().digit() != RegexBuilder().word() + + +def test_equal_builders_produce_equal_hashes(): + assert hash(RegexBuilder().digit()) == hash(RegexBuilder().digit()) + + +def test_a_builder_is_usable_as_a_dict_key(): + lookup = {RegexBuilder().digit(): "digit-key"} + assert lookup[RegexBuilder().digit()] == "digit-key" + + +def test_a_builder_can_be_deduplicated_in_a_set(): + dedup = {RegexBuilder().digit(), RegexBuilder().digit(), RegexBuilder().word()} + assert len(dedup) == 2 + + +def test_a_pattern_and_a_regex_builder_with_the_same_state_compare_equal(): + assert Pattern().digit() == RegexBuilder().digit() + + +def test_a_builder_compared_to_a_non_builder_is_not_equal(): + assert (RegexBuilder().digit() == "foo") is False + assert (RegexBuilder().digit() == 42) is False + + +def test_a_builder_compared_to_a_non_builder_returns_not_implemented_from_the_dunder(): + result = RegexBuilder().digit().__eq__("foo") + assert result is NotImplemented + + +def test_hash_of_two_flags_that_differ_are_distinct(): + a = RegexBuilder().ignore_case().digit() + b = RegexBuilder().digit() + assert a != b + assert hash(a) != hash(b) \ No newline at end of file -- cgit v1.2.3 From dab26fa6193af03b5b132f6d7fe60b60d884ba86 Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:04:28 +0530 Subject: feat: .fork() and .copy() explicit aliases for branch construction --- edify/builder/core.py | 13 ++++++++++++ tests/builder/equality.test.py | 2 +- tests/builder/fork.test.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/builder/fork.test.py diff --git a/edify/builder/core.py b/edify/builder/core.py index 1aa1ca3..333c7ad 100644 --- a/edify/builder/core.py +++ b/edify/builder/core.py @@ -33,6 +33,19 @@ class BuilderCore: new_instance._state = new_state return new_instance + def fork(self) -> Self: + """Return a fresh builder with the same immutable state. + + 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. + """ + return self._with_state(self._state) + + def copy(self) -> Self: + """Alias for :meth:`fork` — return a fresh builder with the same immutable state.""" + return self._with_state(self._state) + def __repr__(self) -> str: """Return ```` for interactive display.""" rendered = _render_or_marker(self) diff --git a/tests/builder/equality.test.py b/tests/builder/equality.test.py index 6c6fce5..717d1ae 100644 --- a/tests/builder/equality.test.py +++ b/tests/builder/equality.test.py @@ -47,4 +47,4 @@ def test_hash_of_two_flags_that_differ_are_distinct(): a = RegexBuilder().ignore_case().digit() b = RegexBuilder().digit() assert a != b - assert hash(a) != hash(b) \ No newline at end of file + assert hash(a) != hash(b) diff --git a/tests/builder/fork.test.py b/tests/builder/fork.test.py new file mode 100644 index 0000000..bcb8d1b --- /dev/null +++ b/tests/builder/fork.test.py @@ -0,0 +1,47 @@ +"""Tests for the :meth:`BuilderCore.fork` and :meth:`BuilderCore.copy` aliases.""" + +from edify import Pattern, RegexBuilder + + +def test_fork_returns_a_regex_builder_with_the_same_state(): + original = RegexBuilder().digit() + forked = original.fork() + assert forked == original + assert forked is not original + + +def test_fork_returns_a_pattern_with_the_same_state(): + original = Pattern().word() + forked = original.fork() + assert forked == original + assert forked is not original + + +def test_copy_returns_a_regex_builder_with_the_same_state(): + original = RegexBuilder().digit() + copied = original.copy() + assert copied == original + assert copied is not original + + +def test_copy_returns_a_pattern_with_the_same_state(): + original = Pattern().word() + copied = original.copy() + assert copied == original + assert copied is not original + + +def test_fork_and_copy_produce_independent_branches(): + root = RegexBuilder().digit() + branch_a = root.fork().word() + branch_b = root.copy().whitespace_char() + assert root.to_regex_string() == "\\d" + assert branch_a.to_regex_string() == "\\d\\w" + assert branch_b.to_regex_string() == "\\d\\s" + + +def test_fork_and_copy_return_the_concrete_class_type(): + assert isinstance(RegexBuilder().fork(), RegexBuilder) + assert isinstance(RegexBuilder().copy(), RegexBuilder) + assert isinstance(Pattern().fork(), Pattern) + assert isinstance(Pattern().copy(), Pattern) -- cgit v1.2.3 From 4a0a1fa3d8f7f0a54c36aae34b23b0585eb1fea7 Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:07:12 +0530 Subject: test: cover the __repr__ fallback for BuilderCore subclasses without TerminalsMixin --- tests/builder/repr.test.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/builder/repr.test.py b/tests/builder/repr.test.py index 278f40b..c5c716e 100644 --- a/tests/builder/repr.test.py +++ b/tests/builder/repr.test.py @@ -1,6 +1,11 @@ """Tests for :meth:`BuilderCore.__repr__` on :class:`RegexBuilder` and :class:`Pattern`.""" from edify import Pattern, RegexBuilder +from edify.builder.core import BuilderCore + + +class _BareBuilder(BuilderCore): + """A minimal :class:`BuilderCore` subclass without :class:`TerminalsMixin`.""" def test_repr_of_a_fresh_regex_builder_shows_the_empty_non_capturing_group(): @@ -29,3 +34,7 @@ def test_repr_of_a_builder_with_open_frames_shows_the_unclosed_marker(): def test_repr_uses_the_concrete_class_name_not_the_base(): assert repr(RegexBuilder()).startswith("'>" -- cgit v1.2.3