diff options
| author | natsuoto <[email protected]> | 2026-07-15 14:02:59 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-15 14:02:59 +0530 |
| commit | 111a310d05df79582a0d8f51c20046b63a002cd7 (patch) | |
| tree | 3470bb742f47b91c370ba09c61e2754f445f96ff | |
| parent | 769cc349e5fb8451eab6c3f23363d2c3589e7bca (diff) | |
| download | edify-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.py | 14 | ||||
| -rw-r--r-- | edify/builder/mixins/terminals.py | 16 | ||||
| -rw-r--r-- | tests/builder/cache.test.py | 63 |
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() |
