diff options
| author | Bobby <[email protected]> | 2026-07-15 14:45:56 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-07-15 14:45:56 +0530 |
| commit | 3dcca5d124fb0c5e92995bf5116f34c8794235d8 (patch) | |
| tree | d06261d4d0a7c87387615c59394fcdf71c6902e2 /tests | |
| parent | 496734c57c0a065119263c6fdca4598bb8e423f5 (diff) | |
| parent | 30c51f8ea0c29a1350da9f92fc6ccc4c6db06375 (diff) | |
| download | edify-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')
| -rw-r--r-- | tests/builder/cache.test.py | 63 | ||||
| -rw-r--r-- | tests/builder/engine.test.py | 42 | ||||
| -rw-r--r-- | tests/builder/flags.test.py | 45 | ||||
| -rw-r--r-- | tests/builder/lookbehind.test.py | 59 | ||||
| -rw-r--r-- | tests/builder/matcher.test.py | 18 | ||||
| -rw-r--r-- | tests/builder/reverse.test.py | 113 | ||||
| -rw-r--r-- | tests/builder/testing.test.py | 71 | ||||
| -rw-r--r-- | tests/builder/timeout.test.py | 85 | ||||
| -rw-r--r-- | tests/discipline/matcher.test.py | 42 | ||||
| -rw-r--r-- | tests/errors/context.test.py | 19 | ||||
| -rw-r--r-- | tests/errors/formatting.test.py | 16 | ||||
| -rw-r--r-- | tests/library/mac.test.py | 1 | ||||
| -rw-r--r-- | tests/library/ssn.test.py | 1 | ||||
| -rw-r--r-- | tests/pattern/call.test.py | 32 | ||||
| -rw-r--r-- | tests/result/match.test.py | 117 |
15 files changed, 703 insertions, 21 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 diff --git a/tests/discipline/matcher.test.py b/tests/discipline/matcher.test.py new file mode 100644 index 0000000..8697daf --- /dev/null +++ b/tests/discipline/matcher.test.py @@ -0,0 +1,42 @@ +"""Discipline test — the builder exposes exactly the five closed match verbs.""" + +import pytest + +from edify import Pattern, RegexBuilder + +_ALLOWED_MATCH_VERBS = frozenset({"test", "match", "search", "findall", "sub"}) +_FORBIDDEN_MATCH_VERBS = frozenset({"fullmatch", "finditer", "subn", "split"}) +_FORBIDDEN_RE_PATTERN_ATTRS = frozenset({"groups", "groupindex", "pattern", "flags"}) + + [email protected]("factory", [RegexBuilder, Pattern]) +def test_builder_exposes_every_allowed_match_verb(factory): + builder = factory() + for verb in _ALLOWED_MATCH_VERBS: + assert callable(getattr(builder, verb)), f"missing verb: {verb}" + + [email protected]("factory", [RegexBuilder, Pattern]) +def test_builder_does_not_expose_any_forbidden_match_verb(factory): + builder = factory() + for verb in _FORBIDDEN_MATCH_VERBS: + assert not hasattr(builder, verb), ( + f"builder must not expose re.Pattern verb {verb!r}; " + "use .to_regex() and call it on the Regex wrapper instead" + ) + + [email protected]("factory", [RegexBuilder, Pattern]) +def test_builder_does_not_expose_re_pattern_metadata_attributes(factory): + builder = factory() + for attribute in _FORBIDDEN_RE_PATTERN_ATTRS: + assert not hasattr(builder, attribute), ( + f"builder must not expose re.Pattern attribute {attribute!r}" + ) + + +def test_regex_wrapper_exposes_every_re_pattern_verb(): + regex = RegexBuilder().string("a").to_regex() + re_pattern_verbs = (_ALLOWED_MATCH_VERBS | _FORBIDDEN_MATCH_VERBS) - {"test"} + for verb in re_pattern_verbs: + assert callable(getattr(regex, verb)), f"Regex wrapper missing verb: {verb}" diff --git a/tests/errors/context.test.py b/tests/errors/context.test.py index 6595e92..60e7add 100644 --- a/tests/errors/context.test.py +++ b/tests/errors/context.test.py @@ -1,5 +1,6 @@ """Tests for the caller-source-location capture helpers in :mod:`edify.errors.context`.""" +import linecache from unittest import mock from edify.errors.context import CallerContext, capture_caller_context @@ -10,7 +11,21 @@ def test_capture_caller_context_returns_none_when_every_frame_is_inside_edify(): assert capture_caller_context() is None -def test_caller_context_dataclass_is_frozen_and_holds_all_five_fields(): - context = CallerContext(filename="a.py", lineno=1, colno=1, end_colno=5, source_line="x = 1") +def test_caller_context_dataclass_is_frozen_and_holds_position_fields(): + context = CallerContext(filename="a.py", lineno=1, colno=1, end_colno=5) assert context.filename == "a.py" + assert context.lineno == 1 + assert context.colno == 1 + assert context.end_colno == 5 + + +def test_caller_context_source_line_property_reads_lazily_via_linecache(): + filename = "/tmp/lazy_source_test.py" + linecache.cache[filename] = (5, None, ["x = 1\n"], filename) + context = CallerContext(filename=filename, lineno=1, colno=1, end_colno=5) assert context.source_line == "x = 1" + + +def test_caller_context_source_line_returns_empty_string_when_line_is_unreadable(): + context = CallerContext(filename="/tmp/no_such_file.py", lineno=999, colno=1, end_colno=1) + assert context.source_line == "" diff --git a/tests/errors/formatting.test.py b/tests/errors/formatting.test.py index 314aff1..2455ecf 100644 --- a/tests/errors/formatting.test.py +++ b/tests/errors/formatting.test.py @@ -1,5 +1,6 @@ """Tests for the message formatter helpers in :mod:`edify.errors.formatting`.""" +import linecache from unittest import mock from edify.errors.context import CallerContext @@ -17,13 +18,19 @@ from edify.errors.formatting import ( ) +def _inject_source(filename: str, lineno: int, source_line: str) -> None: + lines = [""] * (lineno - 1) + [source_line + "\n"] + linecache.cache[filename] = (len(source_line) + 1, None, lines, filename) + + def _context(source_line: str = " x = do_the_thing(1, 2, 3)", colno: int = 9) -> CallerContext: + filename = f"/tmp/user_code_{colno}_{hash(source_line) & 0xFFFF}.py" + _inject_source(filename, 42, source_line) return CallerContext( - filename="/tmp/user_code.py", + filename=filename, lineno=42, colno=colno, end_colno=colno + 15, - source_line=source_line, ) @@ -45,17 +52,18 @@ def test_format_pointer_block_draws_caret_at_caller_column(): context = _context() block = format_pointer_block(context, "here") lines = block.splitlines() - assert lines[0] == " --> /tmp/user_code.py:42:9" + assert lines[0].startswith(" -->") + assert lines[0].endswith(":42:9") assert "^^^^^^^^^^^^^^^ here" in block def test_format_pointer_block_has_at_least_one_caret_when_span_is_empty(): + _inject_source("/tmp/x.py", 1, "abcde") context = CallerContext( filename="/tmp/x.py", lineno=1, colno=5, end_colno=5, - source_line="abcde", ) block = format_pointer_block(context, "point") assert "^ point" in block diff --git a/tests/library/mac.test.py b/tests/library/mac.test.py index 2f24b44..01ac38f 100644 --- a/tests/library/mac.test.py +++ b/tests/library/mac.test.py @@ -5,7 +5,6 @@ def test_mac(): macs = { "00:00:5e:00:53:af": True, "00:00:5e:00:53:af:": False, - 123: False, } for m_a_c, expected in macs.items(): diff --git a/tests/library/ssn.test.py b/tests/library/ssn.test.py index ce2a57b..a93f0c6 100644 --- a/tests/library/ssn.test.py +++ b/tests/library/ssn.test.py @@ -6,7 +6,6 @@ def test_ssn(): "000-22-3333": False, "100-22-3333": True, "": False, - 123: False, } for s_s_n, expected in ssns.items(): assert ssn(s_s_n) == expected diff --git a/tests/pattern/call.test.py b/tests/pattern/call.test.py new file mode 100644 index 0000000..ff26476 --- /dev/null +++ b/tests/pattern/call.test.py @@ -0,0 +1,32 @@ +"""Tests for :meth:`Pattern.__call__` — validators-as-callables.""" + +from edify import Pattern + + +def test_call_returns_true_when_pattern_matches_anywhere_in_string(): + contains_digit = Pattern().digit() + assert contains_digit("hello 42 world") is True + + +def test_call_returns_false_when_pattern_never_matches(): + contains_digit = Pattern().digit() + assert contains_digit("hello world") is False + + +def test_call_delegates_to_test_with_search_semantics(): + only_letters = Pattern().start_of_input().one_or_more().letter().end_of_input() + assert only_letters("abc") is True + assert only_letters("abc123") is False + + +def test_call_result_is_a_bool_not_a_match_object(): + contains_digit = Pattern().digit() + assert isinstance(contains_digit("42"), bool) + + +def test_call_reuses_the_cached_regex_across_repeat_calls(): + pattern = Pattern().string("hi") + first_compiled = pattern.to_regex() + pattern("hi world") + pattern("bye world") + assert pattern.to_regex() is first_compiled diff --git a/tests/result/match.test.py b/tests/result/match.test.py new file mode 100644 index 0000000..b7f7514 --- /dev/null +++ b/tests/result/match.test.py @@ -0,0 +1,117 @@ +"""Tests for the :class:`Match` wrapper — attribute-access for named captures.""" + +import pytest + +from edify import Pattern, RegexBuilder +from edify.result import Match, NamedCaptures + + +def _username_pattern(): + return RegexBuilder().string("@").named_capture("username").one_or_more().letter().end() + + +def test_search_returns_a_wrapped_match(): + result = _username_pattern().search("hi @alice") + assert isinstance(result, Match) + + +def test_search_returns_none_when_no_match(): + result = RegexBuilder().string("zzz").search("hi @alice") + assert result is None + + +def test_match_attribute_access_returns_named_group_substring(): + result = _username_pattern().search("hi @alice") + assert result is not None + assert result.username == "alice" + + +def test_captures_namespace_returns_named_group_substring(): + result = _username_pattern().search("hi @bob") + assert result is not None + assert result.captures.username == "bob" + + +def test_captures_namespace_dir_lists_every_declared_group(): + result = _username_pattern().search("hi @carol") + assert result is not None + assert dir(result.captures) == ["username"] + + +def test_captures_namespace_attribute_error_names_valid_groups(): + result = _username_pattern().search("hi @dave") + assert result is not None + with pytest.raises(AttributeError, match="does not exist"): + _ = result.captures.nonexistent + + +def test_match_delegates_re_match_methods_via_getattr(): + result = _username_pattern().search("hi @erin") + assert result is not None + assert result.group() == "@erin" + assert result.group("username") == "erin" + assert result.groupdict() == {"username": "erin"} + assert result.span("username") == (4, 8) + + +def test_match_wrapped_property_returns_the_underlying_re_match(): + result = _username_pattern().search("hi @frank") + assert result is not None + wrapped = result.wrapped + assert wrapped.group("username") == "frank" + + +def test_match_repr_contains_span_and_text(): + result = _username_pattern().search("hi @greta") + assert result is not None + assert "greta" in repr(result) + + +def test_finditer_yields_wrapped_matches(): + pattern = _username_pattern().to_regex() + hits = list(pattern.finditer("hi @heidi and @ivan")) + assert all(isinstance(item, Match) for item in hits) + assert [item.captures.username for item in hits] == ["heidi", "ivan"] + + +def test_sub_callable_receives_wrapped_match(): + pattern = _username_pattern().to_regex() + result = pattern.sub(lambda m: m.captures.username.upper(), "hi @jane") + assert result == "hi JANE" + + +def test_subn_callable_receives_wrapped_match(): + pattern = _username_pattern().to_regex() + result, count = pattern.subn(lambda m: m.captures.username.upper(), "hi @jane and @jim") + assert result == "hi JANE and JIM" + assert count == 2 + + +def test_sub_still_accepts_a_plain_string_replacement(): + pattern = _username_pattern().to_regex() + assert pattern.sub("[X]", "hi @jane") == "hi [X]" + + +def test_named_captures_instance_is_a_namespace(): + pattern = _username_pattern().to_regex() + result = pattern.search("hi @karen") + assert result is not None + assert isinstance(result.captures, NamedCaptures) + + +def test_pattern_match_via_pattern_class_returns_wrapped_match(): + pattern = Pattern().named_capture("word").one_or_more().letter().end() + assert pattern.match("hello") is not None + assert pattern.match("hello").captures.word == "hello" + + +def test_fullmatch_returns_wrapped_match_when_full_input_matches(): + pattern = _username_pattern().to_regex() + result = pattern.fullmatch("@leo") + assert result is not None + assert result.captures.username == "leo" + + +def test_fullmatch_returns_none_when_input_is_only_partial(): + pattern = _username_pattern().to_regex() + assert pattern.fullmatch("hi @leo") is None |
