diff options
| author | 夏音 / natsuoto.exe <[email protected]> | 2026-07-01 16:13:54 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-07-01 16:13:54 +0530 |
| commit | 42509f3a1d0c93c5fd43b2e2bdc42201d24a643e (patch) | |
| tree | 5f1544aed63e707d5fb2aeeb87b6e7e0dc98f5cd | |
| parent | a757473f3551426948069a1a25ef6657a572d759 (diff) | |
| parent | 341cf4fe45bb56677f0b42e287738999fb8a1ac5 (diff) | |
| download | edify-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__.py | 2 | ||||
| -rw-r--r-- | edify/builder/mixins/quantifiers.py | 13 | ||||
| -rw-r--r-- | edify/compile/quantifier/suffix.py | 3 | ||||
| -rw-r--r-- | edify/elements/types/quantifiers.py | 14 | ||||
| -rw-r--r-- | edify/elements/types/union.py | 2 | ||||
| -rw-r--r-- | edify/pattern/__init__.py | 2 | ||||
| -rw-r--r-- | edify/pattern/factories/__init__.py | 2 | ||||
| -rw-r--r-- | edify/pattern/factories/quantifiers.py | 7 | ||||
| -rw-r--r-- | tests/builder/builder.test.py | 6 | ||||
| -rw-r--r-- | tests/builder/validation.test.py | 5 | ||||
| -rw-r--r-- | tests/pattern/factories/quantifiers.test.py | 14 |
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) |
