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/builder/reverse.test.py | |
| 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/builder/reverse.test.py')
| -rw-r--r-- | tests/builder/reverse.test.py | 113 |
1 files changed, 113 insertions, 0 deletions
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) |
