diff options
| author | 夏音 / natsuoto.exe <[email protected]> | 2026-07-01 17:31:04 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-07-01 17:31:04 +0530 |
| commit | ad4eb75ddcadf263391bba8eed837cce3b860cf9 (patch) | |
| tree | b3ec15be9350eec74079c136d68dbd6c0239e32b | |
| parent | 2d9e7395dc62ca7ff9c0bf508ca4fb5e823b538e (diff) | |
| parent | a2195e709d7c4f1fc3c75740446397f1b62c37a0 (diff) | |
| download | edify-ad4eb75ddcadf263391bba8eed837cce3b860cf9.tar.xz edify-ad4eb75ddcadf263391bba8eed837cce3b860cf9.zip | |
fix!: quantifier validation — dangling and stacked quantifiers raise; Hypothesis property gate (#269)
Tightens quantifier semantics so the builder can no longer silently drop
a quantifier from the emitted regex, and locks the invariant in with a
Hypothesis property test.
## Dangling quantifier now raises
`RegexBuilder().exactly(3).to_regex_string()` used to emit `^$` — the
quantifier had no operand, so it was silently discarded. It now raises
`DanglingQuantifierError` at emit time.
```python
RegexBuilder().exactly(3).to_regex_string()
# edify.errors.quantifier.DanglingQuantifierError:
# Dangling quantifier with no operand. Append an element (e.g. .digit()) before compiling.
```
## Stacked quantifiers now raise
`RegexBuilder().one_or_more().exactly(3).digit()` used to emit `\d{3}` —
the second quantifier overwrote the first, silently dropping
`one_or_more`. It now raises `StackedQuantifierError` at chain-call
time.
```python
RegexBuilder().one_or_more().exactly(3).digit()
# edify.errors.quantifier.StackedQuantifierError:
# Cannot stack a quantifier on top of another pending quantifier.
# Add an operand between the two quantifiers or drop one.
```
## Property assertion
Hypothesis-driven test in `tests/builder/properties.test.py` — for any
list of `(quantifier method, element method)` pairs, the emitted regex
is exactly the concatenation of `<element><suffix>` fragments in order.
`hypothesis>=6.100` added to the `dev` dependency group.
## Breaking
Any chain that previously silently emitted a wrong-but-parseable regex
now raises. Correct chains are unaffected.
Closes #109
Closes #110
Closes #111
| -rw-r--r-- | edify/builder/mixins/quantifiers.py | 10 | ||||
| -rw-r--r-- | edify/builder/mixins/terminals.py | 9 | ||||
| -rw-r--r-- | edify/errors/quantifier.py | 35 | ||||
| -rw-r--r-- | pyproject.toml | 1 | ||||
| -rw-r--r-- | tests/builder/properties.test.py | 73 | ||||
| -rw-r--r-- | tests/errors/quantifier.test.py | 88 | ||||
| -rw-r--r-- | uv.lock | 23 |
7 files changed, 238 insertions, 1 deletions
diff --git a/edify/builder/mixins/quantifiers.py b/edify/builder/mixins/quantifiers.py index f4572ec..e7e0cda 100644 --- a/edify/builder/mixins/quantifiers.py +++ b/edify/builder/mixins/quantifiers.py @@ -30,6 +30,7 @@ from edify.errors.input import ( MustBeLessThanError, MustBePositiveIntegerError, ) +from edify.errors.quantifier import StackedQuantifierError class QuantifiersMixin(BuilderProtocol): @@ -86,7 +87,14 @@ class QuantifiersMixin(BuilderProtocol): def _set_pending(builder: BuilderProtocol, pending_quantifier: PendingQuantifier): - """Replace the top frame with one carrying the given pending quantifier.""" + """Replace the top frame with one carrying the given pending quantifier. + + Raises :class:`StackedQuantifierError` when the top frame already carries + an unconsumed pending quantifier — stacking would silently drop the outer + one at emit time. + """ + if builder._state.top_frame.quantifier is not None: + raise StackedQuantifierError() new_top_frame = builder._state.top_frame.with_quantifier(pending_quantifier) new_state = builder._state.with_top_frame_replaced(new_top_frame) return builder._with_state(new_state) diff --git a/edify/builder/mixins/terminals.py b/edify/builder/mixins/terminals.py index 85b061a..b648b77 100644 --- a/edify/builder/mixins/terminals.py +++ b/edify/builder/mixins/terminals.py @@ -15,6 +15,7 @@ from edify.builder.types.protocol import BuilderProtocol from edify.compile.dispatch import render_element from edify.elements.types.root import RootElement from edify.errors.internal import FailedToCompileRegexError +from edify.errors.quantifier import DanglingQuantifierError from edify.errors.structure import CannotCallSubexpressionError _EMPTY_NON_CAPTURING_GROUP = "(?:)" @@ -32,6 +33,7 @@ class TerminalsMixin(BuilderProtocol): no surrounding ``/.../`` delimiters and no embedded flag suffix. """ _ensure_fully_specified(self) + _ensure_no_dangling_quantifier(self) root_element = RootElement(children=self._state.top_frame.children) rendered_pattern = render_element(root_element) unescaped_pattern = rendered_pattern.replace(_ESCAPED_SPACE, _RAW_SPACE) @@ -57,6 +59,13 @@ def _ensure_fully_specified(builder: BuilderProtocol) -> None: raise CannotCallSubexpressionError(top_frame_type_name) +def _ensure_no_dangling_quantifier(builder: BuilderProtocol) -> None: + """Raise :class:`DanglingQuantifierError` when any frame carries an unconsumed quantifier.""" + for frame in builder._state.stack: + if frame.quantifier is not None: + raise DanglingQuantifierError() + + def _build_flag_bitmask(flags: Flags) -> int: """Combine the True-valued fields of ``flags`` into a single :mod:`re` bitmask.""" bitmask = 0 diff --git a/edify/errors/quantifier.py b/edify/errors/quantifier.py new file mode 100644 index 0000000..23e435e --- /dev/null +++ b/edify/errors/quantifier.py @@ -0,0 +1,35 @@ +"""Exception classes raised for quantifier misuse in a builder chain. + +* :class:`DanglingQuantifierError` — a terminal was called with a pending + quantifier that never received an operand. Emitting silently would + drop the quantifier from the output. +* :class:`StackedQuantifierError` — a quantifier chain method was called + while another quantifier was already pending. Emitting silently would + drop the outer quantifier. +""" + +from __future__ import annotations + +from edify.errors.syntax import EdifySyntaxError + + +class DanglingQuantifierError(EdifySyntaxError): + """Raised when a terminal is called while a quantifier is still pending.""" + + def __init__(self) -> None: + message = ( + "Dangling quantifier with no operand. " + "Append an element (e.g. .digit()) before compiling." + ) + super().__init__(message) + + +class StackedQuantifierError(EdifySyntaxError): + """Raised when a quantifier chain method is called with another quantifier already pending.""" + + def __init__(self) -> None: + message = ( + "Cannot stack a quantifier on top of another pending quantifier. " + "Add an operand between the two quantifiers or drop one." + ) + super().__init__(message) diff --git a/pyproject.toml b/pyproject.toml index 6fb6c03..df02ef5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ Changelog = "https://edify.readthedocs.io/en/latest/changelog.html" [dependency-groups] dev = [ + "hypothesis>=6.100", "pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.7", diff --git a/tests/builder/properties.test.py b/tests/builder/properties.test.py new file mode 100644 index 0000000..24f5d01 --- /dev/null +++ b/tests/builder/properties.test.py @@ -0,0 +1,73 @@ +"""Property assertion — no chain ever silently drops a quantifier. + +For any list of ``(quantifier method, args)`` calls, each immediately +followed by a leaf-element call, the emitted regex is the concatenation +of ``<element><suffix>`` fragments in the same order — no quantifier is +lost, none appears twice. +""" + +from hypothesis import given +from hypothesis import strategies as st + +from edify import RegexBuilder + +_QUANTIFIER_STRATEGIES: list[st.SearchStrategy[tuple[str, tuple[int, ...], str]]] = [ + st.just(("optional", (), "?")), + st.just(("zero_or_more", (), "*")), + st.just(("zero_or_more_lazy", (), "*?")), + st.just(("one_or_more", (), "+")), + st.just(("one_or_more_lazy", (), "+?")), + st.integers(min_value=1, max_value=8).map(lambda n: ("exactly", (n,), f"{{{n}}}")), + st.integers(min_value=1, max_value=8).map(lambda n: ("at_least", (n,), f"{{{n},}}")), + st.integers(min_value=1, max_value=8).map(lambda n: ("at_most", (n,), f"{{0,{n}}}")), + st.tuples( + st.integers(min_value=0, max_value=6), + st.integers(min_value=1, max_value=8), + ) + .filter(lambda pair: pair[0] < pair[1]) + .map(lambda pair: ("between", pair, f"{{{pair[0]},{pair[1]}}}")), + st.tuples( + st.integers(min_value=0, max_value=6), + st.integers(min_value=1, max_value=8), + ) + .filter(lambda pair: pair[0] < pair[1]) + .map(lambda pair: ("between_lazy", pair, f"{{{pair[0]},{pair[1]}}}?")), +] + +_ELEMENT_STRATEGIES = [ + st.just(("digit", (), "\\d")), + st.just(("word", (), "\\w")), + st.just(("whitespace_char", (), "\\s")), + st.just(("letter", (), "[a-zA-Z]")), + st.just(("uppercase", (), "[A-Z]")), + st.just(("lowercase", (), "[a-z]")), + st.just(("alphanumeric", (), "[a-zA-Z0-9]")), +] + +_quantifier_element_pair = st.tuples( + st.one_of(*_QUANTIFIER_STRATEGIES), + st.one_of(*_ELEMENT_STRATEGIES), +) + + +@given(st.lists(_quantifier_element_pair, min_size=1, max_size=8)) +def test_every_quantifier_chain_call_produces_exactly_one_output_quantifier(pairs): + builder = RegexBuilder() + expected_fragments: list[str] = [] + for quantifier_call, element_call in pairs: + quantifier_name, quantifier_args, quantifier_suffix = quantifier_call + element_name, element_args, element_regex = element_call + builder = getattr(builder, quantifier_name)(*quantifier_args) + builder = getattr(builder, element_name)(*element_args) + expected_fragments.append(f"{element_regex}{quantifier_suffix}") + assert builder.to_regex_string() == "".join(expected_fragments) + + +@given(st.lists(st.one_of(*_ELEMENT_STRATEGIES), min_size=1, max_size=8)) +def test_bare_element_chain_emits_the_concatenation_of_element_fragments(elements): + builder = RegexBuilder() + expected_fragments: list[str] = [] + for element_name, element_args, element_regex in elements: + builder = getattr(builder, element_name)(*element_args) + expected_fragments.append(element_regex) + assert builder.to_regex_string() == "".join(expected_fragments) diff --git a/tests/errors/quantifier.test.py b/tests/errors/quantifier.test.py new file mode 100644 index 0000000..27b5ddf --- /dev/null +++ b/tests/errors/quantifier.test.py @@ -0,0 +1,88 @@ +"""Tests for the :mod:`edify.errors.quantifier` exception classes.""" + +import pytest + +from edify import Pattern, RegexBuilder +from edify.errors.quantifier import DanglingQuantifierError, StackedQuantifierError + + +def test_to_regex_string_raises_when_a_bare_quantifier_has_no_operand(): + with pytest.raises(DanglingQuantifierError): + RegexBuilder().exactly(3).to_regex_string() + + +def test_to_regex_string_raises_for_optional_with_no_operand(): + with pytest.raises(DanglingQuantifierError): + RegexBuilder().optional().to_regex_string() + + +def test_to_regex_string_raises_for_zero_or_more_with_no_operand(): + with pytest.raises(DanglingQuantifierError): + RegexBuilder().zero_or_more().to_regex_string() + + +def test_to_regex_string_raises_for_one_or_more_with_no_operand(): + with pytest.raises(DanglingQuantifierError): + RegexBuilder().one_or_more().to_regex_string() + + +def test_to_regex_string_raises_for_between_with_no_operand(): + with pytest.raises(DanglingQuantifierError): + RegexBuilder().between(2, 5).to_regex_string() + + +def test_pattern_to_regex_string_raises_when_a_bare_quantifier_has_no_operand(): + with pytest.raises(DanglingQuantifierError): + Pattern().exactly(3).to_regex_string() + + +def test_to_regex_raises_when_a_bare_quantifier_has_no_operand(): + with pytest.raises(DanglingQuantifierError): + RegexBuilder().at_least(2).to_regex() + + +def test_dangling_message_hints_at_appending_an_operand(): + with pytest.raises(DanglingQuantifierError, match="Append an element"): + RegexBuilder().exactly(3).to_regex_string() + + +def test_dangling_quantifier_error_message_contains_expected_text(): + error = DanglingQuantifierError() + assert "Dangling quantifier" in str(error) + assert "no operand" in str(error) + + +def test_stacking_one_or_more_over_exactly_raises(): + with pytest.raises(StackedQuantifierError): + RegexBuilder().one_or_more().exactly(3).digit() + + +def test_stacking_optional_over_at_least_raises(): + with pytest.raises(StackedQuantifierError): + RegexBuilder().optional().at_least(2).digit() + + +def test_stacking_between_over_zero_or_more_raises(): + with pytest.raises(StackedQuantifierError): + RegexBuilder().zero_or_more().between(1, 3).digit() + + +def test_stacking_lazy_variants_also_raises(): + with pytest.raises(StackedQuantifierError): + RegexBuilder().one_or_more_lazy().exactly(2).digit() + + +def test_stacking_on_pattern_raises(): + with pytest.raises(StackedQuantifierError): + Pattern().one_or_more().exactly(3).digit() + + +def test_a_valid_quantifier_element_quantifier_element_chain_works(): + expr = RegexBuilder().one_or_more().digit().exactly(3).word() + assert expr.to_regex_string() == "\\d+\\w{3}" + + +def test_stacked_quantifier_error_message_contains_expected_text(): + error = StackedQuantifierError() + assert "stack" in str(error) + assert "pending" in str(error) @@ -235,6 +235,7 @@ source = { editable = "." } [package.dev-dependencies] dev = [ + { name = "hypothesis" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -249,6 +250,7 @@ docs = [ [package.metadata.requires-dev] dev = [ + { name = "hypothesis", specifier = ">=6.100" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "ruff", specifier = ">=0.7" }, @@ -259,6 +261,18 @@ docs = [ ] [[package]] +name = "hypothesis" +version = "6.155.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/55/983b6bc1b6b343a5ff6020388f9d0680ab477be59a731517e6c4a0387100/hypothesis-6.155.7.tar.gz", hash = "sha256:d8d6091753d0669db3c90c5e5b346cb37c72f3dd9378c8413acb1fd5da63f7ea", size = 478291, upload-time = "2026-06-21T05:54:31.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/f8/c151e196d4f397ed9436a071e52666c70a2f021138dea828b0a461e245db/hypothesis-6.155.7-py3-none-any.whl", hash = "sha256:9f634bdb1f9e9b8ab6ba09431cf2deedb750c96978125a6fb3c5a0f6c6db4131", size = 544762, upload-time = "2026-06-21T05:54:29.506Z" }, +] + +[[package]] name = "idna" version = "3.18" source = { registry = "https://pypi.org/simple" } @@ -487,6 +501,15 @@ wheels = [ ] [[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] name = "sphinx" version = "9.0.4" source = { registry = "https://pypi.org/simple" } |
