aboutsummaryrefslogtreecommitdiff
path: root/tests/builder
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-07-15 14:45:56 +0530
committerGitHub <[email protected]>2026-07-15 14:45:56 +0530
commit3dcca5d124fb0c5e92995bf5116f34c8794235d8 (patch)
treed06261d4d0a7c87387615c59394fcdf71c6902e2 /tests/builder
parent496734c57c0a065119263c6fdca4598bb8e423f5 (diff)
parent30c51f8ea0c29a1350da9f92fc6ccc4c6db06375 (diff)
downloadedify-3dcca5d124fb0c5e92995bf5116f34c8794235d8.tar.xz
edify-3dcca5d124fb0c5e92995bf5116f34c8794235d8.zip
feat: optional regex engine, per-instance compile cache, testing helpers, and reverse parser (#280)
Ships the opt-in regex-engine backend and the builder-ergonomics polish end to end. ## Optional regex-engine backend - Introduces `edify/compile/backend.py` — a small backend layer that owns the "compile a rendered pattern string with these flags" operation per engine. - `edify.RegexBuilder().*.to_regex(engine="re" | "regex")` now actually routes: `"re"` uses the stdlib :mod:`re` module (default, unchanged); `"regex"` uses the third-party `regex` module. - The `regex` module is a **deferred import** — `import edify` never imports it. When the caller selects `engine="regex"` without the extra installed, they get a clean, annotated `MissingRegexBackendError` telling them to `pip install edify[regex]`, not a `ModuleNotFoundError` deep in a traceback. - Under `engine="regex"`, **variable-width lookbehind** compiles cleanly (`assert_behind` bodies with quantifiers like `.between(1, 3).string("foo")` no longer fail). Under `engine="re"`, the compile path catches stdlib re's cryptic "look-behind requires fixed-width pattern" and re-raises `VariableWidthLookbehindNotSupportedError` — an annotated message that names the construct and points the caller at `engine="regex"`. - Every match method on `Regex` (`match`, `search`, `fullmatch`, `findall`, `finditer`, `sub`, `subn`, `split`) accepts a per-call `timeout=` kwarg. Under `engine="regex"` the value flows through to the underlying pattern; under `engine="re"` it raises `TimeoutNotSupportedByEngineError`. - Two new CI jobs — one installs edify without the extra and verifies both the friendly ImportError path and the re engine's happy path; the other installs `edify[regex]` and verifies both engines compile plus the variable-width-lookbehind fixture only compiles under `engine="regex"`. ## Builder ergonomics - **Per-instance lazy compile cache.** The first no-kwargs `.to_regex()` (or match verb) compiles once and caches the resulting `Regex`; every subsequent no-kwargs call returns the same object (`builder.to_regex() is builder.to_regex()`). A chain step or `fork()` produces a fresh builder with its own empty cache. Kwarg calls bypass the cache and always compile fresh. - **Match-verb surface closed at five.** `MatcherMixin` now exposes exactly `test` / `match` / `search` / `findall` / `sub` on the builder. The four less-common verbs (`fullmatch`, `finditer`, `subn`, `split`) are reached through `.to_regex()` on the `Regex` wrapper. A discipline test asserts the closed surface and the absence of every other `re.Pattern` attribute (`groups`, `groupindex`, `pattern`, `flags`). - **`Pattern.__call__` delegates to `.test`** — so a `Pattern` doubles as a validator callable (`email(value) -> bool`) without an intermediate `.test` step. - **Testing helpers.** `.assert_matches([...])` and `.assert_rejects([...])` raise annotated `PatternDidNotMatchInputsError` / `PatternMatchedRejectedInputsError` (both subclasses of `AssertionError` so pytest introspects them exactly like a bare `assert`). - **Match wrapper.** Every match verb now returns an edify `Match` that exposes named captures as attributes — `m.username` and `m.captures.username` — while delegating everything else (`group()`, `groupdict()`, `span()`, ...) to the underlying `re.Match` via `__getattr__`. `re.Pattern.sub`/`subn` callables receive the wrapped `Match`. - **`RegexBuilder.from_regex(pattern)` reverse parser.** Converts a raw regex string back into a builder chain via `re._parser.parse`, covering the common construct set (literals, character classes with ranges/categories, quantifiers greedy + lazy including `?`/`*`/`+`/`{n}`/`{m,n}`, capture / named-capture / non-capturing groups, all four lookarounds, alternation over literals, anchors, categories). Anything unrecognized raises the annotated `UnsupportedReverseParseError`. - **Per-call allocation win.** Cached `os.path.abspath` for caller-context filenames, cached `co_positions()` per code object, and moved `source_line` reading behind a lazy property on `CallerContext`. Chain-step allocation is ~4.6× faster on a 300-step build/compile microbenchmark (9.4s → 2.0s / 1000 iterations). - **Immutability contract documented.** Dedicated section in the API reference + one-liner in the README explaining that every chain method returns a new builder, that branching (`base.exactly(2).digit()` vs `base.end_of_input()`) is always safe, and that repeat `.to_regex()` returns the cached instance. ## Breaking changes - `MatcherMixin` no longer exposes `fullmatch`, `finditer`, `subn`, `split` on the builder — call `.to_regex()` first and use them on the returned `Regex`. - `Regex.match` / `.search` / `.fullmatch` and `Regex.finditer` now return the edify `Match` wrapper (or an iterator of it) rather than a bare `re.Match`. Existing code that calls `.group(...)`, `.groupdict()`, `.span(...)`, etc. keeps working via `__getattr__` delegation. - `EngineNotWiredError` is deleted; both engines are wired now, so the class had no live callers. Closes #74, closes #115, closes #116, closes #117, closes #118, closes #119, closes #120, closes #121, closes #129, closes #138, closes #140, closes #141, closes #142, closes #143, closes #144, closes #149, closes #150, closes #151, closes #175.
Diffstat (limited to 'tests/builder')
-rw-r--r--tests/builder/cache.test.py63
-rw-r--r--tests/builder/engine.test.py42
-rw-r--r--tests/builder/flags.test.py45
-rw-r--r--tests/builder/lookbehind.test.py59
-rw-r--r--tests/builder/matcher.test.py18
-rw-r--r--tests/builder/reverse.test.py113
-rw-r--r--tests/builder/testing.test.py71
-rw-r--r--tests/builder/timeout.test.py85
8 files changed, 483 insertions, 13 deletions
diff --git a/tests/builder/cache.test.py b/tests/builder/cache.test.py
new file mode 100644
index 0000000..700a8e4
--- /dev/null
+++ b/tests/builder/cache.test.py
@@ -0,0 +1,63 @@
+"""Tests for the per-instance lazy compile cache."""
+
+from edify import Pattern, RegexBuilder
+
+
+def test_two_no_kwargs_to_regex_calls_return_the_same_instance():
+ builder = RegexBuilder().one_or_more().digit()
+ first = builder.to_regex()
+ second = builder.to_regex()
+ assert first is second
+
+
+def test_matcher_call_shares_the_cache_with_to_regex():
+ builder = RegexBuilder().one_or_more().digit()
+ first = builder.to_regex()
+ builder.test("42")
+ assert builder.to_regex() is first
+
+
+def test_kwarg_call_bypasses_the_cache_and_never_populates_it():
+ builder = RegexBuilder().string("ABC")
+ with_kwargs = builder.to_regex(ignore_case=True)
+ default = builder.to_regex()
+ assert with_kwargs is not default
+ assert builder.to_regex() is default
+
+
+def test_regex_engine_kwarg_bypasses_the_cache():
+ builder = RegexBuilder().digit()
+ default = builder.to_regex()
+ via_regex = builder.to_regex(engine="regex")
+ assert default is not via_regex
+ assert default.engine == "re"
+ assert via_regex.engine == "regex"
+
+
+def test_kwarg_call_first_still_leaves_a_default_no_kwargs_call_cacheable():
+ builder = RegexBuilder().string("hi")
+ builder.to_regex(ignore_case=True)
+ first_default = builder.to_regex()
+ second_default = builder.to_regex()
+ assert first_default is second_default
+
+
+def test_forked_builder_gets_its_own_empty_cache():
+ parent = RegexBuilder().digit()
+ parent.to_regex()
+ child = parent.fork()
+ assert child.to_regex() is child.to_regex()
+ assert parent.to_regex() is not child.to_regex()
+
+
+def test_chain_step_yields_a_fresh_cache_slot():
+ root = RegexBuilder().digit()
+ original = root.to_regex()
+ extended = root.word()
+ extended_regex = extended.to_regex()
+ assert extended_regex is not original
+
+
+def test_pattern_also_caches_across_repeat_to_regex_calls():
+ pattern = Pattern().string("hi")
+ assert pattern.to_regex() is pattern.to_regex()
diff --git a/tests/builder/engine.test.py b/tests/builder/engine.test.py
index e569c71..c6a32d1 100644
--- a/tests/builder/engine.test.py
+++ b/tests/builder/engine.test.py
@@ -1,19 +1,53 @@
+import sys
+
import pytest
from edify import RegexBuilder
-from edify.errors.engine import EngineNotWiredError
+from edify.errors.backend import MissingRegexBackendError
def test_engine_defaults_to_re_and_compiles():
compiled = RegexBuilder().digit().to_regex()
assert compiled.source == "\\d"
+ assert compiled.engine == "re"
-def test_engine_re_explicit_is_accepted():
+def test_engine_re_explicit_compiles_via_stdlib():
compiled = RegexBuilder().digit().to_regex(engine="re")
assert compiled.source == "\\d"
+ assert compiled.engine == "re"
+ assert compiled.compiled.__class__.__module__ == "re"
+
+
+def test_engine_regex_compiles_via_third_party_module():
+ compiled = RegexBuilder().digit().to_regex(engine="regex")
+ assert compiled.source == "\\d"
+ assert compiled.engine == "regex"
+ assert compiled.compiled.__class__.__module__.endswith("regex")
+
+
+def test_engine_regex_and_engine_re_are_not_equal_for_the_same_source():
+ left = RegexBuilder().digit().to_regex(engine="re")
+ right = RegexBuilder().digit().to_regex(engine="regex")
+ assert left != right
-def test_engine_regex_raises_until_wired():
- with pytest.raises(EngineNotWiredError, match="engine='regex'"):
+def test_engine_regex_raises_clean_import_error_without_the_extra(monkeypatch):
+ monkeypatch.setitem(sys.modules, "regex", None)
+ with pytest.raises(MissingRegexBackendError, match="engine='regex'") as excinfo:
RegexBuilder().digit().to_regex(engine="regex")
+ text = str(excinfo.value)
+ assert "pip install edify[regex]" in text
+ assert "= note:" in text
+
+
+def test_missing_regex_backend_error_chains_from_underlying_import_error(monkeypatch):
+ monkeypatch.setitem(sys.modules, "regex", None)
+ with pytest.raises(MissingRegexBackendError) as excinfo:
+ RegexBuilder().digit().to_regex(engine="regex")
+ assert isinstance(excinfo.value.__cause__, ImportError)
+
+
+def test_regex_engine_flags_propagate():
+ compiled = RegexBuilder().string("ABC").to_regex(engine="regex", ignore_case=True)
+ assert bool(compiled.search("abc"))
diff --git a/tests/builder/flags.test.py b/tests/builder/flags.test.py
index d35d2ff..02e875e 100644
--- a/tests/builder/flags.test.py
+++ b/tests/builder/flags.test.py
@@ -70,3 +70,48 @@ def test_multiple_kwargs_combine():
assert compiled.compiled.flags & re.I == re.I
assert compiled.compiled.flags & re.M == re.M
assert compiled.compiled.flags & re.S == re.S
+
+
+def _regex_module():
+ import regex
+
+ return regex
+
+
+def test_regex_engine_ignore_case_kwarg_enables_the_flag():
+ regex = _regex_module()
+ compiled = RegexBuilder().string("hi").to_regex(engine="regex", ignore_case=True)
+ assert compiled.compiled.flags & regex.I == regex.I
+
+
+def test_regex_engine_multiline_kwarg_enables_the_flag():
+ regex = _regex_module()
+ compiled = RegexBuilder().string("hi").to_regex(engine="regex", multiline=True)
+ assert compiled.compiled.flags & regex.M == regex.M
+
+
+def test_regex_engine_dotall_kwarg_enables_the_flag():
+ regex = _regex_module()
+ compiled = RegexBuilder().string("hi").to_regex(engine="regex", dotall=True)
+ assert compiled.compiled.flags & regex.S == regex.S
+
+
+def test_regex_engine_ascii_only_kwarg_enables_the_flag():
+ regex = _regex_module()
+ compiled = RegexBuilder().string("hi").to_regex(engine="regex", ascii_only=True)
+ assert compiled.compiled.flags & regex.A == regex.A
+
+
+def test_regex_engine_verbose_kwarg_enables_the_flag():
+ regex = _regex_module()
+ compiled = RegexBuilder().string("hi").to_regex(engine="regex", verbose=True)
+ assert compiled.compiled.flags & regex.X == regex.X
+
+
+ _ON_PYPY,
+ reason="PyPy's regex.DEBUG opcode disassembler shares the same upstream disassembler bug",
+)
+def test_regex_engine_debug_kwarg_compiles_without_error():
+ compiled = RegexBuilder().string("hi").to_regex(engine="regex", debug=True)
+ assert compiled.source == "hi"
diff --git a/tests/builder/lookbehind.test.py b/tests/builder/lookbehind.test.py
new file mode 100644
index 0000000..f16a6cc
--- /dev/null
+++ b/tests/builder/lookbehind.test.py
@@ -0,0 +1,59 @@
+"""Tests for lookbehind width behavior across the ``re`` and ``regex`` engines."""
+
+import pytest
+
+from edify import RegexBuilder
+from edify.errors.backend import VariableWidthLookbehindNotSupportedError
+
+
+def _variable_width_lookbehind_builder():
+ return RegexBuilder().assert_behind().between(1, 3).string("foo").end().string("bar")
+
+
+def _fixed_width_lookbehind_builder():
+ return RegexBuilder().assert_behind().string("foo").end().string("bar")
+
+
+def test_variable_width_lookbehind_under_re_raises_annotated_error():
+ with pytest.raises(VariableWidthLookbehindNotSupportedError) as excinfo:
+ _variable_width_lookbehind_builder().to_regex(engine="re")
+ text = str(excinfo.value)
+ assert "assert_behind" in text
+ assert "engine='regex'" in text
+ assert "= note:" in text
+ assert "help:" in text
+
+
+def test_variable_width_lookbehind_under_regex_compiles_and_matches():
+ compiled = _variable_width_lookbehind_builder().to_regex(engine="regex")
+ assert compiled.engine == "regex"
+ assert compiled.search("foobar") is not None
+ assert compiled.search("foofoobar") is not None
+ assert compiled.search("foofoofoobar") is not None
+ assert compiled.search("bar") is None
+
+
+def test_fixed_width_lookbehind_still_works_under_re():
+ compiled = _fixed_width_lookbehind_builder().to_regex(engine="re")
+ assert compiled.search("foobar") is not None
+ assert compiled.search("bar") is None
+
+
+def test_variable_width_lookbehind_error_chains_the_underlying_pattern_error():
+ with pytest.raises(VariableWidthLookbehindNotSupportedError) as excinfo:
+ _variable_width_lookbehind_builder().to_regex(engine="re")
+ import re
+
+ assert isinstance(excinfo.value.__cause__, re.error)
+
+
+def test_re_engine_still_surfaces_other_pattern_errors_unchanged(monkeypatch):
+ import re
+
+ def raise_other_error(_pattern, flags=0):
+ raise re.error("some other syntax error")
+
+ monkeypatch.setattr(re, "compile", raise_other_error)
+ with pytest.raises(re.error) as excinfo:
+ RegexBuilder().digit().to_regex(engine="re")
+ assert "some other syntax error" in str(excinfo.value)
diff --git a/tests/builder/matcher.test.py b/tests/builder/matcher.test.py
index 05a763a..d32097b 100644
--- a/tests/builder/matcher.test.py
+++ b/tests/builder/matcher.test.py
@@ -22,10 +22,10 @@ def test_pattern_search_finds_a_substring_hit():
assert hit.group() == "world"
-def test_pattern_fullmatch_requires_the_entire_string():
+def test_pattern_fullmatch_via_to_regex_requires_the_entire_string():
zip_code = START + exactly(5, DIGIT) + END
- assert zip_code.fullmatch("10001") is not None
- assert zip_code.fullmatch("10001x") is None
+ assert zip_code.to_regex().fullmatch("10001") is not None
+ assert zip_code.to_regex().fullmatch("10001x") is None
def test_pattern_findall_returns_every_hit():
@@ -33,9 +33,9 @@ def test_pattern_findall_returns_every_hit():
assert lower.findall("hi there") == ["hi", "there"]
-def test_pattern_finditer_yields_match_objects():
+def test_pattern_finditer_via_to_regex_yields_match_objects():
lower = one_or_more(range_of("a", "z"))
- hits = list(lower.finditer("foo bar"))
+ hits = list(lower.to_regex().finditer("foo bar"))
assert [match.group() for match in hits] == ["foo", "bar"]
@@ -44,16 +44,16 @@ def test_pattern_sub_replaces_hits():
assert lower.sub("[X]", "hi and hello!") == "[X] [X] [X]!"
-def test_pattern_subn_returns_count_of_replacements():
+def test_pattern_subn_via_to_regex_returns_count_of_replacements():
lower = one_or_more(range_of("a", "z"))
- result, count = lower.subn("[X]", "hi hi hi")
+ result, count = lower.to_regex().subn("[X]", "hi hi hi")
assert result == "[X] [X] [X]"
assert count == 3
-def test_pattern_split_splits_on_matches():
+def test_pattern_split_via_to_regex_splits_on_matches():
lower = one_or_more(range_of("a", "z"))
- assert lower.split("one two three") == ["", " ", " ", ""]
+ assert lower.to_regex().split("one two three") == ["", " ", " ", ""]
def test_regex_builder_match_delegates_to_re_pattern_match():
diff --git a/tests/builder/reverse.test.py b/tests/builder/reverse.test.py
new file mode 100644
index 0000000..1ed00ec
--- /dev/null
+++ b/tests/builder/reverse.test.py
@@ -0,0 +1,113 @@
+"""Tests for :meth:`RegexBuilder.from_regex` — the reverse parser."""
+
+import re
+
+import pytest
+
+from edify import RegexBuilder
+from edify.builder.reverse import UnsupportedReverseParseError
+
+
+ ("source", "expected"),
+ [
+ ("hello", "hello"),
+ ("a", "a"),
+ (r"\d+", r"\d+"),
+ (r"\d*", r"\d*"),
+ (r"\d?", r"\d?"),
+ (r"\d{3}", r"\d{3}"),
+ (r"\d{2,5}", r"\d{2,5}"),
+ (r"\d{2,}", r"\d{2,}"),
+ (r"\w", r"\w"),
+ (r"\W", r"\W"),
+ (r"\s", r"\s"),
+ (r"\S", r"\S"),
+ (r"\D", r"\D"),
+ ("[a-z]", "[a-z]"),
+ ("[abc]", "[abc]"),
+ ("^", "^"),
+ ("$", "$"),
+ (r"\b", r"\b"),
+ (r"\B", r"\B"),
+ (".", "."),
+ ("(?:foo)", "foo"),
+ ("(abc)", "(abc)"),
+ ("(?P<name>abc)", "(?P<name>abc)"),
+ ("foo|bar", "(?:foo|bar)"),
+ ("(?=x)y", "(?=x)y"),
+ ("(?!x)y", "(?!x)y"),
+ ("(?<=x)y", "(?<=x)y"),
+ ("(?<!x)y", "(?<!x)y"),
+ ],
+)
+def test_from_regex_translates_common_constructs_faithfully(source, expected):
+ builder = RegexBuilder.from_regex(source)
+ assert builder.to_regex_string() == expected
+
+
+ "source",
+ [
+ "hello",
+ r"\d+",
+ "^abc$",
+ r"[a-z]+",
+ "(?P<x>abc)",
+ "foo|bar",
+ r"(?=x)\w+",
+ ],
+)
+def test_round_trip_compiled_regex_matches_the_same_inputs(source):
+ original = re.compile(source)
+ reversed_builder = RegexBuilder.from_regex(source)
+ reversed_compiled = reversed_builder.to_regex()
+ for candidate in ["", "a", "abc", "abc123", "xyz", "foo bar", "hi"]:
+ assert bool(original.search(candidate)) == bool(reversed_compiled.search(candidate))
+
+
+def test_from_regex_raises_for_unsupported_construct():
+ with pytest.raises(UnsupportedReverseParseError):
+ RegexBuilder.from_regex(r"(a)\1")
+
+
+def test_from_regex_raises_for_alternation_with_non_literal_branch():
+ with pytest.raises(UnsupportedReverseParseError):
+ RegexBuilder.from_regex(r"a|\d+")
+
+
+def test_from_regex_raises_for_unsupported_class_member_shape():
+ with pytest.raises(UnsupportedReverseParseError):
+ RegexBuilder.from_regex(r"[\da-z_]")
+
+
+def test_from_regex_lazy_quantifier_translates_via_lazy_variants():
+ builder = RegexBuilder.from_regex(r"a+?")
+ assert builder.to_regex_string() == "a+?"
+
+
+def test_from_regex_lazy_between_translates_via_lazy_variant():
+ builder = RegexBuilder.from_regex(r"a{2,5}?")
+ assert builder.to_regex_string() == "a{2,5}?"
+
+
+def test_from_regex_lazy_zero_or_more_translates_via_lazy_variant():
+ builder = RegexBuilder.from_regex(r"a*?")
+ assert builder.to_regex_string() == "a*?"
+
+
+def test_from_regex_named_group_reproduces_the_name():
+ builder = RegexBuilder.from_regex("(?P<username>[a-z]+)")
+ emitted = builder.to_regex_string()
+ assert emitted == "(?P<username>[a-z]+)"
+
+
+def test_unsupported_error_message_names_the_offending_construct():
+ with pytest.raises(UnsupportedReverseParseError) as excinfo:
+ RegexBuilder.from_regex(r"(a)\1")
+ assert "hand-write" in str(excinfo.value)
+
+
+def test_from_regex_produces_a_regexbuilder_not_a_pattern():
+ builder = RegexBuilder.from_regex("abc")
+ assert isinstance(builder, RegexBuilder)
diff --git a/tests/builder/testing.test.py b/tests/builder/testing.test.py
new file mode 100644
index 0000000..9a7928e
--- /dev/null
+++ b/tests/builder/testing.test.py
@@ -0,0 +1,71 @@
+"""Tests for the ``assert_matches`` / ``assert_rejects`` helpers."""
+
+import pytest
+
+from edify import Pattern, RegexBuilder
+from edify.errors.testing import PatternDidNotMatchInputsError, PatternMatchedRejectedInputsError
+
+
+def _digits_pattern():
+ return Pattern().one_or_more().digit()
+
+
+def test_assert_matches_returns_self_when_every_input_matches():
+ pattern = _digits_pattern()
+ result = pattern.assert_matches(["12", "3", "999"])
+ assert result is pattern
+
+
+def test_assert_matches_raises_when_at_least_one_input_does_not_match():
+ with pytest.raises(PatternDidNotMatchInputsError) as excinfo:
+ _digits_pattern().assert_matches(["12", "abc", "999"])
+ text = str(excinfo.value)
+ assert "'abc'" in text
+ assert "did not match" in text
+ assert "= note:" in text
+
+
+def test_assert_matches_reports_every_missing_input_at_once():
+ with pytest.raises(PatternDidNotMatchInputsError) as excinfo:
+ _digits_pattern().assert_matches(["abc", "xyz", "42"])
+ assert excinfo.value.missing_matches == ("abc", "xyz")
+
+
+def test_assert_rejects_returns_self_when_every_input_is_rejected():
+ pattern = _digits_pattern()
+ result = pattern.assert_rejects(["abc", "xyz"])
+ assert result is pattern
+
+
+def test_assert_rejects_raises_when_at_least_one_input_matches():
+ with pytest.raises(PatternMatchedRejectedInputsError) as excinfo:
+ _digits_pattern().assert_rejects(["abc", "42"])
+ text = str(excinfo.value)
+ assert "'42'" in text
+ assert "expected to be rejected" in text
+
+
+def test_assert_rejects_reports_every_unexpected_match_at_once():
+ with pytest.raises(PatternMatchedRejectedInputsError) as excinfo:
+ _digits_pattern().assert_rejects(["abc", "42", "999"])
+ assert excinfo.value.unexpected_matches == ("42", "999")
+
+
+def test_assert_matches_works_on_regex_builder_too():
+ builder = RegexBuilder().one_or_more().digit()
+ assert builder.assert_matches(["1", "22", "333"]) is builder
+
+
+def test_assert_rejects_works_on_regex_builder_too():
+ builder = RegexBuilder().one_or_more().digit()
+ assert builder.assert_rejects(["abc", ""]) is builder
+
+
+def test_assert_matches_accepts_any_iterable_not_only_lists():
+ pattern = _digits_pattern()
+ assert pattern.assert_matches(iter(["1", "22", "333"])) is pattern
+
+
+def test_assert_rejects_accepts_any_iterable_not_only_lists():
+ pattern = _digits_pattern()
+ assert pattern.assert_rejects(iter(["abc", ""])) is pattern
diff --git a/tests/builder/timeout.test.py b/tests/builder/timeout.test.py
new file mode 100644
index 0000000..e443d2b
--- /dev/null
+++ b/tests/builder/timeout.test.py
@@ -0,0 +1,85 @@
+"""Tests for the per-call ``timeout=`` kwarg on match methods."""
+
+import pytest
+
+from edify import RegexBuilder
+from edify.errors.backend import TimeoutNotSupportedByEngineError
+
+
+def _regex_pattern():
+ return RegexBuilder().string("a").to_regex(engine="regex")
+
+
+def _re_pattern():
+ return RegexBuilder().string("a").to_regex(engine="re")
+
+
+def test_timeout_flows_through_search_under_regex_engine():
+ assert _regex_pattern().search("aaa", timeout=1.0) is not None
+
+
+def test_timeout_flows_through_match_under_regex_engine():
+ assert _regex_pattern().match("aaa", timeout=1.0) is not None
+
+
+def test_timeout_flows_through_fullmatch_under_regex_engine():
+ assert _regex_pattern().fullmatch("a", timeout=1.0) is not None
+
+
+def test_timeout_flows_through_findall_under_regex_engine():
+ assert _regex_pattern().findall("aaa", timeout=1.0) == ["a", "a", "a"]
+
+
+def test_timeout_flows_through_finditer_under_regex_engine():
+ matches = list(_regex_pattern().finditer("aaa", timeout=1.0))
+ assert len(matches) == 3
+
+
+def test_timeout_flows_through_sub_under_regex_engine():
+ assert _regex_pattern().sub("b", "aaa", timeout=1.0) == "bbb"
+
+
+def test_timeout_flows_through_subn_under_regex_engine():
+ assert _regex_pattern().subn("b", "aaa", timeout=1.0) == ("bbb", 3)
+
+
+def test_timeout_flows_through_split_under_regex_engine():
+ assert _regex_pattern().split("a1a2a3", timeout=1.0) == ["", "1", "2", "3"]
+
+
+def test_timeout_under_re_engine_raises_annotated_error():
+ with pytest.raises(TimeoutNotSupportedByEngineError) as excinfo:
+ _re_pattern().search("aaa", timeout=1.0)
+ text = str(excinfo.value)
+ assert "timeout=" in text
+ assert "engine='regex'" in text
+ assert "= note:" in text
+
+
+def test_matcher_search_forwards_timeout_to_the_cached_regex():
+ builder = RegexBuilder().string("a")
+ with pytest.raises(TimeoutNotSupportedByEngineError):
+ builder.search("aaa", timeout=1.0)
+
+
+def test_matcher_match_forwards_timeout_to_the_cached_regex():
+ builder = RegexBuilder().string("a")
+ with pytest.raises(TimeoutNotSupportedByEngineError):
+ builder.match("aaa", timeout=1.0)
+
+
+def test_matcher_findall_forwards_timeout_to_the_cached_regex():
+ builder = RegexBuilder().string("a")
+ with pytest.raises(TimeoutNotSupportedByEngineError):
+ builder.findall("aaa", timeout=1.0)
+
+
+def test_matcher_sub_forwards_timeout_to_the_cached_regex():
+ builder = RegexBuilder().string("a")
+ with pytest.raises(TimeoutNotSupportedByEngineError):
+ builder.sub("b", "aaa", timeout=1.0)
+
+
+def test_no_timeout_kwarg_never_raises_on_either_engine():
+ assert _regex_pattern().search("aaa") is not None
+ assert _re_pattern().search("aaa") is not None