aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-15 14:02:59 +0530
committernatsuoto <[email protected]>2026-07-15 14:02:59 +0530
commit111a310d05df79582a0d8f51c20046b63a002cd7 (patch)
tree3470bb742f47b91c370ba09c61e2754f445f96ff
parent769cc349e5fb8451eab6c3f23363d2c3589e7bca (diff)
downloadedify-111a310d05df79582a0d8f51c20046b63a002cd7.tar.xz
edify-111a310d05df79582a0d8f51c20046b63a002cd7.zip
feat(builder): per-instance lazy compile cache shared across to_regex and the matcher methods
-rw-r--r--edify/builder/core.py14
-rw-r--r--edify/builder/mixins/terminals.py16
-rw-r--r--tests/builder/cache.test.py63
3 files changed, 80 insertions, 13 deletions
diff --git a/edify/builder/core.py b/edify/builder/core.py
index fca2a76..056e1b1 100644
--- a/edify/builder/core.py
+++ b/edify/builder/core.py
@@ -22,12 +22,9 @@ _UNCLOSED_FRAME_MARKER = "<unclosed>"
class BuilderCore(BuilderProtocol):
"""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
+ self._state: BuilderState = BuilderState()
+ self._cached_regex: Regex | None = None
def _with_state(self, new_state: BuilderState) -> Self:
"""Return a fresh instance of the same concrete type carrying ``new_state``."""
@@ -39,12 +36,7 @@ class BuilderCore(BuilderProtocol):
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
+ return self.to_regex()
def fork(self) -> Self:
"""Return a fresh builder with the same immutable state."""
diff --git a/edify/builder/mixins/terminals.py b/edify/builder/mixins/terminals.py
index de230db..b25873b 100644
--- a/edify/builder/mixins/terminals.py
+++ b/edify/builder/mixins/terminals.py
@@ -28,6 +28,8 @@ _RAW_SPACE = " "
class TerminalsMixin(BuilderProtocol):
"""Provides the two pattern-emitting terminal methods on the builder."""
+ _cached_regex: Regex | None
+
def to_regex_string(self) -> str:
"""Return the bare regex string the builder describes.
@@ -71,8 +73,11 @@ class TerminalsMixin(BuilderProtocol):
(variable-width lookbehind, per-call timeouts). ``"regex"`` requires
``pip install edify[regex]``; a clean :class:`MissingRegexBackendError`
surfaces when the extra is not installed.
+
+ Default (no-kwargs) calls hit a per-instance lazy cache: the second and
+ subsequent no-kwargs calls return the same :class:`Regex` the first call
+ produced. Passing any kwarg bypasses the cache and always compiles fresh.
"""
- pattern_string = self.to_regex_string()
kwarg_flags = Flags(
ascii_only=ascii_only,
debug=debug,
@@ -81,14 +86,21 @@ class TerminalsMixin(BuilderProtocol):
dotall=dotall,
verbose=verbose,
)
+ can_cache = engine == "re" and kwarg_flags == Flags()
+ if can_cache and self._cached_regex is not None:
+ return self._cached_regex
+ pattern_string = self.to_regex_string()
effective_flags = self._state.flags.with_merged(kwarg_flags)
compiled_pattern = compile_pattern(pattern_string, engine, effective_flags)
- return Regex(
+ wrapped = Regex(
source=pattern_string,
compiled=compiled_pattern,
elements=tuple(self._state.top_frame.children),
engine=engine,
)
+ if can_cache:
+ self._cached_regex = wrapped
+ return wrapped
def _ensure_fully_specified(builder: BuilderProtocol) -> None:
diff --git a/tests/builder/cache.test.py b/tests/builder/cache.test.py
new file mode 100644
index 0000000..700a8e4
--- /dev/null
+++ b/tests/builder/cache.test.py
@@ -0,0 +1,63 @@
+"""Tests for the per-instance lazy compile cache."""
+
+from edify import Pattern, RegexBuilder
+
+
+def test_two_no_kwargs_to_regex_calls_return_the_same_instance():
+ builder = RegexBuilder().one_or_more().digit()
+ first = builder.to_regex()
+ second = builder.to_regex()
+ assert first is second
+
+
+def test_matcher_call_shares_the_cache_with_to_regex():
+ builder = RegexBuilder().one_or_more().digit()
+ first = builder.to_regex()
+ builder.test("42")
+ assert builder.to_regex() is first
+
+
+def test_kwarg_call_bypasses_the_cache_and_never_populates_it():
+ builder = RegexBuilder().string("ABC")
+ with_kwargs = builder.to_regex(ignore_case=True)
+ default = builder.to_regex()
+ assert with_kwargs is not default
+ assert builder.to_regex() is default
+
+
+def test_regex_engine_kwarg_bypasses_the_cache():
+ builder = RegexBuilder().digit()
+ default = builder.to_regex()
+ via_regex = builder.to_regex(engine="regex")
+ assert default is not via_regex
+ assert default.engine == "re"
+ assert via_regex.engine == "regex"
+
+
+def test_kwarg_call_first_still_leaves_a_default_no_kwargs_call_cacheable():
+ builder = RegexBuilder().string("hi")
+ builder.to_regex(ignore_case=True)
+ first_default = builder.to_regex()
+ second_default = builder.to_regex()
+ assert first_default is second_default
+
+
+def test_forked_builder_gets_its_own_empty_cache():
+ parent = RegexBuilder().digit()
+ parent.to_regex()
+ child = parent.fork()
+ assert child.to_regex() is child.to_regex()
+ assert parent.to_regex() is not child.to_regex()
+
+
+def test_chain_step_yields_a_fresh_cache_slot():
+ root = RegexBuilder().digit()
+ original = root.to_regex()
+ extended = root.word()
+ extended_regex = extended.to_regex()
+ assert extended_regex is not original
+
+
+def test_pattern_also_caches_across_repeat_to_regex_calls():
+ pattern = Pattern().string("hi")
+ assert pattern.to_regex() is pattern.to_regex()