aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author夏音 / natsuoto.exe <[email protected]>2026-07-01 17:08:33 +0530
committerGitHub <[email protected]>2026-07-01 17:08:33 +0530
commit2d9e7395dc62ca7ff9c0bf508ca4fb5e823b538e (patch)
treef9fb46ce588f8269da5c58072bbf23729aebd0e4
parent3d5094a875fbfb167d53278585414fc7884ad521 (diff)
parent4a0a1fa3d8f7f0a54c36aae34b23b0585eb1fea7 (diff)
downloadedify-2d9e7395dc62ca7ff9c0bf508ca4fb5e823b538e.tar.xz
edify-2d9e7395dc62ca7ff9c0bf508ca4fb5e823b538e.zip
feat: builder ergonomics — __repr__, __eq__/__hash__, .fork()/.copy() (#268)
Three builder-ergonomics wins so `RegexBuilder` / `Pattern` behave like proper Python values in interactive shells, dicts, and comparisons. ## `__repr__` shows the pattern-so-far Replaces the useless default `<edify.builder.builder.RegexBuilder object at 0x...>` with `<RegexBuilder '^\d+'>`. Builders whose frames are still open render as `<RegexBuilder '<unclosed>'>` so the repr never raises. ## `__eq__` / `__hash__` — value equality Two builders are equal when their underlying immutable `BuilderState` matches; the hash derives from the same state. Builders become safe as dict keys, set members, or assertion targets. `RegexBuilder` and `Pattern` share every mixin below the class name, so a builder and pattern with identical state compare equal. **Breaking:** identity-based `==` on builders becomes value-based. Only code that intentionally relied on identity equality is affected. ## `.fork()` / `.copy()` — explicit branch construction Chaining already yields implicit forking (immutable state means `root.digit()` and `root.word()` share nothing after the split); `.fork()` and `.copy()` are aliases that surface the property via autocomplete and signal branching intent in code. ## Example ```python from edify import RegexBuilder builder = RegexBuilder().start_of_input().exactly(4).digit().end_of_input() repr(builder) # <RegexBuilder '^\d{4}$'> RegexBuilder().digit() == RegexBuilder().digit() # True lookup = {RegexBuilder().digit(): "hit"} # hashable, dict-key safe root = RegexBuilder().digit() branch = root.fork().word() # explicit branch off root ``` Closes #136 Closes #137 Closes #139
-rw-r--r--edify/builder/core.py49
-rw-r--r--tests/builder/equality.test.py50
-rw-r--r--tests/builder/fork.test.py47
-rw-r--r--tests/builder/repr.test.py40
4 files changed, 183 insertions, 3 deletions
diff --git a/edify/builder/core.py b/edify/builder/core.py
index f6a1bd9..333c7ad 100644
--- a/edify/builder/core.py
+++ b/edify/builder/core.py
@@ -2,9 +2,10 @@
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, 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
@@ -12,6 +13,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 = "<unclosed>"
class BuilderCore:
@@ -28,3 +32,42 @@ class BuilderCore:
new_instance = concrete_class.__new__(concrete_class)
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 ``<ClassName 'pattern-so-far'>`` for interactive display."""
+ 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."""
+ 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/equality.test.py b/tests/builder/equality.test.py
new file mode 100644
index 0000000..717d1ae
--- /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)
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)
diff --git a/tests/builder/repr.test.py b/tests/builder/repr.test.py
new file mode 100644
index 0000000..c5c716e
--- /dev/null
+++ b/tests/builder/repr.test.py
@@ -0,0 +1,40 @@
+"""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():
+ assert repr(RegexBuilder()) == "<RegexBuilder '(?:)'>"
+
+
+def test_repr_of_a_fresh_pattern_shows_the_empty_non_capturing_group():
+ assert repr(Pattern()) == "<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) == "<RegexBuilder '^\\\\d{4}$'>"
+
+
+def test_repr_of_a_pattern_shows_the_pattern_so_far():
+ pattern = Pattern().one_or_more().word()
+ assert repr(pattern) == "<Pattern '\\\\w+'>"
+
+
+def test_repr_of_a_builder_with_open_frames_shows_the_unclosed_marker():
+ builder = RegexBuilder().any_of().string("cat")
+ assert repr(builder) == "<RegexBuilder '<unclosed>'>"
+
+
+def test_repr_uses_the_concrete_class_name_not_the_base():
+ assert repr(RegexBuilder()).startswith("<RegexBuilder ")
+ assert repr(Pattern()).startswith("<Pattern ")
+
+
+def test_repr_falls_back_when_the_subclass_lacks_the_terminals_mixin():
+ assert repr(_BareBuilder()) == "<_BareBuilder '<unclosed>'>"