aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-01 14:10:45 +0530
committernatsuoto <[email protected]>2026-07-01 14:10:45 +0530
commit391a4c2fd4afbaa5910aaf670a5e1c88c5e5aa84 (patch)
treefa0232556e112b320c0bfde96e3ef5f279b3df2d
parentbd3b12ff2f30c251c5fc52b43f81ec2f282d53ab (diff)
downloadedify-391a4c2fd4afbaa5910aaf670a5e1c88c5e5aa84.tar.xz
edify-391a4c2fd4afbaa5910aaf670a5e1c88c5e5aa84.zip
feat: add Pattern class and .use() composition (closes #125, #126)
-rw-r--r--edify/__init__.py2
-rw-r--r--edify/builder/builder.py23
-rw-r--r--edify/builder/core.py30
-rw-r--r--edify/builder/mixins/subexpression.py31
-rw-r--r--edify/pattern/__init__.py3
-rw-r--r--edify/pattern/composition.py42
-rw-r--r--tests/builder/use.test.py30
-rw-r--r--tests/pattern/composition.test.py40
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")