diff options
| author | natsuoto <[email protected]> | 2026-07-01 14:10:45 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-01 14:10:45 +0530 |
| commit | 391a4c2fd4afbaa5910aaf670a5e1c88c5e5aa84 (patch) | |
| tree | fa0232556e112b320c0bfde96e3ef5f279b3df2d | |
| parent | bd3b12ff2f30c251c5fc52b43f81ec2f282d53ab (diff) | |
| download | edify-391a4c2fd4afbaa5910aaf670a5e1c88c5e5aa84.tar.xz edify-391a4c2fd4afbaa5910aaf670a5e1c88c5e5aa84.zip | |
feat: add Pattern class and .use() composition (closes #125, #126)
| -rw-r--r-- | edify/__init__.py | 2 | ||||
| -rw-r--r-- | edify/builder/builder.py | 23 | ||||
| -rw-r--r-- | edify/builder/core.py | 30 | ||||
| -rw-r--r-- | edify/builder/mixins/subexpression.py | 31 | ||||
| -rw-r--r-- | edify/pattern/__init__.py | 3 | ||||
| -rw-r--r-- | edify/pattern/composition.py | 42 | ||||
| -rw-r--r-- | tests/builder/use.test.py | 30 | ||||
| -rw-r--r-- | tests/pattern/composition.test.py | 40 |
8 files changed, 178 insertions, 23 deletions
diff --git a/edify/__init__.py b/edify/__init__.py index 94eb775..3be49d4 100644 --- a/edify/__init__.py +++ b/edify/__init__.py @@ -3,6 +3,7 @@ import importlib.metadata from edify.builder.builder import RegexBuilder from edify.errors.base import EdifyError from edify.errors.syntax import EdifySyntaxError +from edify.pattern.composition import Pattern def _resolve_installed_version() -> str: @@ -18,6 +19,7 @@ __version__ = _resolve_installed_version() __all__ = [ "EdifyError", "EdifySyntaxError", + "Pattern", "RegexBuilder", "__version__", ] diff --git a/edify/builder/builder.py b/edify/builder/builder.py index b4854cf..9a11d0d 100644 --- a/edify/builder/builder.py +++ b/edify/builder/builder.py @@ -5,15 +5,14 @@ the mixins under :mod:`edify.builder.mixins`. The composition order does not matter for behavior because the mixins do not override each other's methods, but they are listed alphabetically by mixin name for predictability. -The two attributes required by :class:`BuilderProtocol` — -``_state: BuilderState`` and ``_with_state`` — live here. Every chain -method reads ``self._state`` and returns ``self._with_state(new_state)``. +The immutable-state plumbing (``_state`` attribute + ``_with_state`` helper) +lives on :class:`edify.builder.core.BuilderCore`, which every fluent surface +in the package inherits from. """ from __future__ import annotations -from typing import Self - +from edify.builder.core import BuilderCore from edify.builder.mixins.anchors import AnchorsMixin from edify.builder.mixins.assertions import AssertionsMixin from edify.builder.mixins.captures import CapturesMixin @@ -25,10 +24,10 @@ from edify.builder.mixins.groups import GroupsMixin from edify.builder.mixins.quantifiers import QuantifiersMixin from edify.builder.mixins.subexpression import SubexpressionMixin from edify.builder.mixins.terminals import TerminalsMixin -from edify.builder.types.state import BuilderState class RegexBuilder( + BuilderCore, AnchorsMixin, AssertionsMixin, CapturesMixin, @@ -42,15 +41,3 @@ class RegexBuilder( TerminalsMixin, ): """A fluent, immutable, strongly-typed regex builder.""" - - _state: BuilderState - - def __init__(self) -> None: - self._state = BuilderState() - - def _with_state(self, new_state: BuilderState) -> Self: - """Return a fresh builder of the same concrete type carrying ``new_state``.""" - builder_class = type(self) - new_instance = builder_class.__new__(builder_class) - new_instance._state = new_state - return new_instance diff --git a/edify/builder/core.py b/edify/builder/core.py new file mode 100644 index 0000000..f6a1bd9 --- /dev/null +++ b/edify/builder/core.py @@ -0,0 +1,30 @@ +"""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, and the ``_with_state`` helper that +mixins call to produce new instances; concrete classes compose it with +their mixin set. +""" + +from __future__ import annotations + +from typing import Self + +from edify.builder.types.state import BuilderState + + +class BuilderCore: + """Holds the immutable :class:`BuilderState` and clones it on chain steps.""" + + _state: BuilderState + + def __init__(self) -> None: + self._state = BuilderState() + + 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 + return new_instance diff --git a/edify/builder/mixins/subexpression.py b/edify/builder/mixins/subexpression.py index 2fc0540..f3bcf1c 100644 --- a/edify/builder/mixins/subexpression.py +++ b/edify/builder/mixins/subexpression.py @@ -1,9 +1,15 @@ """The :class:`SubexpressionMixin` — merge another builder as a quantifiable atom. -Validates the incoming expression, transforms its elements through the -namespace / capture-offset / anchor logic in :mod:`edify.builder.merge`, -wraps the merged children in a :class:`SubexpressionElement`, and appends -that to the current frame (applying any pending quantifier). +Provides both :meth:`SubexpressionMixin.subexpression` (the primitive with +full option surface) and :meth:`SubexpressionMixin.use` (the ergonomic +alias that composes a :class:`edify.pattern.Pattern` or another builder +with the common-case defaults). + +Both entry points validate the incoming expression, transform its elements +through the namespace / capture-offset / anchor logic in +:mod:`edify.builder.merge`, wrap the merged children in a +:class:`SubexpressionElement`, and append that to the current frame +(applying any pending quantifier). """ from __future__ import annotations @@ -19,7 +25,22 @@ from edify.errors.structure import CannotCallSubexpressionError class SubexpressionMixin(BuilderProtocol): - """Provides the ``.subexpression`` chain method.""" + """Provides the ``.subexpression`` and ``.use`` chain methods.""" + + def use(self, pattern: BuilderProtocol) -> Self: + """Return a new builder with ``pattern`` embedded via subexpression semantics. + + Ergonomic alias for ``self.subexpression(pattern)`` using the + common-case defaults (``ignore_flags=True``, ``ignore_start_and_end=True``, + no namespace). Use :meth:`subexpression` directly when you need to + override any of those. + + Args: + pattern: A :class:`edify.pattern.Pattern` — or any fully-specified + fluent surface conforming to :class:`BuilderProtocol` — to + embed at the current position. + """ + return self.subexpression(pattern) def subexpression( self, diff --git a/edify/pattern/__init__.py b/edify/pattern/__init__.py index e69de29..a1ed07c 100644 --- a/edify/pattern/__init__.py +++ b/edify/pattern/__init__.py @@ -0,0 +1,3 @@ +from edify.pattern.composition import Pattern + +__all__ = ["Pattern"] diff --git a/edify/pattern/composition.py b/edify/pattern/composition.py new file mode 100644 index 0000000..cc09c44 --- /dev/null +++ b/edify/pattern/composition.py @@ -0,0 +1,42 @@ +"""The composition root for :class:`Pattern` — a reusable regex fragment. + +:class:`Pattern` shares every chain-method mixin with :class:`RegexBuilder` +except the terminals: a pattern is not intended to compile itself, it is +intended to compose *into* a builder (or into another pattern) via +``.use()`` or ``.subexpression()``. To emit a regex from a pattern, embed it +in a builder: ``RegexBuilder().use(my_pattern).to_regex()``. + +The immutable-state plumbing (``_state`` attribute + ``_with_state`` helper) +comes from :class:`edify.builder.core.BuilderCore`, the same base +:class:`edify.builder.builder.RegexBuilder` inherits from. +""" + +from __future__ import annotations + +from edify.builder.core import BuilderCore +from edify.builder.mixins.anchors import AnchorsMixin +from edify.builder.mixins.assertions import AssertionsMixin +from edify.builder.mixins.captures import CapturesMixin +from edify.builder.mixins.chain import ChainMixin +from edify.builder.mixins.chars import CharsMixin +from edify.builder.mixins.classes import ClassesMixin +from edify.builder.mixins.flags import FlagsMixin +from edify.builder.mixins.groups import GroupsMixin +from edify.builder.mixins.quantifiers import QuantifiersMixin +from edify.builder.mixins.subexpression import SubexpressionMixin + + +class Pattern( + BuilderCore, + AnchorsMixin, + AssertionsMixin, + CapturesMixin, + ChainMixin, + CharsMixin, + ClassesMixin, + FlagsMixin, + GroupsMixin, + QuantifiersMixin, + SubexpressionMixin, +): + """A named, reusable regex fragment. Composes into a builder via ``.use()``.""" diff --git a/tests/builder/use.test.py b/tests/builder/use.test.py new file mode 100644 index 0000000..10d7855 --- /dev/null +++ b/tests/builder/use.test.py @@ -0,0 +1,30 @@ +"""Tests for the ``.use(pattern)`` chain method on :class:`RegexBuilder`.""" + +from edify import Pattern, RegexBuilder + + +def test_use_embeds_a_pattern_at_the_current_position(): + username = Pattern().between(3, 20).word() + expression = RegexBuilder().string("User ").use(username) + assert expression.to_regex_string() == "User \\w{3,20}" + + +def test_use_composes_multiple_patterns_in_sequence(): + first_pattern = Pattern().exactly(3).digit() + second_pattern = Pattern().char("-") + third_pattern = Pattern().exactly(4).digit() + expression = RegexBuilder().use(first_pattern).use(second_pattern).use(third_pattern) + assert expression.to_regex_string() == "\\d{3}\\-\\d{4}" + + +def test_use_drops_the_pattern_flag_snapshot_by_default(): + case_insensitive_pattern = Pattern().ignore_case().string("hello") + expression = RegexBuilder().use(case_insensitive_pattern) + compiled = expression.to_regex() + assert compiled.flags & 2 == 0 + + +def test_use_accepts_another_regex_builder_as_the_source(): + fragment = RegexBuilder().one_or_more().digit() + expression = RegexBuilder().string("id=").use(fragment) + assert expression.to_regex_string() == "id=\\d+" diff --git a/tests/pattern/composition.test.py b/tests/pattern/composition.test.py new file mode 100644 index 0000000..9cf0003 --- /dev/null +++ b/tests/pattern/composition.test.py @@ -0,0 +1,40 @@ +"""Tests for the :class:`Pattern` composition surface.""" + +import pytest + +from edify import Pattern, RegexBuilder +from edify.errors.input import MustBeInstanceError + + +def test_pattern_builds_the_same_element_tree_as_a_builder(): + pattern = Pattern().between(3, 20).word() + builder = RegexBuilder().between(3, 20).word() + assert pattern._state == builder._state + + +def test_pattern_has_no_to_regex_terminal(): + pattern = Pattern().digit() + assert not hasattr(pattern, "to_regex") + + +def test_pattern_has_no_to_regex_string_terminal(): + pattern = Pattern().digit() + assert not hasattr(pattern, "to_regex_string") + + +def test_pattern_supports_nested_use_composition(): + inner = Pattern().one_or_more().digit() + outer = Pattern().string("v").use(inner) + embedded = RegexBuilder().use(outer) + assert embedded.to_regex_string() == "v\\d+" + + +def test_subexpression_still_accepts_a_pattern(): + pattern = Pattern().at_least(3).word() + embedded = RegexBuilder().subexpression(pattern) + assert embedded.to_regex_string() == "\\w{3,}" + + +def test_subexpression_rejects_non_builder_input(): + with pytest.raises(MustBeInstanceError): + RegexBuilder().subexpression("not a pattern") |
