aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author夏音 / natsuoto.exe <[email protected]>2026-07-01 16:13:54 +0530
committerGitHub <[email protected]>2026-07-01 16:13:54 +0530
commit42509f3a1d0c93c5fd43b2e2bdc42201d24a643e (patch)
tree5f1544aed63e707d5fb2aeeb87b6e7e0dc98f5cd
parenta757473f3551426948069a1a25ef6657a572d759 (diff)
parent341cf4fe45bb56677f0b42e287738999fb8a1ac5 (diff)
downloadedify-42509f3a1d0c93c5fd43b2e2bdc42201d24a643e.tar.xz
edify-42509f3a1d0c93c5fd43b2e2bdc42201d24a643e.zip
feat: .at_most(n) quantifier closes the symmetry gap with at_least/exactly/between (#265)
Adds `.at_most(n)` on both `RegexBuilder`/`Pattern` and as an `at_most(n, operand)` factory. Closes the symmetry gap in the quantifier lineup — we had `exactly(n)`, `at_least(n)`, and `between(lo, hi)`, but no `{0,n}` shorthand. ## What is new - `AtMostElement(times, child)` in the elements union, rendered as `{0,times}` by the quantifier suffix dispatch. - `.at_most(count)` chain method on `QuantifiersMixin` — validates `count > 0` (matches the `at_least` contract). - `at_most(count, operand)` free-function factory in the humre-style API. - Re-exported through `edify.pattern` and top-level `edify`. ## Example ```python from edify import DIGIT, at_most, Pattern at_most(3, DIGIT).to_regex_string() # \d{0,3} Pattern().at_most(3).digit().to_regex_string() # \d{0,3} ``` Closes #130
-rw-r--r--edify/__init__.py2
-rw-r--r--edify/builder/mixins/quantifiers.py13
-rw-r--r--edify/compile/quantifier/suffix.py3
-rw-r--r--edify/elements/types/quantifiers.py14
-rw-r--r--edify/elements/types/union.py2
-rw-r--r--edify/pattern/__init__.py2
-rw-r--r--edify/pattern/factories/__init__.py2
-rw-r--r--edify/pattern/factories/quantifiers.py7
-rw-r--r--tests/builder/builder.test.py6
-rw-r--r--tests/builder/validation.test.py5
-rw-r--r--tests/pattern/factories/quantifiers.test.py14
11 files changed, 70 insertions, 0 deletions
diff --git a/edify/__init__.py b/edify/__init__.py
index f2a2c1f..430ac1c 100644
--- a/edify/__init__.py
+++ b/edify/__init__.py
@@ -26,6 +26,7 @@ from edify.pattern.factories import (
assert_not_ahead,
assert_not_behind,
at_least,
+ at_most,
back_reference,
between,
between_lazy,
@@ -86,6 +87,7 @@ __all__ = [
"assert_not_ahead",
"assert_not_behind",
"at_least",
+ "at_most",
"back_reference",
"between",
"between_lazy",
diff --git a/edify/builder/mixins/quantifiers.py b/edify/builder/mixins/quantifiers.py
index b485c87..f4572ec 100644
--- a/edify/builder/mixins/quantifiers.py
+++ b/edify/builder/mixins/quantifiers.py
@@ -15,6 +15,7 @@ from edify.builder.types.protocol import BuilderProtocol
from edify.elements.types.base import BaseElement
from edify.elements.types.quantifiers import (
AtLeastElement,
+ AtMostElement,
BetweenElement,
BetweenLazyElement,
ExactlyElement,
@@ -64,6 +65,11 @@ class QuantifiersMixin(BuilderProtocol):
_ensure_positive_integer("count", count)
return _set_pending(self, _at_least_factory(count))
+ def at_most(self, count: int) -> Self:
+ """Return a new builder with ``{0,count}`` queued as the pending quantifier."""
+ _ensure_positive_integer("count", count)
+ return _set_pending(self, _at_most_factory(count))
+
def between(self, lower: int, upper: int) -> Self:
"""Return a new builder with ``{lower,upper}`` queued as the pending quantifier."""
_ensure_non_negative_integer("x", lower)
@@ -120,6 +126,13 @@ def _at_least_factory(count: int) -> PendingQuantifier:
return factory
+def _at_most_factory(count: int) -> PendingQuantifier:
+ def factory(child: BaseElement) -> AtMostElement:
+ return AtMostElement(times=count, child=child)
+
+ return factory
+
+
def _between_factory(lower: int, upper: int) -> PendingQuantifier:
def factory(child: BaseElement) -> BetweenElement:
return BetweenElement(lower=lower, upper=upper, child=child)
diff --git a/edify/compile/quantifier/suffix.py b/edify/compile/quantifier/suffix.py
index aa38811..0f742db 100644
--- a/edify/compile/quantifier/suffix.py
+++ b/edify/compile/quantifier/suffix.py
@@ -11,6 +11,7 @@ from __future__ import annotations
from edify.elements.types.quantifiers import (
AtLeastElement,
+ AtMostElement,
BetweenElement,
BetweenLazyElement,
ExactlyElement,
@@ -47,6 +48,8 @@ def quantifier_suffix(quantifier: QuantifierElement) -> str:
return f"{{{exact_count}}}"
case AtLeastElement(times=minimum_count):
return f"{{{minimum_count},}}"
+ case AtMostElement(times=maximum_count):
+ return f"{{0,{maximum_count}}}"
case BetweenElement(lower=lower_bound, upper=upper_bound):
return f"{{{lower_bound},{upper_bound}}}"
case BetweenLazyElement(lower=lower_bound, upper=upper_bound):
diff --git a/edify/elements/types/quantifiers.py b/edify/elements/types/quantifiers.py
index 28bd122..41d82c8 100644
--- a/edify/elements/types/quantifiers.py
+++ b/edify/elements/types/quantifiers.py
@@ -11,6 +11,7 @@ Greedy quantifiers:
* :class:`OneOrMoreElement` — ``+``.
* :class:`ExactlyElement` — ``{n}``.
* :class:`AtLeastElement` — ``{n,}``.
+* :class:`AtMostElement` — ``{0,n}``.
* :class:`BetweenElement` — ``{lower,upper}``.
Lazy variants:
@@ -113,6 +114,19 @@ class AtLeastElement(BaseElement):
@dataclass(frozen=True)
+class AtMostElement(BaseElement):
+ """``{0,times}`` quantifier — at most ``times`` matches.
+
+ Attributes:
+ times: The maximum number of repetitions allowed.
+ child: The element this quantifier applies to.
+ """
+
+ times: int
+ child: BaseElement
+
+
+@dataclass(frozen=True)
class BetweenElement(BaseElement):
"""``{lower,upper}`` greedy quantifier — between ``lower`` and ``upper`` matches.
diff --git a/edify/elements/types/union.py b/edify/elements/types/union.py
index bff76da..e5a0785 100644
--- a/edify/elements/types/union.py
+++ b/edify/elements/types/union.py
@@ -53,6 +53,7 @@ from edify.elements.types.leaves import (
)
from edify.elements.types.quantifiers import (
AtLeastElement,
+ AtMostElement,
BetweenElement,
BetweenLazyElement,
ExactlyElement,
@@ -115,6 +116,7 @@ QuantifierElement = (
| OneOrMoreLazyElement
| ExactlyElement
| AtLeastElement
+ | AtMostElement
| BetweenElement
| BetweenLazyElement
)
diff --git a/edify/pattern/__init__.py b/edify/pattern/__init__.py
index 20a6826..76090d5 100644
--- a/edify/pattern/__init__.py
+++ b/edify/pattern/__init__.py
@@ -21,6 +21,7 @@ from edify.pattern.factories import (
assert_not_ahead,
assert_not_behind,
at_least,
+ at_most,
back_reference,
between,
between_lazy,
@@ -66,6 +67,7 @@ __all__ = [
"assert_not_ahead",
"assert_not_behind",
"at_least",
+ "at_most",
"back_reference",
"between",
"between_lazy",
diff --git a/edify/pattern/factories/__init__.py b/edify/pattern/factories/__init__.py
index 3111705..b261616 100644
--- a/edify/pattern/factories/__init__.py
+++ b/edify/pattern/factories/__init__.py
@@ -18,6 +18,7 @@ from edify.pattern.factories.groups import (
)
from edify.pattern.factories.quantifiers import (
at_least,
+ at_most,
between,
between_lazy,
exactly,
@@ -44,6 +45,7 @@ __all__ = [
"assert_not_ahead",
"assert_not_behind",
"at_least",
+ "at_most",
"back_reference",
"between",
"between_lazy",
diff --git a/edify/pattern/factories/quantifiers.py b/edify/pattern/factories/quantifiers.py
index 1607a48..3278dca 100644
--- a/edify/pattern/factories/quantifiers.py
+++ b/edify/pattern/factories/quantifiers.py
@@ -14,6 +14,7 @@ from __future__ import annotations
from edify.builder.types.protocol import BuilderProtocol
from edify.elements.types.quantifiers import (
AtLeastElement,
+ AtMostElement,
BetweenElement,
BetweenLazyElement,
ExactlyElement,
@@ -69,6 +70,12 @@ def at_least(count: int, operand: BuilderProtocol) -> Pattern:
return pattern_containing(AtLeastElement(times=count, child=target_element(operand)))
+def at_most(count: int, operand: BuilderProtocol) -> Pattern:
+ """Return ``operand`` wrapped in ``{0,count}``."""
+ _ensure_positive_integer("count", count)
+ return pattern_containing(AtMostElement(times=count, child=target_element(operand)))
+
+
def between(lower: int, upper: int, operand: BuilderProtocol) -> Pattern:
"""Return ``operand`` wrapped in a greedy ``{lower,upper}`` quantifier."""
_ensure_non_negative_integer("x", lower)
diff --git a/tests/builder/builder.test.py b/tests/builder/builder.test.py
index 062e1ae..33aa17d 100644
--- a/tests/builder/builder.test.py
+++ b/tests/builder/builder.test.py
@@ -348,6 +348,12 @@ def test_at_least():
regex_compilation("\\w{3,}", expr)
+def test_at_most():
+ expr = RegexBuilder().at_most(3).word()
+ regex_equality("\\w{0,3}", expr)
+ regex_compilation("\\w{0,3}", expr)
+
+
def test_between():
expr = RegexBuilder().between(3, 5).word()
regex_equality("\\w{3,5}", expr)
diff --git a/tests/builder/validation.test.py b/tests/builder/validation.test.py
index e4898ad..53128c5 100644
--- a/tests/builder/validation.test.py
+++ b/tests/builder/validation.test.py
@@ -109,6 +109,11 @@ def test_at_least_non_positive_raises():
RegexBuilder().at_least(-1).digit()
+def test_at_most_non_positive_raises():
+ with pytest.raises(MustBePositiveIntegerError):
+ RegexBuilder().at_most(0).digit()
+
+
def test_between_negative_lower_raises():
with pytest.raises(MustBeIntegerGreaterThanZeroError):
RegexBuilder().between(-1, 5).digit()
diff --git a/tests/pattern/factories/quantifiers.test.py b/tests/pattern/factories/quantifiers.test.py
index 8a6f34f..a0f558a 100644
--- a/tests/pattern/factories/quantifiers.test.py
+++ b/tests/pattern/factories/quantifiers.test.py
@@ -7,6 +7,7 @@ from edify import (
WORD,
Pattern,
at_least,
+ at_most,
between,
between_lazy,
char,
@@ -62,6 +63,14 @@ def test_at_least_produces_open_ended_quantifier():
assert at_least(2, DIGIT).to_regex_string() == "\\d{2,}"
+def test_at_most_produces_zero_lower_bound_quantifier():
+ assert at_most(3, DIGIT).to_regex_string() == "\\d{0,3}"
+
+
+def test_at_most_groups_a_multi_element_operand():
+ assert at_most(3, DIGIT + WORD).to_regex_string() == "(?:\\d\\w){0,3}"
+
+
def test_between_produces_greedy_bounded_quantifier():
assert between(2, 4, DIGIT).to_regex_string() == "\\d{2,4}"
@@ -84,6 +93,11 @@ def test_at_least_rejects_negative_count():
at_least(-1, DIGIT)
+def test_at_most_rejects_zero_count():
+ with pytest.raises(MustBePositiveIntegerError):
+ at_most(0, DIGIT)
+
+
def test_between_rejects_negative_lower_bound():
with pytest.raises(MustBeIntegerGreaterThanZeroError):
between(-1, 5, DIGIT)