aboutsummaryrefslogtreecommitdiff
path: root/.github
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 /.github
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 '.github')
-rw-r--r--.github/workflows/github-actions.yml74
1 files changed, 73 insertions, 1 deletions
diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml
index 262826b..0e4cd98 100644
--- a/.github/workflows/github-actions.yml
+++ b/.github/workflows/github-actions.yml
@@ -57,4 +57,76 @@ jobs:
- name: install dependencies
run: uv sync --frozen --all-groups
- name: ${{ matrix.name }}
- run: ${{ matrix.command }} \ No newline at end of file
+ run: ${{ matrix.command }}
+
+ install-without-regex-extra:
+ name: 'install: edify (no [regex] extra)'
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+ - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39
+ with:
+ python-version: '3.11'
+ - name: install edify without any extra
+ run: |
+ uv venv .venv-bare
+ uv pip install --python .venv-bare/bin/python .
+ - name: import succeeds and re engine works; regex engine raises the friendly error
+ run: |
+ .venv-bare/bin/python <<'PYCHECK'
+ import edify
+ from edify.errors.backend import MissingRegexBackendError
+
+ compiled_re = edify.RegexBuilder().digit().to_regex(engine="re")
+ assert compiled_re.source == "\\d", compiled_re.source
+ assert compiled_re.engine == "re"
+
+ try:
+ edify.RegexBuilder().digit().to_regex(engine="regex")
+ except MissingRegexBackendError as reason:
+ text = str(reason)
+ assert "pip install edify[regex]" in text, text
+ else:
+ raise SystemExit("expected MissingRegexBackendError under bare install")
+ PYCHECK
+
+ install-with-regex-extra:
+ name: 'install: edify[regex]'
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+ - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39
+ with:
+ python-version: '3.11'
+ - name: install edify[regex]
+ run: |
+ uv venv .venv-extra
+ uv pip install --python .venv-extra/bin/python '.[regex]'
+ - name: both engines compile; variable-width lookbehind works only under regex
+ run: |
+ .venv-extra/bin/python <<'PYCHECK'
+ import edify
+ from edify.errors.backend import VariableWidthLookbehindNotSupportedError
+
+ for engine_choice in ("re", "regex"):
+ compiled = edify.RegexBuilder().digit().to_regex(engine=engine_choice)
+ assert compiled.source == "\\d", (engine_choice, compiled.source)
+ assert compiled.engine == engine_choice
+
+ variable_lookbehind_builder = (
+ edify.RegexBuilder()
+ .assert_behind().between(1, 3).string("foo").end()
+ .string("bar")
+ )
+ try:
+ variable_lookbehind_builder.to_regex(engine="re")
+ except VariableWidthLookbehindNotSupportedError:
+ pass
+ else:
+ raise SystemExit("expected VariableWidthLookbehindNotSupportedError under engine='re'")
+
+ via_regex = variable_lookbehind_builder.to_regex(engine="regex")
+ assert via_regex.search("foofoobar") is not None
+ PYCHECK