aboutsummaryrefslogtreecommitdiff
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
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.
-rw-r--r--.github/workflows/github-actions.yml74
-rw-r--r--README.rst1
-rw-r--r--docs/regex-builder/builder/index.rst27
-rw-r--r--edify/builder/builder.py26
-rw-r--r--edify/builder/core.py14
-rw-r--r--edify/builder/diagnose.py1
-rw-r--r--edify/builder/mixins/matcher.py87
-rw-r--r--edify/builder/mixins/terminals.py60
-rw-r--r--edify/builder/mixins/testing.py39
-rw-r--r--edify/builder/reverse.py228
-rw-r--r--edify/compile/backend.py91
-rw-r--r--edify/errors/backend.py63
-rw-r--r--edify/errors/context.py46
-rw-r--r--edify/errors/engine.py31
-rw-r--r--edify/errors/testing.py55
-rw-r--r--edify/pattern/composition.py16
-rw-r--r--edify/result/__init__.py3
-rw-r--r--edify/result/match.py59
-rw-r--r--edify/result/regex.py180
-rw-r--r--pyproject.toml6
-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
-rw-r--r--tests/discipline/matcher.test.py42
-rw-r--r--tests/errors/context.test.py19
-rw-r--r--tests/errors/formatting.test.py16
-rw-r--r--tests/library/mac.test.py1
-rw-r--r--tests/library/ssn.test.py1
-rw-r--r--tests/pattern/call.test.py32
-rw-r--r--tests/result/match.test.py117
-rw-r--r--uv.lock116
36 files changed, 1731 insertions, 216 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
diff --git a/README.rst b/README.rst
index e38d341..80e3288 100644
--- a/README.rst
+++ b/README.rst
@@ -246,6 +246,7 @@ That's where Edify becomes extremely useful. It allows you to create regular exp
- Order matters! Quantifiers are specified before the thing they change, just like in English (e.g. ``RegexBuilder().exactly(5).digit()``).
- If you make a mistake, you'll know how to fix it. Edify will guide you towards a fix if your expression is invalid.
- ``subexpressions`` can be used to create meaningful, reusable components.
+- Every chain method returns a new builder — the receiver is never mutated. Two extensions of the same base pattern are always independent, so reusable base builders are safe to share across modules, closures, and threads.
Edify turns those complex and unwieldy regexes that appear in code reviews into something that can be read, understood, and **properly reviewed** by your peers - and maintained by anyone!
diff --git a/docs/regex-builder/builder/index.rst b/docs/regex-builder/builder/index.rst
index 4b1702f..d7ca44b 100644
--- a/docs/regex-builder/builder/index.rst
+++ b/docs/regex-builder/builder/index.rst
@@ -8,6 +8,33 @@ RegexBuilder is a class that helps you build regular expressions. It is based on
- If you make a mistake, you'll know how to fix it. Edify will guide you towards a fix if your expression is invalid.
- ``subexpressions`` can be used to create meaningful, reusable components.
+Immutability contract
+---------------------
+
+Every chain method on ``RegexBuilder`` returns a **new** builder; the receiver is never mutated. Two consequences follow, and both are guaranteed by contract:
+
+1. **Branching is safe.** A base builder can be extended along multiple independent paths without any of them observing another's state:
+
+ .. code-block:: python
+
+ from edify import RegexBuilder
+
+ base = RegexBuilder().start_of_input().exactly(3).digit()
+ zip5 = base.exactly(2).digit().end_of_input()
+ zip3 = base.end_of_input()
+
+ assert zip5.to_regex_string() == "^\\d{3}\\d{2}$"
+ assert zip3.to_regex_string() == "^\\d{3}$"
+ assert base.to_regex_string() == "^\\d{3}"
+
+ ``zip5`` and ``zip3`` are independent chains rooted in the same ``base``; extending one has no effect on the other or on ``base`` itself.
+
+2. **Reusable base patterns are safe to share.** A base pattern held in a module-level constant, imported across files, or captured in a closure cannot be corrupted by any caller. The same object can be extended, forked, or handed to ``subexpression`` any number of times.
+
+The same guarantee holds for :class:`edify.Pattern` — it is a ``RegexBuilder`` under the hood and inherits the contract.
+
+The lazy compile cache preserves the guarantee: repeated no-kwargs ``.to_regex()`` calls on the same builder instance return the same :class:`Regex` object (``builder.to_regex() is builder.to_regex()``), but a chain step or ``fork()`` produces a fresh builder with its own empty cache.
+
.any_char()
-----------
diff --git a/edify/builder/builder.py b/edify/builder/builder.py
index 0e775f1..cde72d9 100644
--- a/edify/builder/builder.py
+++ b/edify/builder/builder.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import edify.builder.reverse as reverse_module
from edify.builder.core import BuilderCore
from edify.builder.mixins.anchors import AnchorsMixin
from edify.builder.mixins.assertions import AssertionsMixin
@@ -16,6 +17,7 @@ from edify.builder.mixins.operators import OperatorsMixin
from edify.builder.mixins.quantifiers import QuantifiersMixin
from edify.builder.mixins.subexpression import SubexpressionMixin
from edify.builder.mixins.terminals import TerminalsMixin
+from edify.builder.mixins.testing import TestingMixin
class RegexBuilder(
@@ -33,5 +35,27 @@ class RegexBuilder(
QuantifiersMixin,
SubexpressionMixin,
TerminalsMixin,
+ TestingMixin,
):
- """A fluent, immutable, strongly-typed regex builder."""
+ """A fluent, immutable, strongly-typed regex builder.
+
+ Immutability is a hard contract: every chain method returns a *new* builder
+ and leaves the receiver untouched. A base builder held in a constant, closure,
+ or attribute can be extended along multiple independent paths without any of
+ them observing another's state. Repeat no-kwargs ``.to_regex()`` calls on the
+ same builder return the same cached :class:`Regex`; a chain step or ``fork()``
+ yields a fresh builder with its own empty cache.
+ """
+
+ @classmethod
+ def from_regex(cls, pattern_text: str) -> RegexBuilder:
+ """Return a builder chain whose emitted pattern is equivalent to ``pattern_text``.
+
+ Args:
+ pattern_text: Raw regex source, exactly what would be handed to :func:`re.compile`.
+
+ Raises:
+ edify.builder.reverse.UnsupportedReverseParseError: when the source uses a
+ construct the reverse parser cannot translate yet.
+ """
+ return reverse_module.build_from_regex(pattern_text)
diff --git a/edify/builder/core.py b/edify/builder/core.py
index fca2a76..056e1b1 100644
--- a/edify/builder/core.py
+++ b/edify/builder/core.py
@@ -22,12 +22,9 @@ _UNCLOSED_FRAME_MARKER = "<unclosed>"
class BuilderCore(BuilderProtocol):
"""Holds the immutable :class:`BuilderState` and clones it on chain steps."""
- _state: BuilderState
- _cached_regex: Regex | None
-
def __init__(self) -> None:
- self._state = BuilderState()
- self._cached_regex = None
+ self._state: BuilderState = BuilderState()
+ self._cached_regex: Regex | None = None
def _with_state(self, new_state: BuilderState) -> Self:
"""Return a fresh instance of the same concrete type carrying ``new_state``."""
@@ -39,12 +36,7 @@ class BuilderCore(BuilderProtocol):
def _lazy_regex(self) -> Regex:
"""Return the memoised :class:`Regex` for this builder, compiling once on first call."""
- cached = self._cached_regex
- if cached is None:
- to_regex = self.to_regex
- cached = to_regex()
- self._cached_regex = cached
- return cached
+ return self.to_regex()
def fork(self) -> Self:
"""Return a fresh builder with the same immutable state."""
diff --git a/edify/builder/diagnose.py b/edify/builder/diagnose.py
index 4802145..c96f8e4 100644
--- a/edify/builder/diagnose.py
+++ b/edify/builder/diagnose.py
@@ -23,7 +23,6 @@ _UNKNOWN_CALL_SITE = CallerContext(
lineno=0,
colno=1,
end_colno=1,
- source_line="",
)
diff --git a/edify/builder/mixins/matcher.py b/edify/builder/mixins/matcher.py
index 3ed74fa..6f35f24 100644
--- a/edify/builder/mixins/matcher.py
+++ b/edify/builder/mixins/matcher.py
@@ -1,76 +1,73 @@
-"""The :class:`MatcherMixin` — direct :mod:`re` method proxies on any fluent surface.
+"""The :class:`MatcherMixin` — the closed set of five match verbs on any fluent surface.
Compiles ``self`` on first use via :meth:`edify.builder.core.BuilderCore._lazy_regex`
and reuses the cached :class:`edify.result.regex.Regex` on every subsequent call,
so ``pattern.test("a")`` followed by ``pattern.match("b")`` compiles the underlying
regex exactly once.
-The signatures mirror :class:`re.Pattern` exactly so IDE autocomplete and
-type inference stay unchanged. :meth:`test` is the one non-:mod:`re` method:
-a boolean shortcut that returns ``True`` when the pattern matches anywhere
-in the input, ``False`` otherwise.
+The surface is intentionally limited to ``test``, ``match``, ``search``, ``findall``,
+``sub`` — the small verb set that keeps chain autocomplete uncluttered. Reach through
+:meth:`to_regex` and use :class:`edify.result.regex.Regex` for ``fullmatch``,
+``finditer``, ``subn``, or ``split``. Every match method also accepts a per-call
+``timeout=`` kwarg that only applies under ``engine="regex"``.
"""
from __future__ import annotations
-import re
import sys
-from collections.abc import Callable, Iterator
+from collections.abc import Callable
from edify.builder.types.protocol import BuilderProtocol
+from edify.result.match import Match
class MatcherMixin(BuilderProtocol):
- """Provides nine :class:`re.Pattern` proxy methods plus :meth:`test` on any fluent surface."""
+ """Provides the five-verb match surface (``test``/``match``/``search``/``findall``/``sub``)."""
def test(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> bool:
"""Return ``True`` when the pattern matches anywhere in ``string``, else ``False``."""
return self._lazy_regex().search(string, pos, endpos) is not None
- def match(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None:
- """Delegate to :meth:`re.Pattern.match`."""
- return self._lazy_regex().match(string, pos, endpos)
-
- def search(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None:
- """Delegate to :meth:`re.Pattern.search`."""
- return self._lazy_regex().search(string, pos, endpos)
-
- def fullmatch(
- self, string: str, pos: int = 0, endpos: int = sys.maxsize
- ) -> re.Match[str] | None:
- """Delegate to :meth:`re.Pattern.fullmatch`."""
- return self._lazy_regex().fullmatch(string, pos, endpos)
+ def match(
+ self,
+ string: str,
+ pos: int = 0,
+ endpos: int = sys.maxsize,
+ *,
+ timeout: float | None = None,
+ ) -> Match | None:
+ """Delegate to :meth:`re.Pattern.match`, returning an edify :class:`Match`."""
+ return self._lazy_regex().match(string, pos, endpos, timeout=timeout)
+
+ def search(
+ self,
+ string: str,
+ pos: int = 0,
+ endpos: int = sys.maxsize,
+ *,
+ timeout: float | None = None,
+ ) -> Match | None:
+ """Delegate to :meth:`re.Pattern.search`, returning an edify :class:`Match`."""
+ return self._lazy_regex().search(string, pos, endpos, timeout=timeout)
def findall(
- self, string: str, pos: int = 0, endpos: int = sys.maxsize
+ self,
+ string: str,
+ pos: int = 0,
+ endpos: int = sys.maxsize,
+ *,
+ timeout: float | None = None,
) -> list[str] | list[tuple[str, ...]]:
"""Delegate to :meth:`re.Pattern.findall`."""
- return self._lazy_regex().findall(string, pos, endpos)
-
- def finditer(
- self, string: str, pos: int = 0, endpos: int = sys.maxsize
- ) -> Iterator[re.Match[str]]:
- """Delegate to :meth:`re.Pattern.finditer`."""
- return self._lazy_regex().finditer(string, pos, endpos)
+ return self._lazy_regex().findall(string, pos, endpos, timeout=timeout)
def sub(
self,
- replacement: str | Callable[[re.Match[str]], str],
+ replacement: str | Callable[[Match], str],
string: str,
count: int = 0,
+ *,
+ timeout: float | None = None,
) -> str:
- """Delegate to :meth:`re.Pattern.sub`."""
- return self._lazy_regex().sub(replacement, string, count=count)
-
- def subn(
- self,
- replacement: str | Callable[[re.Match[str]], str],
- string: str,
- count: int = 0,
- ) -> tuple[str, int]:
- """Delegate to :meth:`re.Pattern.subn`."""
- return self._lazy_regex().subn(replacement, string, count=count)
-
- def split(self, string: str, maxsplit: int = 0) -> list[str | None]:
- """Delegate to :meth:`re.Pattern.split`."""
- return self._lazy_regex().split(string, maxsplit=maxsplit)
+ """Delegate to :meth:`re.Pattern.sub`; callables receive an edify :class:`Match`."""
+ return self._lazy_regex().sub(replacement, string, count=count, timeout=timeout)
diff --git a/edify/builder/mixins/terminals.py b/edify/builder/mixins/terminals.py
index 25d8191..b25873b 100644
--- a/edify/builder/mixins/terminals.py
+++ b/edify/builder/mixins/terminals.py
@@ -4,19 +4,18 @@ Both methods require the builder to be fully specified: every opened frame
must have been closed with ``.end()`` so only the root frame remains. The
string terminal returns exactly the pattern you'd hand to ``re.compile``;
the compiled terminal performs that compilation with the current flag set
-and returns the emitted string wrapped in an :class:`edify.result.Regex`.
+via the selected engine backend, returning the emitted string wrapped in
+an :class:`edify.result.Regex`.
"""
from __future__ import annotations
-import re
-
from edify.builder.types.engine import Engine
from edify.builder.types.flags import Flags
from edify.builder.types.protocol import BuilderProtocol
+from edify.compile.backend import compile_pattern
from edify.compile.dispatch import render_element
from edify.elements.types.root import RootElement
-from edify.errors.engine import EngineNotWiredError
from edify.errors.quantifier import DanglingQuantifierError
from edify.errors.structure import CannotCallSubexpressionError
from edify.result import Regex
@@ -29,6 +28,8 @@ _RAW_SPACE = " "
class TerminalsMixin(BuilderProtocol):
"""Provides the two pattern-emitting terminal methods on the builder."""
+ _cached_regex: Regex | None
+
def to_regex_string(self) -> str:
"""Return the bare regex string the builder describes.
@@ -58,21 +59,25 @@ class TerminalsMixin(BuilderProtocol):
"""Return the pattern + flags compiled and wrapped in :class:`edify.result.Regex`.
The wrapper exposes the pattern string as ``.source`` and the underlying
- :class:`re.Pattern` as ``.compiled``, plus the eight :mod:`re` query
- methods as direct delegates.
+ compiled pattern as ``.compiled``, plus the eight :mod:`re` query methods
+ as direct delegates against the selected engine backend.
Keyword arguments are OR-merged into the flag snapshot the builder
already carries — passing ``ignore_case=True`` here is equivalent to
having called ``.ignore_case()`` in the chain. Flags never turn off,
only on.
- The ``engine`` kwarg selects the compilation backend. Only ``"re"`` is
- wired today; ``"regex"`` is reserved for the opt-in third-party engine
- and raises :class:`NotImplementedError` until that dispatch lands.
+ The ``engine`` kwarg selects the compilation backend. ``"re"`` (default)
+ uses the stdlib :mod:`re` module. ``"regex"`` uses the third-party
+ ``regex`` module, which unlocks constructs the stdlib does not accept
+ (variable-width lookbehind, per-call timeouts). ``"regex"`` requires
+ ``pip install edify[regex]``; a clean :class:`MissingRegexBackendError`
+ surfaces when the extra is not installed.
+
+ Default (no-kwargs) calls hit a per-instance lazy cache: the second and
+ subsequent no-kwargs calls return the same :class:`Regex` the first call
+ produced. Passing any kwarg bypasses the cache and always compiles fresh.
"""
- pattern_string = self.to_regex_string()
- if engine != "re":
- raise EngineNotWiredError(engine)
kwarg_flags = Flags(
ascii_only=ascii_only,
debug=debug,
@@ -81,14 +86,21 @@ class TerminalsMixin(BuilderProtocol):
dotall=dotall,
verbose=verbose,
)
+ can_cache = engine == "re" and kwarg_flags == Flags()
+ if can_cache and self._cached_regex is not None:
+ return self._cached_regex
+ pattern_string = self.to_regex_string()
effective_flags = self._state.flags.with_merged(kwarg_flags)
- flag_bitmask = _build_flag_bitmask(effective_flags)
- compiled_pattern = re.compile(pattern_string, flags=flag_bitmask)
- return Regex(
+ compiled_pattern = compile_pattern(pattern_string, engine, effective_flags)
+ wrapped = Regex(
source=pattern_string,
compiled=compiled_pattern,
elements=tuple(self._state.top_frame.children),
+ engine=engine,
)
+ if can_cache:
+ self._cached_regex = wrapped
+ return wrapped
def _ensure_fully_specified(builder: BuilderProtocol) -> None:
@@ -104,21 +116,3 @@ def _ensure_no_dangling_quantifier(builder: BuilderProtocol) -> None:
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
- if flags.ascii_only:
- bitmask = bitmask | re.A
- if flags.debug:
- bitmask = bitmask | re.DEBUG
- if flags.ignore_case:
- bitmask = bitmask | re.I
- if flags.multiline:
- bitmask = bitmask | re.M
- if flags.dotall:
- bitmask = bitmask | re.S
- if flags.verbose:
- bitmask = bitmask | re.X
- return bitmask
diff --git a/edify/builder/mixins/testing.py b/edify/builder/mixins/testing.py
new file mode 100644
index 0000000..904721f
--- /dev/null
+++ b/edify/builder/mixins/testing.py
@@ -0,0 +1,39 @@
+"""The :class:`TestingMixin` — first-class ``assert_matches`` / ``assert_rejects`` helpers.
+
+Both methods accept an iterable of strings and raise if any input fails the
+declared expectation. The raised assertion class inherits :class:`AssertionError`
+so pytest introspects it exactly like a bare ``assert``.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from typing import Self
+
+from edify.builder.types.protocol import BuilderProtocol
+from edify.errors.testing import (
+ PatternDidNotMatchInputsError,
+ PatternMatchedRejectedInputsError,
+)
+
+
+class TestingMixin(BuilderProtocol):
+ """Provides ``assert_matches`` and ``assert_rejects`` on any fluent surface."""
+
+ def assert_matches(self, inputs: Iterable[str]) -> Self:
+ """Assert every string in ``inputs`` matches this pattern; return ``self``."""
+ compiled = self._lazy_regex()
+ input_tuple = tuple(inputs)
+ missing = tuple(item for item in input_tuple if compiled.search(item) is None)
+ if missing:
+ raise PatternDidNotMatchInputsError(compiled.source, missing)
+ return self
+
+ def assert_rejects(self, inputs: Iterable[str]) -> Self:
+ """Assert every string in ``inputs`` is rejected by this pattern; return ``self``."""
+ compiled = self._lazy_regex()
+ input_tuple = tuple(inputs)
+ matched = tuple(item for item in input_tuple if compiled.search(item) is not None)
+ if matched:
+ raise PatternMatchedRejectedInputsError(compiled.source, matched)
+ return self
diff --git a/edify/builder/reverse.py b/edify/builder/reverse.py
new file mode 100644
index 0000000..d7526c7
--- /dev/null
+++ b/edify/builder/reverse.py
@@ -0,0 +1,228 @@
+"""Reverse parser: translate a raw regex string into an equivalent builder chain."""
+
+from __future__ import annotations
+
+import re
+import re._constants as sre_constants
+import re._parser as sre_parser
+from typing import Any, cast
+
+import edify.builder.builder as builder_module
+
+_ANCHOR_METHOD_BY_CONSTANT = {
+ sre_constants.AT_BEGINNING: "start_of_input",
+ sre_constants.AT_BEGINNING_STRING: "start_of_input",
+ sre_constants.AT_END: "end_of_input",
+ sre_constants.AT_END_STRING: "end_of_input",
+ sre_constants.AT_BOUNDARY: "word_boundary",
+ sre_constants.AT_NON_BOUNDARY: "non_word_boundary",
+}
+
+_CATEGORY_METHOD_BY_CONSTANT = {
+ sre_constants.CATEGORY_DIGIT: "digit",
+ sre_constants.CATEGORY_NOT_DIGIT: "non_digit",
+ sre_constants.CATEGORY_WORD: "word",
+ sre_constants.CATEGORY_NOT_WORD: "non_word",
+ sre_constants.CATEGORY_SPACE: "whitespace_char",
+ sre_constants.CATEGORY_NOT_SPACE: "non_whitespace_char",
+}
+
+
+class UnsupportedReverseParseError(ValueError):
+ """Raised when ``from_regex`` encounters a construct the reverse parser cannot translate."""
+
+ def __init__(self, construct: str) -> None:
+ message = (
+ f"from_regex cannot translate the regex construct {construct!r} yet; "
+ "hand-write the equivalent chain and file an issue with the source pattern."
+ )
+ super().__init__(message)
+
+
+def build_from_regex(pattern_text: str) -> builder_module.RegexBuilder:
+ """Return a :class:`RegexBuilder` whose emitted pattern is equivalent to ``pattern_text``."""
+ parsed_tree: Any = sre_parser.parse(pattern_text)
+ name_by_number = _name_by_group_number(pattern_text)
+ node_list: list[Any] = list(parsed_tree)
+ empty_builder: builder_module.RegexBuilder = builder_module.RegexBuilder()
+ return _translate_sequence(empty_builder, node_list, name_by_number)
+
+
+def _name_by_group_number(pattern_text: str) -> dict[int, str]:
+ compiled_pattern = re.compile(pattern_text)
+ return {number: name for name, number in compiled_pattern.groupindex.items()}
+
+
+def _translate_sequence(
+ builder: builder_module.RegexBuilder,
+ nodes: list[Any],
+ names: dict[int, str],
+) -> builder_module.RegexBuilder:
+ current = builder
+ literal_run: list[str] = []
+ for node in nodes:
+ opcode, argument = node
+ if opcode == sre_constants.LITERAL:
+ literal_run.append(chr(argument))
+ continue
+ if literal_run:
+ current = _emit_string(current, literal_run)
+ literal_run = []
+ current = _translate_node(current, opcode, argument, names)
+ if literal_run:
+ current = _emit_string(current, literal_run)
+ return current
+
+
+def _emit_string(
+ builder: builder_module.RegexBuilder, literal_run: list[str]
+) -> builder_module.RegexBuilder:
+ joined = "".join(literal_run)
+ if len(joined) == 1:
+ return builder.char(joined)
+ return builder.string(joined)
+
+
+def _translate_node(
+ builder: builder_module.RegexBuilder,
+ opcode: Any,
+ argument: Any,
+ names: dict[int, str],
+) -> builder_module.RegexBuilder:
+ if opcode == sre_constants.ANY:
+ return builder.any_char()
+ if opcode == sre_constants.AT:
+ method_name = _ANCHOR_METHOD_BY_CONSTANT[argument]
+ anchor_method = getattr(builder, method_name)
+ return cast(builder_module.RegexBuilder, anchor_method())
+ if opcode == sre_constants.IN:
+ return _translate_character_class(builder, list(argument))
+ if opcode == sre_constants.MAX_REPEAT:
+ return _translate_repeat(builder, argument, names, lazy=False)
+ if opcode == sre_constants.MIN_REPEAT:
+ return _translate_repeat(builder, argument, names, lazy=True)
+ if opcode == sre_constants.SUBPATTERN:
+ return _translate_subpattern(builder, argument, names)
+ if opcode == sre_constants.BRANCH:
+ return _translate_branch(builder, argument)
+ if opcode == sre_constants.ASSERT:
+ return _translate_lookaround(builder, argument, names, negative=False)
+ if opcode == sre_constants.ASSERT_NOT:
+ return _translate_lookaround(builder, argument, names, negative=True)
+ raise UnsupportedReverseParseError(str(opcode))
+
+
+def _translate_character_class(
+ builder: builder_module.RegexBuilder, members: list[Any]
+) -> builder_module.RegexBuilder:
+ if len(members) == 1:
+ return _translate_single_class_member(builder, members[0])
+ literal_chars: list[str] = []
+ for member in members:
+ opcode, argument = member
+ if opcode == sre_constants.LITERAL:
+ literal_chars.append(chr(argument))
+ continue
+ raise UnsupportedReverseParseError(f"character-class member {member!r}")
+ joined_chars = "".join(literal_chars)
+ return builder.any_of_chars(joined_chars)
+
+
+def _translate_single_class_member(
+ builder: builder_module.RegexBuilder, member: tuple[Any, Any]
+) -> builder_module.RegexBuilder:
+ opcode, argument = member
+ if opcode == sre_constants.RANGE:
+ start_codepoint, end_codepoint = argument
+ return builder.range(chr(start_codepoint), chr(end_codepoint))
+ method_name = _CATEGORY_METHOD_BY_CONSTANT[argument]
+ category_method = getattr(builder, method_name)
+ return cast(builder_module.RegexBuilder, category_method())
+
+
+def _translate_repeat(
+ builder: builder_module.RegexBuilder,
+ argument: Any,
+ names: dict[int, str],
+ lazy: bool,
+) -> builder_module.RegexBuilder:
+ min_count, max_count, body = argument
+ quantified = _apply_quantifier(builder, min_count, max_count, lazy)
+ body_nodes = list(body)
+ return _translate_sequence(quantified, body_nodes, names)
+
+
+def _apply_quantifier(
+ builder: builder_module.RegexBuilder, min_count: int, max_count: int, lazy: bool
+) -> builder_module.RegexBuilder:
+ if min_count == 0 and max_count == 1:
+ return builder.optional()
+ if min_count == 0 and max_count == sre_constants.MAXREPEAT:
+ return builder.zero_or_more_lazy() if lazy else builder.zero_or_more()
+ if min_count == 1 and max_count == sre_constants.MAXREPEAT:
+ return builder.one_or_more_lazy() if lazy else builder.one_or_more()
+ if min_count == max_count:
+ return builder.exactly(min_count)
+ if max_count == sre_constants.MAXREPEAT:
+ return builder.at_least(min_count)
+ if lazy:
+ return builder.between_lazy(min_count, max_count)
+ return builder.between(min_count, max_count)
+
+
+def _translate_subpattern(
+ builder: builder_module.RegexBuilder, argument: Any, names: dict[int, str]
+) -> builder_module.RegexBuilder:
+ group_number, _in_flags, _out_flags, body = argument
+ body_nodes = list(body)
+ if group_number in names:
+ opened = builder.named_capture(names[group_number])
+ else:
+ opened = builder.capture()
+ populated = _translate_sequence(opened, body_nodes, names)
+ return populated.end()
+
+
+def _translate_branch(
+ builder: builder_module.RegexBuilder, argument: Any
+) -> builder_module.RegexBuilder:
+ _leading, branches = argument
+ literal_branches: list[str] = []
+ for branch in branches:
+ branch_nodes = list(branch)
+ literal_string = _branch_as_literal_string(branch_nodes)
+ if literal_string is None:
+ raise UnsupportedReverseParseError("alternation with non-literal branch")
+ literal_branches.append(literal_string)
+ return builder.any_of(*literal_branches)
+
+
+def _branch_as_literal_string(nodes: list[Any]) -> str | None:
+ characters: list[str] = []
+ for node in nodes:
+ opcode, argument = node
+ if opcode != sre_constants.LITERAL:
+ return None
+ characters.append(chr(argument))
+ return "".join(characters)
+
+
+def _translate_lookaround(
+ builder: builder_module.RegexBuilder,
+ argument: Any,
+ names: dict[int, str],
+ negative: bool,
+) -> builder_module.RegexBuilder:
+ direction, body = argument
+ method_name = _lookaround_method(direction, negative)
+ lookaround_opener = getattr(builder, method_name)
+ opened = cast(builder_module.RegexBuilder, lookaround_opener())
+ body_nodes = list(body)
+ populated = _translate_sequence(opened, body_nodes, names)
+ return populated.end()
+
+
+def _lookaround_method(direction: int, negative: bool) -> str:
+ if direction == 1:
+ return "assert_not_ahead" if negative else "assert_ahead"
+ return "assert_not_behind" if negative else "assert_behind"
diff --git a/edify/compile/backend.py b/edify/compile/backend.py
new file mode 100644
index 0000000..d2bae12
--- /dev/null
+++ b/edify/compile/backend.py
@@ -0,0 +1,91 @@
+"""Engine backends for compiling a rendered pattern string.
+
+The stdlib :mod:`re` backend is always available. The third-party ``regex``
+module is loaded lazily so ``import edify`` never imports it. Callers that
+select ``engine="regex"`` reach this module and receive a clean
+:class:`edify.errors.backend.MissingRegexBackendError` when the extra is
+not installed.
+"""
+
+from __future__ import annotations
+
+import re
+from importlib import import_module
+from types import ModuleType
+from typing import cast
+
+from edify.builder.types.engine import Engine
+from edify.builder.types.flags import Flags
+from edify.errors.backend import MissingRegexBackendError, VariableWidthLookbehindNotSupportedError
+
+
+def load_regex_module() -> ModuleType:
+ """Return the third-party ``regex`` module, importing it on first call.
+
+ Raises:
+ MissingRegexBackendError: when the ``regex`` extra is not installed.
+ """
+ try:
+ return import_module("regex")
+ except ImportError as reason:
+ raise MissingRegexBackendError() from reason
+
+
+def compile_pattern(pattern: str, engine: Engine, flags: Flags) -> re.Pattern[str]:
+ """Compile ``pattern`` with the selected engine, applying ``flags`` as a bitmask.
+
+ The return type is declared as :class:`re.Pattern` so downstream call sites
+ stay strongly typed. Under ``engine="regex"`` the value is a ``regex.Pattern``
+ that mirrors :class:`re.Pattern`'s method surface.
+
+ Raises:
+ VariableWidthLookbehindNotSupportedError: when ``engine='re'`` and the
+ pattern uses a variable-width lookbehind body that stdlib re rejects.
+ MissingRegexBackendError: when ``engine='regex'`` and the ``regex`` module
+ is not installed.
+ """
+ if engine == "regex":
+ regex_module = load_regex_module()
+ return cast(
+ re.Pattern[str], regex_module.compile(pattern, _regex_flag_bitmask(regex_module, flags))
+ )
+ try:
+ return re.compile(pattern, flags=_re_flag_bitmask(flags))
+ except re.error as reason:
+ if "look-behind" in str(reason):
+ raise VariableWidthLookbehindNotSupportedError() from reason
+ raise
+
+
+def _re_flag_bitmask(flags: Flags) -> int:
+ bitmask = 0
+ if flags.ascii_only:
+ bitmask = bitmask | re.A
+ if flags.debug:
+ bitmask = bitmask | re.DEBUG
+ if flags.ignore_case:
+ bitmask = bitmask | re.I
+ if flags.multiline:
+ bitmask = bitmask | re.M
+ if flags.dotall:
+ bitmask = bitmask | re.S
+ if flags.verbose:
+ bitmask = bitmask | re.X
+ return bitmask
+
+
+def _regex_flag_bitmask(regex_module: ModuleType, flags: Flags) -> int:
+ bitmask = 0
+ if flags.ascii_only:
+ bitmask = bitmask | regex_module.A
+ if flags.debug:
+ bitmask = bitmask | regex_module.DEBUG
+ if flags.ignore_case:
+ bitmask = bitmask | regex_module.I
+ if flags.multiline:
+ bitmask = bitmask | regex_module.M
+ if flags.dotall:
+ bitmask = bitmask | regex_module.S
+ if flags.verbose:
+ bitmask = bitmask | regex_module.X
+ return bitmask
diff --git a/edify/errors/backend.py b/edify/errors/backend.py
new file mode 100644
index 0000000..3102c55
--- /dev/null
+++ b/edify/errors/backend.py
@@ -0,0 +1,63 @@
+"""Exceptions raised at engine-dispatch time."""
+
+from __future__ import annotations
+
+from edify.errors.formatting import compose_annotated_message
+from edify.errors.syntax import EdifySyntaxError
+
+
+class MissingRegexBackendError(EdifySyntaxError):
+ """Raised when ``.to_regex(engine="regex")`` runs without the ``regex`` module installed."""
+
+ def __init__(self) -> None:
+ message = compose_annotated_message(
+ summary="engine='regex' requires the 'regex' module, which is not installed",
+ trigger_hint=".to_regex(engine='regex') called here",
+ note=(
+ "the 'regex' engine is an opt-in extra so pip install edify stays dependency-free."
+ ),
+ help_line="help: install the extra with `pip install edify[regex]` and retry.",
+ )
+ super().__init__(message)
+
+
+class TimeoutNotSupportedByEngineError(EdifySyntaxError):
+ """Raised when ``timeout=`` is passed to a match call on an ``engine='re'`` pattern."""
+
+ def __init__(self) -> None:
+ message = compose_annotated_message(
+ summary="the timeout= kwarg is only supported under engine='regex'",
+ trigger_hint="match/search/... called with timeout= here",
+ note=(
+ "the stdlib re module has no per-call timeout facility; only the "
+ "third-party regex engine exposes one."
+ ),
+ help_line=(
+ "help: re-compile the pattern with .to_regex(engine='regex') to use timeout=, "
+ "or drop the kwarg."
+ ),
+ )
+ super().__init__(message)
+
+
+class VariableWidthLookbehindNotSupportedError(EdifySyntaxError):
+ """Raised when ``engine='re'`` compiles a lookbehind whose body is variable-width."""
+
+ def __init__(self) -> None:
+ message = compose_annotated_message(
+ summary=(
+ "assert_behind / assert_not_behind has a variable-width body, "
+ "which the stdlib 're' engine does not accept"
+ ),
+ trigger_hint=".to_regex(engine='re') called here",
+ note=(
+ "stdlib re requires every lookbehind branch to be fixed-width, so a "
+ "quantifier like +/*/?/{m,n} or a same-frame alternation with differing "
+ "branch widths inside a lookbehind will fail to compile."
+ ),
+ help_line=(
+ "help: switch to the third-party engine with .to_regex(engine='regex'), "
+ "which supports variable-width lookbehind."
+ ),
+ )
+ super().__init__(message)
diff --git a/edify/errors/context.py b/edify/errors/context.py
index 4108002..23859df 100644
--- a/edify/errors/context.py
+++ b/edify/errors/context.py
@@ -6,7 +6,8 @@ import linecache
import os
import sys
from dataclasses import dataclass
-from types import FrameType
+from functools import cache
+from types import CodeType, FrameType
_EDIFY_PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_EDIFY_PACKAGE_PREFIX = _EDIFY_PACKAGE_ROOT + os.sep
@@ -21,14 +22,17 @@ class CallerContext:
lineno: 1-indexed line number of the offending call.
colno: 1-indexed column at which the offending call starts.
end_colno: 1-indexed column one past where the offending call ends.
- source_line: The verbatim source line, trailing whitespace stripped.
"""
filename: str
lineno: int
colno: int
end_colno: int
- source_line: str
+
+ @property
+ def source_line(self) -> str:
+ """Return the verbatim source line at ``lineno``, trailing whitespace stripped."""
+ return read_source_line(self.filename, self.lineno)
def capture_caller_context() -> CallerContext | None:
@@ -40,35 +44,47 @@ def capture_caller_context() -> CallerContext | None:
current_frame: FrameType | None = sys._getframe(1)
while current_frame is not None:
filename = current_frame.f_code.co_filename
- absolute_filename = (
- os.path.abspath(filename) if os.path.isabs(filename) is False else filename
- )
- if not absolute_filename.startswith(_EDIFY_PACKAGE_PREFIX):
+ if not _is_inside_edify(filename):
return _context_for_frame(current_frame)
current_frame = current_frame.f_back
return None
+def read_source_line(filename: str, lineno: int) -> str:
+ """Return the verbatim source line at ``lineno``, or an empty string when unreadable."""
+ raw_line = linecache.getline(filename, lineno)
+ return raw_line.rstrip()
+
+
+@cache
+def _is_inside_edify(filename: str) -> bool:
+ absolute_filename = os.path.abspath(filename) if not os.path.isabs(filename) else filename
+ return absolute_filename.startswith(_EDIFY_PACKAGE_PREFIX)
+
+
def _context_for_frame(frame: FrameType) -> CallerContext:
"""Return a :class:`CallerContext` describing ``frame``'s current instruction."""
filename = frame.f_code.co_filename
- positions = list(frame.f_code.co_positions())
instruction_index = frame.f_lasti // 2
- raw_start_line, _end_line, raw_start_col, raw_end_col = positions[instruction_index]
+ raw_start_line, raw_start_col, raw_end_col = _positions_at(frame.f_code, instruction_index)
resolved_start_line = raw_start_line if raw_start_line is not None else frame.f_lineno
resolved_start_col = raw_start_col if raw_start_col is not None else 0
resolved_end_col = raw_end_col if raw_end_col is not None else resolved_start_col
- source_line = _read_source_line(filename, resolved_start_line)
return CallerContext(
filename=filename,
lineno=resolved_start_line,
colno=resolved_start_col + 1,
end_colno=resolved_end_col + 1,
- source_line=source_line,
)
-def _read_source_line(filename: str, lineno: int) -> str:
- """Return the verbatim source line at ``lineno``, or an empty string when unreadable."""
- raw_line = linecache.getline(filename, lineno)
- return raw_line.rstrip()
+def _positions_at(code: CodeType, index: int) -> tuple[int | None, int | None, int | None]:
+ start_line, _end_line, start_col, end_col = _positions_for_code(code)[index]
+ return start_line, start_col, end_col
+
+
+@cache
+def _positions_for_code(
+ code: CodeType,
+) -> tuple[tuple[int | None, int | None, int | None, int | None], ...]:
+ return tuple(code.co_positions())
diff --git a/edify/errors/engine.py b/edify/errors/engine.py
deleted file mode 100644
index 5a9e6cb..0000000
--- a/edify/errors/engine.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""Exception classes raised when the ``engine`` kwarg selects an unavailable backend."""
-
-from __future__ import annotations
-
-from edify.errors.formatting import compose_annotated_message
-from edify.errors.syntax import EdifySyntaxError
-
-
-class EngineNotWiredError(EdifySyntaxError):
- """Raised when ``.to_regex(engine=...)`` selects a backend this build does not wire.
-
- Args:
- requested_engine: The value the caller passed for the ``engine`` kwarg.
- """
-
- def __init__(self, requested_engine: str) -> None:
- message = compose_annotated_message(
- summary=(
- f"engine={requested_engine!r} is not wired in this build; only 're' is available"
- ),
- trigger_hint=".to_regex(engine=…) called here",
- note=(
- f"the {requested_engine!r} backend is a planned optional dependency "
- "and its dispatch has not shipped yet."
- ),
- help_line=(
- "help: call .to_regex() without the engine kwarg, or pin edify to a "
- "release that wires the requested backend."
- ),
- )
- super().__init__(message)
diff --git a/edify/errors/testing.py b/edify/errors/testing.py
new file mode 100644
index 0000000..f02a64a
--- /dev/null
+++ b/edify/errors/testing.py
@@ -0,0 +1,55 @@
+"""Exceptions raised by the pattern-testing helpers on the builder surface."""
+
+from __future__ import annotations
+
+from edify.errors.formatting import compose_annotated_message
+
+
+class PatternDidNotMatchInputsError(AssertionError):
+ """Raised by :meth:`assert_matches` when one or more expected inputs did not match."""
+
+ def __init__(self, pattern_source: str, missing_matches: tuple[str, ...]) -> None:
+ listed = ", ".join(repr(item) for item in missing_matches)
+ summary = (
+ f"pattern {pattern_source!r} did not match {len(missing_matches)} expected "
+ f"input(s): {listed}"
+ )
+ message = compose_annotated_message(
+ summary=summary,
+ trigger_hint=".assert_matches([...]) called here",
+ note=(
+ "every string in the argument list must match the pattern to satisfy the "
+ "assertion; the listed inputs were rejected."
+ ),
+ help_line=(
+ "help: adjust the pattern to accept these inputs, or drop them from the "
+ "assertion list."
+ ),
+ )
+ super().__init__(message)
+ self.missing_matches = missing_matches
+
+
+class PatternMatchedRejectedInputsError(AssertionError):
+ """Raised by :meth:`assert_rejects` when one or more inputs matched unexpectedly."""
+
+ def __init__(self, pattern_source: str, unexpected_matches: tuple[str, ...]) -> None:
+ listed = ", ".join(repr(item) for item in unexpected_matches)
+ summary = (
+ f"pattern {pattern_source!r} matched {len(unexpected_matches)} input(s) that "
+ f"were expected to be rejected: {listed}"
+ )
+ message = compose_annotated_message(
+ summary=summary,
+ trigger_hint=".assert_rejects([...]) called here",
+ note=(
+ "every string in the argument list must be rejected by the pattern to satisfy "
+ "the assertion; the listed inputs matched instead."
+ ),
+ help_line=(
+ "help: tighten the pattern so these inputs no longer match, or drop them from "
+ "the assertion list."
+ ),
+ )
+ super().__init__(message)
+ self.unexpected_matches = unexpected_matches
diff --git a/edify/pattern/composition.py b/edify/pattern/composition.py
index a913f8b..aab8738 100644
--- a/edify/pattern/composition.py
+++ b/edify/pattern/composition.py
@@ -18,6 +18,7 @@ from edify.builder.mixins.operators import OperatorsMixin
from edify.builder.mixins.quantifiers import QuantifiersMixin
from edify.builder.mixins.subexpression import SubexpressionMixin
from edify.builder.mixins.terminals import TerminalsMixin
+from edify.builder.mixins.testing import TestingMixin
from edify.serialize.dump import state_to_dict
from edify.serialize.load import dict_to_state
from edify.serialize.types import JSONValue
@@ -38,6 +39,7 @@ class Pattern(
QuantifiersMixin,
SubexpressionMixin,
TerminalsMixin,
+ TestingMixin,
):
"""A named, reusable regex fragment.
@@ -46,18 +48,12 @@ class Pattern(
"""
def __call__(self, value: str) -> bool:
- """Return True when ``value`` matches this pattern from its first character.
+ """Return True when ``value`` matches this pattern anywhere in it.
- Args:
- value: The string to test against the pattern.
-
- Returns:
- True when the pattern matches ``value`` from its start; False otherwise.
+ Shortcut for :meth:`test` so a Pattern doubles as a validator callable
+ (``email(value) -> bool``) without an intermediate ``.test`` step.
"""
- if not isinstance(value, str):
- return False
- compiled = self.to_regex()
- return compiled.match(value) is not None
+ return self.test(value)
def to_dict(self) -> dict[str, JSONValue]:
"""Return the canonical dict representation of this pattern."""
diff --git a/edify/result/__init__.py b/edify/result/__init__.py
index 457f79e..858003e 100644
--- a/edify/result/__init__.py
+++ b/edify/result/__init__.py
@@ -1,3 +1,4 @@
+from edify.result.match import Match, NamedCaptures
from edify.result.regex import Regex
-__all__ = ["Regex"]
+__all__ = ["Match", "NamedCaptures", "Regex"]
diff --git a/edify/result/match.py b/edify/result/match.py
new file mode 100644
index 0000000..247ee77
--- /dev/null
+++ b/edify/result/match.py
@@ -0,0 +1,59 @@
+"""The :class:`Match` composition wrapper — attribute-access for named captures."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Callable
+from typing import Any
+
+
+class NamedCaptures:
+ """A namespace exposing every named capture group as an attribute."""
+
+ def __init__(self, wrapped_match: re.Match[str]) -> None:
+ self._wrapped_match = wrapped_match
+
+ def __getattr__(self, name: str) -> str | None:
+ """Return the substring captured by the named group ``name``."""
+ group_index_map = self._wrapped_match.re.groupindex
+ if name not in group_index_map:
+ raise AttributeError(
+ f"named capture group {name!r} does not exist on this pattern; "
+ f"declared groups are {sorted(group_index_map)}"
+ )
+ return self._wrapped_match.group(name)
+
+ def __dir__(self) -> list[str]:
+ """Return every declared named group so ``dir()`` and IDE completion see them."""
+ return sorted(self._wrapped_match.re.groupindex)
+
+
+class Match:
+ """A wrapped :class:`re.Match` with attribute-access for named captures."""
+
+ def __init__(self, wrapped_match: re.Match[str]) -> None:
+ self._wrapped_match: re.Match[str] = wrapped_match
+ self._captures: NamedCaptures = NamedCaptures(wrapped_match)
+
+ @property
+ def wrapped(self) -> re.Match[str]:
+ """The underlying :class:`re.Match` for direct interop with :mod:`re`."""
+ return self._wrapped_match
+
+ @property
+ def captures(self) -> NamedCaptures:
+ """A namespace of named groups; access captures via ``m.captures.<name>``."""
+ return self._captures
+
+ def __getattr__(self, name: str) -> Any:
+ """Return the named-group substring or delegate to the underlying re.Match."""
+ wrapped = self._wrapped_match
+ group_index_map = wrapped.re.groupindex
+ if name in group_index_map:
+ return wrapped.group(name)
+ attribute: str | Callable[..., Any] = getattr(wrapped, name)
+ return attribute
+
+ def __repr__(self) -> str:
+ """Return ``<Match 'span'-'text'>`` for interactive display."""
+ return f"<Match {self._wrapped_match.span()} {self._wrapped_match.group()!r}>"
diff --git a/edify/result/regex.py b/edify/result/regex.py
index 89dbb03..d718580 100644
--- a/edify/result/regex.py
+++ b/edify/result/regex.py
@@ -1,42 +1,48 @@
-"""The :class:`Regex` composition wrapper over :class:`re.Pattern`."""
+"""The :class:`Regex` composition wrapper over the compiled backend pattern."""
from __future__ import annotations
import re
import sys
from collections.abc import Callable, Iterator, Mapping
+from typing import Any, cast
+from edify.builder.types.engine import Engine
from edify.elements.types.base import BaseElement
+from edify.errors.backend import TimeoutNotSupportedByEngineError
from edify.introspect.explain import explain_elements
from edify.introspect.verbose import verbose_elements
from edify.introspect.visualize import visualize_elements
+from edify.result.match import Match
-_RePatternMethodReturn = (
- re.Match[str]
+_MethodReturn = (
+ Match
| list[str]
| list[tuple[str, ...]]
- | Iterator[re.Match[str]]
+ | Iterator[Match]
| str
| tuple[str, int]
| list[str | None]
| None
)
-_RePatternAttribute = int | str | Mapping[str, int] | Callable[..., _RePatternMethodReturn]
+_PatternAttribute = int | str | Mapping[str, int] | Callable[..., _MethodReturn]
class Regex:
- """A compiled edify pattern; wraps :class:`re.Pattern` by composition."""
+ """A compiled edify pattern; wraps the selected engine's ``Pattern`` by composition."""
def __init__(
self,
source: str,
- compiled: re.Pattern[str],
+ compiled: Any,
elements: tuple[BaseElement, ...] = (),
+ engine: Engine = "re",
) -> None:
- self._source = source
- self._compiled = compiled
- self._elements = elements
+ self._source: str = source
+ self._compiled: re.Pattern[str] = cast(re.Pattern[str], compiled)
+ self._elements: tuple[BaseElement, ...] = elements
+ self._engine: Engine = engine
@property
def source(self) -> str:
@@ -45,7 +51,7 @@ class Regex:
@property
def compiled(self) -> re.Pattern[str]:
- """The underlying :class:`re.Pattern` for direct interop with :mod:`re`."""
+ """The underlying compiled ``Pattern`` for direct interop with the engine module."""
return self._compiled
@property
@@ -53,53 +59,114 @@ class Regex:
"""The AST elements produced by the builder, in emission order."""
return self._elements
- def match(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None:
- """Delegate to :meth:`re.Pattern.match`."""
- return self._compiled.match(string, pos, endpos)
+ @property
+ def engine(self) -> Engine:
+ """The engine identifier this pattern was compiled with."""
+ return self._engine
- def search(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None:
- """Delegate to :meth:`re.Pattern.search`."""
- return self._compiled.search(string, pos, endpos)
+ def match(
+ self,
+ string: str,
+ pos: int = 0,
+ endpos: int = sys.maxsize,
+ *,
+ timeout: float | None = None,
+ ) -> Match | None:
+ """Delegate to the compiled pattern's ``match`` method."""
+ raw = self._compiled.match(string, pos, endpos, **_timeout_kwargs(self._engine, timeout))
+ return Match(raw) if raw is not None else None
+
+ def search(
+ self,
+ string: str,
+ pos: int = 0,
+ endpos: int = sys.maxsize,
+ *,
+ timeout: float | None = None,
+ ) -> Match | None:
+ """Delegate to the compiled pattern's ``search`` method."""
+ raw = self._compiled.search(string, pos, endpos, **_timeout_kwargs(self._engine, timeout))
+ return Match(raw) if raw is not None else None
def fullmatch(
- self, string: str, pos: int = 0, endpos: int = sys.maxsize
- ) -> re.Match[str] | None:
- """Delegate to :meth:`re.Pattern.fullmatch`."""
- return self._compiled.fullmatch(string, pos, endpos)
+ self,
+ string: str,
+ pos: int = 0,
+ endpos: int = sys.maxsize,
+ *,
+ timeout: float | None = None,
+ ) -> Match | None:
+ """Delegate to the compiled pattern's ``fullmatch`` method."""
+ raw = self._compiled.fullmatch(
+ string, pos, endpos, **_timeout_kwargs(self._engine, timeout)
+ )
+ return Match(raw) if raw is not None else None
def findall(
- self, string: str, pos: int = 0, endpos: int = sys.maxsize
+ self,
+ string: str,
+ pos: int = 0,
+ endpos: int = sys.maxsize,
+ *,
+ timeout: float | None = None,
) -> list[str] | list[tuple[str, ...]]:
- """Delegate to :meth:`re.Pattern.findall`."""
- return self._compiled.findall(string, pos, endpos)
+ """Delegate to the compiled pattern's ``findall`` method."""
+ return self._compiled.findall(string, pos, endpos, **_timeout_kwargs(self._engine, timeout))
def finditer(
- self, string: str, pos: int = 0, endpos: int = sys.maxsize
- ) -> Iterator[re.Match[str]]:
- """Delegate to :meth:`re.Pattern.finditer`."""
- return self._compiled.finditer(string, pos, endpos)
+ self,
+ string: str,
+ pos: int = 0,
+ endpos: int = sys.maxsize,
+ *,
+ timeout: float | None = None,
+ ) -> Iterator[Match]:
+ """Delegate to the compiled pattern's ``finditer`` method."""
+ raw_iter = self._compiled.finditer(
+ string, pos, endpos, **_timeout_kwargs(self._engine, timeout)
+ )
+ for raw in raw_iter:
+ yield Match(raw)
def sub(
self,
- replacement: str | Callable[[re.Match[str]], str],
+ replacement: str | Callable[[Match], str],
string: str,
count: int = 0,
+ *,
+ timeout: float | None = None,
) -> str:
- """Delegate to :meth:`re.Pattern.sub`."""
- return self._compiled.sub(replacement, string, count=count)
+ """Delegate to the compiled pattern's ``sub`` method."""
+ adapter = _wrap_replacement(replacement)
+ return self._compiled.sub(
+ adapter, string, count=count, **_timeout_kwargs(self._engine, timeout)
+ )
def subn(
self,
- replacement: str | Callable[[re.Match[str]], str],
+ replacement: str | Callable[[Match], str],
string: str,
count: int = 0,
+ *,
+ timeout: float | None = None,
) -> tuple[str, int]:
- """Delegate to :meth:`re.Pattern.subn`."""
- return self._compiled.subn(replacement, string, count=count)
+ """Delegate to the compiled pattern's ``subn`` method."""
+ adapter = _wrap_replacement(replacement)
+ return self._compiled.subn(
+ adapter, string, count=count, **_timeout_kwargs(self._engine, timeout)
+ )
- def split(self, string: str, maxsplit: int = 0) -> list[str | None]:
- """Delegate to :meth:`re.Pattern.split`."""
- return self._compiled.split(string, maxsplit=maxsplit)
+ def split(
+ self,
+ string: str,
+ maxsplit: int = 0,
+ *,
+ timeout: float | None = None,
+ ) -> list[str | None]:
+ """Delegate to the compiled pattern's ``split`` method."""
+ return self._compiled.split(
+ string, maxsplit=maxsplit, **_timeout_kwargs(self._engine, timeout)
+ )
def explain(self) -> str:
"""Return a human-readable narrative describing what the pattern matches."""
@@ -133,9 +200,9 @@ class Regex:
"""
return visualize_elements(self._elements, format=format, engine=engine)
- def __getattr__(self, name: str) -> _RePatternAttribute:
- """Return the underlying :class:`re.Pattern` attribute named ``name``."""
- attribute: _RePatternAttribute = getattr(self._compiled, name)
+ def __getattr__(self, name: str) -> _PatternAttribute:
+ """Return the underlying compiled pattern's attribute named ``name``."""
+ attribute: _PatternAttribute = getattr(self._compiled, name)
return attribute
def __repr__(self) -> str:
@@ -143,11 +210,36 @@ class Regex:
return f"<Regex {self._source!r}>"
def __eq__(self, other: object) -> bool:
- """Return True when ``other`` is a Regex whose source and flags match."""
+ """Return True when ``other`` is a Regex whose source, engine, and flags match."""
if not isinstance(other, Regex):
return NotImplemented
- return self._source == other._source and self._compiled.flags == other._compiled.flags
+ return (
+ self._source == other._source
+ and self._engine == other._engine
+ and self._compiled.flags == other._compiled.flags
+ )
def __hash__(self) -> int:
- """Return a hash derived from the source and compiled flags."""
- return hash((self._source, self._compiled.flags))
+ """Return a hash derived from the source, engine, and compiled flags."""
+ return hash((self._source, self._engine, self._compiled.flags))
+
+
+def _timeout_kwargs(engine: Engine, timeout: float | None) -> Mapping[str, float]:
+ if timeout is None:
+ return {}
+ if engine == "re":
+ raise TimeoutNotSupportedByEngineError()
+ return {"timeout": timeout}
+
+
+def _wrap_replacement(
+ replacement: str | Callable[[Match], str],
+) -> str | Callable[[re.Match[str]], str]:
+ if isinstance(replacement, str):
+ return replacement
+ caller = replacement
+
+ def adapter(raw_match: re.Match[str]) -> str:
+ return caller(Match(raw_match))
+
+ return adapter
diff --git a/pyproject.toml b/pyproject.toml
index eb18734..bde6a28 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,7 @@ dynamic = ["version"]
[project.optional-dependencies]
graphviz = ["graphviz>=0.20"]
+regex = ["regex>=2024.11.6"]
[project.urls]
Documentation = "https://edify.readthedocs.io/"
@@ -49,6 +50,7 @@ dev = [
"pyright==1.1.389",
"pytest>=8.0",
"pytest-cov>=5.0",
+ "regex>=2024.11.6",
"ruff>=0.7",
]
docs = [
@@ -143,6 +145,10 @@ ignore_missing_imports = true
module = ["graphviz"]
ignore_missing_imports = true
+[[tool.mypy.overrides]]
+module = ["re._parser", "re._constants"]
+ignore_missing_imports = true
+
[tool.coverage.run]
branch = false
source = ["edify"]
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
diff --git a/uv.lock b/uv.lock
index dd17d7a..2510266 100644
--- a/uv.lock
+++ b/uv.lock
@@ -237,6 +237,9 @@ source = { editable = "." }
graphviz = [
{ name = "graphviz" },
]
+regex = [
+ { name = "regex" },
+]
[package.dev-dependencies]
dev = [
@@ -246,6 +249,7 @@ dev = [
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-cov" },
+ { name = "regex" },
{ name = "ruff" },
]
docs = [
@@ -255,8 +259,11 @@ docs = [
]
[package.metadata]
-requires-dist = [{ name = "graphviz", marker = "extra == 'graphviz'", specifier = ">=0.20" }]
-provides-extras = ["graphviz"]
+requires-dist = [
+ { name = "graphviz", marker = "extra == 'graphviz'", specifier = ">=0.20" },
+ { name = "regex", marker = "extra == 'regex'", specifier = ">=2024.11.6" },
+]
+provides-extras = ["graphviz", "regex"]
[package.metadata.requires-dev]
dev = [
@@ -266,6 +273,7 @@ dev = [
{ name = "pyright", specifier = "==1.1.389" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-cov", specifier = ">=5.0" },
+ { name = "regex", specifier = ">=2024.11.6" },
{ name = "ruff", specifier = ">=0.7" },
]
docs = [
@@ -524,6 +532,110 @@ wheels = [
]
[[package]]
+name = "regex"
+version = "2026.7.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/37/451aaddbf50922f34d744ad5ca919ae1fcfac112123885d9728f52a484b3/regex-2026.7.10.tar.gz", hash = "sha256:1050fedf0a8a92e843971120c2f57c3a99bea86c0dfa1d63a9fac053fe54b135", size = 416282, upload-time = "2026-07-10T19:49:46.267Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/16/bfd13770be1acd1c05506b93fc6be15c759d6417595d1ba334d355efbf26/regex-2026.7.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:66d2c35587cd601c95965d5c0415058ba5cfd6ffbab7624ce198bd967102b341", size = 494639, upload-time = "2026-07-10T19:46:52.207Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/b4/0086215709f0f705661f13ba81516287538886ef0d589c545c12b0484669/regex-2026.7.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28a0973eeffff4292f5a7ee498ab65d5e94ee8cc9cea364239251eb4a260a0f1", size = 295920, upload-time = "2026-07-10T19:46:53.63Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/9e/8e07d0eea46d2cf36bf4d3794634bb0a820f016d31bc349dfef008d96b02/regex-2026.7.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8331484450b3894298bef8abecce532171ff6ac60b71f999eed10f2c01941a8a", size = 290673, upload-time = "2026-07-10T19:46:54.863Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/56/d83c446de21c70ff49d2f1b2ff2196ac79a4ac6373d2cfe496011a250600/regex-2026.7.10-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0639b2488b775a0109f55a5a2172deebdedb4b6c5ab0d48c90b43cbf5de58d17", size = 792378, upload-time = "2026-07-10T19:46:56.116Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/0e/d265e0cc6da47aea97e90eb896be2d2e8f92d16add13bac04fa46a0fd972/regex-2026.7.10-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be4223af640d0aa04c05db81d5d96ada3ead9c09187d892fd37f4f97829480be", size = 861790, upload-time = "2026-07-10T19:46:57.611Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/a5/62655f6208d1170a3e9188d6a45d4af0a5ae3b9da8b87d474818ac5ff016/regex-2026.7.10-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d3c75d57a00109255e60bc9c623b6ececaf7905eaab845c79f036670ed4750a2", size = 906530, upload-time = "2026-07-10T19:46:59.142Z" },
+ { url = "https://files.pythonhosted.org/packages/86/b7/d65aa2e9ffb18677cd0afbcf5990da8519a4e50778deb1bca49f043c5174/regex-2026.7.10-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:724ee9379568658ec06362cf24325c5315cc5a67f61dfe585bfeff58300a355b", size = 799912, upload-time = "2026-07-10T19:47:00.534Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/19/3a5ce23ea2eb1fe36306aef49c79746ce297e4b434aeb981b525c661413a/regex-2026.7.10-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:732c19e5828eb287d01edb83b2eb87f283ba8e5fc3441c732709d3e8cbd14aaa", size = 773675, upload-time = "2026-07-10T19:47:01.999Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/76/3c0eaa426700dd2ba14f2335f2b700a4e1484202254192ae440b83b8352a/regex-2026.7.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:982d07727c809b42a3968785354f11c3728414e4e90af0754345b431b2c32561", size = 781711, upload-time = "2026-07-10T19:47:03.425Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/a8/a5a3fad84f9a7f897619f0f8e0a2c64946e9709044a186a8f869fb5c332f/regex-2026.7.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4574feca202f8c470bf678aed8b5d89df04aaf8dc677f3b83d92825051301c0f", size = 854539, upload-time = "2026-07-10T19:47:04.999Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/c7/47e9b8c8ee77723b9eda74f517b6b25d2f555cf276c063a9eeea35bd86d5/regex-2026.7.10-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:80151ca5bfc6c4524186b3e08b499e97319b2001fc265ed2d4fc12c0d5692cdf", size = 763378, upload-time = "2026-07-10T19:47:06.845Z" },
+ { url = "https://files.pythonhosted.org/packages/36/09/e27e42d9d42edf71205c7e6f5b2902bc874ea03c557c80da03b8ed16c9bf/regex-2026.7.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bb52e10e453b5493afe1f7702a2973bc10f4dd8901c0f2ed869ffaa3f8319296", size = 844663, upload-time = "2026-07-10T19:47:08.923Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/b5/2423acb98362184ad9c8eebabafa15188d6a177daab919add8f2120fc6cd/regex-2026.7.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e37aba1994d73b4944053ab65a15f313bd5c28c885dd7f0d494a11749d89db6e", size = 789236, upload-time = "2026-07-10T19:47:10.303Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ed/b387e84c8a3d6aa115dfb56865437a3fbaf28f4a6fb3b76cc6cce38ced70/regex-2026.7.10-cp311-cp311-win32.whl", hash = "sha256:6cbedeb5112f59dbd169385459b9943310bdd241c6966c19c5f6e2295055c93a", size = 266774, upload-time = "2026-07-10T19:47:11.904Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/64/f30a163a65ed1f07ad12c53af00a6bd2a7251a5329fba5a08adc6f9e81a3/regex-2026.7.10-cp311-cp311-win_amd64.whl", hash = "sha256:b1963ec5ba4d52788fb0eac6aca6eb8040e8e318c7e47ebbdfc09440c802919c", size = 277959, upload-time = "2026-07-10T19:47:13.231Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/de/61c8174171134cebb834ca9f8fe2ff8f49d8a3dd43453b48b537d0fbb49b/regex-2026.7.10-cp311-cp311-win_arm64.whl", hash = "sha256:3750c42d47712e362158a04d0fd80131f73a55e8c715b2885442a0ff6f9fc3fc", size = 276918, upload-time = "2026-07-10T19:47:14.693Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/9c/2503d4ccf3452dc323f8baa3cf3ee10406037d52735c76cfced81423f183/regex-2026.7.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7252b48b0c60100095088fbeb281fca9a4fcf678a4e04b1c520c3f8613c952c4", size = 497114, upload-time = "2026-07-10T19:47:16.22Z" },
+ { url = "https://files.pythonhosted.org/packages/91/eb/04534f4263a4f658cd20a511e9d6124350044f2214eb24fee2db96acf318/regex-2026.7.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:da6ef4cb8d457aab0482b50120136ae94238aaa421863eaa7d599759742c72d6", size = 297422, upload-time = "2026-07-10T19:47:17.794Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/2d/35809de392ab66ba439b58c3187ae3b8b53c883233f284b59961e5725c99/regex-2026.7.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fe7ff456c22725c9d9017f7a2a7df2b51af6df77314176760b22e2d05278e181", size = 292110, upload-time = "2026-07-10T19:47:19.188Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1e/5ce0fbe9aab071893ce2b7df020d0f561f7b411ec334124302468d587884/regex-2026.7.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3463a5f26be513a49e4d497debcf1b252a2db7b92c77d89621aa90b83d2dd38", size = 796800, upload-time = "2026-07-10T19:47:20.639Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/67/c1ccbada395c10e334763b583e1039b1660b142303ebb941d4269130b22f/regex-2026.7.10-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:948dfc62683a6947b9b486c4598d8f6e3ecc542478b6767b87d52be68aeb55c6", size = 865509, upload-time = "2026-07-10T19:47:22.135Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/06/f0b31afc16c1208f945b66290eb2a9936ab8becdfb23bbcedb91cc5f9d9b/regex-2026.7.10-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c2cbd385d82f63bb35edb60b09b08abad3619bd0a4a492ae59e55afaf98e1b9d", size = 912395, upload-time = "2026-07-10T19:47:24.128Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1c/8687de3a6c3220f4f872a9bf4bcd8dc249f2a96e7dddfa93de8bd4d16399/regex-2026.7.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f6222cafe00e072bb2b8f14142cd969637411fbc4dd3b1d73a90a3b817fa046f", size = 801308, upload-time = "2026-07-10T19:47:25.696Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/e3/60a40ec02a2315d826414a125640aceb6f30450574c530c8f352110ece0e/regex-2026.7.10-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:65ee5d1ac3cd541325f5ac92625b1c1505f4d171520dd931bda7952895c5321a", size = 777120, upload-time = "2026-07-10T19:47:27.158Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/9a/ec579b4f840ac59bc7c192b56e66abd4cbf385615300d59f7c94bf6863ae/regex-2026.7.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa34473fbcc108fea403074f3f45091461b18b2047d136f16ffaa4c65ad46a68", size = 785164, upload-time = "2026-07-10T19:47:28.732Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1c/60d88afd5f98d4b0fb1f8b8969270628140dc01c7ff93a939f2aa83f31a6/regex-2026.7.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d028d189d8f38d7ff292f22187c0df37f2317f554d2ed9a2908ada330af57c0", size = 860161, upload-time = "2026-07-10T19:47:30.605Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/40/08ae3ba45fe79e48c9a888a3389a7ee7e2d8c580d2d996da5ece02dfdcb9/regex-2026.7.10-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:396ea70e4ea1f19571940add3bad9fd3eb6a19dc610d0d01f692bc1ba0c10cb4", size = 765829, upload-time = "2026-07-10T19:47:32.06Z" },
+ { url = "https://files.pythonhosted.org/packages/12/e6/e613c6755d19aca9d977cdc3418a1991ffc8f386779752dd8fdfa888ea89/regex-2026.7.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ebbf0d83ed5271991d666e54bb6c90ac2c55fb2ef3a88740c6af85dc85de2402", size = 852170, upload-time = "2026-07-10T19:47:33.567Z" },
+ { url = "https://files.pythonhosted.org/packages/03/33/89072f2060e6b844b4916d5bc40ef01e973640c703025707869264ec75ab/regex-2026.7.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a4571b2a093f6f6ee4fd281faa8ebf645abcf575f758173ea2605c7a1e1ecb", size = 789550, upload-time = "2026-07-10T19:47:35.395Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/3c/4bc8be9a155035e63780ccac1da101f36194946fdc3f6fce90c7179fc6df/regex-2026.7.10-cp312-cp312-win32.whl", hash = "sha256:eac1207936555aa691ce32df1432b478f2729d54e6d93a1f4db9215bcd8eb47d", size = 267151, upload-time = "2026-07-10T19:47:37.047Z" },
+ { url = "https://files.pythonhosted.org/packages/35/73/9f5aade65bb98cc6e99c336e45a49a658300720c16721f3e687f8d754fec/regex-2026.7.10-cp312-cp312-win_amd64.whl", hash = "sha256:ecae626449d00db8c08f8f1fc00047a32d6d7eb5402b3976f5c3fda2b80a7a4f", size = 277751, upload-time = "2026-07-10T19:47:38.488Z" },
+ { url = "https://files.pythonhosted.org/packages/36/6f/d069dd12872ea1d50e17319d342f89e2072cae4b62f4245009a1108c74d8/regex-2026.7.10-cp312-cp312-win_arm64.whl", hash = "sha256:87794549a3f5c1c2bdfba2380c1bf87b931e375f4133d929da44f95e396bf5fe", size = 277063, upload-time = "2026-07-10T19:47:40.023Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/88/0c977b9f3ba9b08645516eca236388c340f56f7a87054d41a187a04e134c/regex-2026.7.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4db009b4fc533d79af3e841d6c8538730423f82ea8508e353a3713725de7901c", size = 496868, upload-time = "2026-07-10T19:47:41.675Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/51/600882cd5d9a3cf083fd66a4064f5b7f243ba2a7de2437d42823e286edaf/regex-2026.7.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b96341cb29a3faa5db05aff29c77d141d827414f145330e5d8846892119351c1", size = 297306, upload-time = "2026-07-10T19:47:43.521Z" },
+ { url = "https://files.pythonhosted.org/packages/52/6f/48a912054ffcb756e374207bb8f4430c5c3e0ffa9627b3c7b6661844b30a/regex-2026.7.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14d27f6bd04beb01f6a25a1153d73e58c290fd45d92ba56af1bb44199fd1010d", size = 291950, upload-time = "2026-07-10T19:47:45.267Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/c8/8e1c3c86ebcee7effccbd1f7fc54fe3af22aa0e9204503e2baea4a6ff001/regex-2026.7.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b6a11bf898cca3ce7bfaa17b646901107f3975677fbd5097f36e5eb5641983", size = 796817, upload-time = "2026-07-10T19:47:48.054Z" },
+ { url = "https://files.pythonhosted.org/packages/65/39/3e49d9ff0e0737eb8180a00569b47aabb59b84611f48392eba4d998d91a0/regex-2026.7.10-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234f8e0d65cf1df9becadae98648f74030ee85a8f12edcb5eb0f60a22a602197", size = 865513, upload-time = "2026-07-10T19:47:49.855Z" },
+ { url = "https://files.pythonhosted.org/packages/70/57/6511ad809bb3122c65bbeeffa5b750652bb03d273d29f3acb0754109b183/regex-2026.7.10-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:91b916d495db3e1b473c7c8e68733beec4dce8e487442db61764fff94f59740e", size = 912391, upload-time = "2026-07-10T19:47:51.776Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/29/a1b0c109c9e878cb04b931bfe4c54332d692b93c322e127b5ae9f25b0d9e/regex-2026.7.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f0d4ccf70b1d13711242de0ba78967db5c35d12ac408378c70e06295c3f6644", size = 801338, upload-time = "2026-07-10T19:47:53.38Z" },
+ { url = "https://files.pythonhosted.org/packages/33/be/171c3dad4d77000e1befeff2883ca88734696dfd97b2951e5e074f32e4dd/regex-2026.7.10-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c622f4c638a725c39abcb2e680b1bd592663c83b672a4ed350a17f806d75618e", size = 777149, upload-time = "2026-07-10T19:47:54.944Z" },
+ { url = "https://files.pythonhosted.org/packages/33/61/41ab0de0e4574da1071c151f67d1eb9db3d92c43e31d64d2e6863c3d89bf/regex-2026.7.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41a47c2b28d9421e2509a4583a22510dc31d83212fcf38e1508a7013140f71a8", size = 785216, upload-time = "2026-07-10T19:47:56.56Z" },
+ { url = "https://files.pythonhosted.org/packages/66/28/372859ea693736f07cf7023247c7eca8f221d9c6df8697ff9f93371cca08/regex-2026.7.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:13fba679fe035037e9d5286620f88bbfd105df4d5fcd975942edd282ab986775", size = 860229, upload-time = "2026-07-10T19:47:58.278Z" },
+ { url = "https://files.pythonhosted.org/packages/50/b1/e1d32cd944b599534ae655d35e8640d0ec790c0fa12e1fb29bf434d50f55/regex-2026.7.10-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8e26a075fa9945b9e44a3d02cc83d776c3b76bb1ff4b133bbfa620d5650131da", size = 765797, upload-time = "2026-07-10T19:48:00.291Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/79a2cd9556a3329351e370929743ef4f0ccc0aaff6b3dc414ae5fa4a1302/regex-2026.7.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d0834c84ae8750ae1c4cede59b0afd4d2f775be958e11b18a3eea24ed9d0d9f1", size = 852130, upload-time = "2026-07-10T19:48:01.972Z" },
+ { url = "https://files.pythonhosted.org/packages/66/58/76fec29898cf5d359ab63face50f9d4f7135cc2eca3477139227b1d09952/regex-2026.7.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64722a5031aeace7f6c8d5ea9a9b22d9368af0d6e8fa532585da8158549ea963", size = 789644, upload-time = "2026-07-10T19:48:03.748Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/06/3c7cec7817bda293e13c8f88aed227bbcf8b37e5990936ff6442a8fdf11a/regex-2026.7.10-cp313-cp313-win32.whl", hash = "sha256:74ae61d8573ecd51b5eeee7be2218e4c56e99c14fa8fcf97cf7519611d4be92e", size = 267130, upload-time = "2026-07-10T19:48:05.677Z" },
+ { url = "https://files.pythonhosted.org/packages/88/6c/e2a6f9a6a905f923cfc912298a5949737e9504b1ca24f29eda8d04d05ece/regex-2026.7.10-cp313-cp313-win_amd64.whl", hash = "sha256:5e792367e5f9b4ffb8cad93f1beaa91837056b94da98aa5c65a0db0c1b474927", size = 277722, upload-time = "2026-07-10T19:48:07.318Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a6/9d8935aaa940c388496aa1a0c82669cc4b5d06291c2712d595e3f0cf16d3/regex-2026.7.10-cp313-cp313-win_arm64.whl", hash = "sha256:82ab8330e7e2e416c2d42fcec67f02c242393b8681014750d4b70b3f158e1f08", size = 277059, upload-time = "2026-07-10T19:48:08.977Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/e9/26decfd3e85c09e42ff7b0d23a6f51085ca4c268db15f084928ca33459c6/regex-2026.7.10-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:2b93eafd92c4128bab2f93500e8912cc9ecb3d3765f6685b902c6820d0909b6b", size = 501508, upload-time = "2026-07-10T19:48:10.668Z" },
+ { url = "https://files.pythonhosted.org/packages/38/a5/5b167cebde101945690219bf34361481c9f07e858a4f46d9996b80ec1490/regex-2026.7.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3f03b92fb6ec739df042e45b06423fc717ecf0063e07ffe2897f7b2d5735e1e8", size = 299705, upload-time = "2026-07-10T19:48:12.544Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/20/7909be4b9f449f8c282c14b6762d59aa722aeaeebe7ee4f9bb623eeaa5e0/regex-2026.7.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bb5aab464a0c5e03a97abad5bdf54517061ebbf72340d576e99ff661a42575cc", size = 294605, upload-time = "2026-07-10T19:48:14.495Z" },
+ { url = "https://files.pythonhosted.org/packages/82/88/e52550185d6fda68f549b01239698697de47320fd599f5e880b1986b7673/regex-2026.7.10-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fadb07dbe36a541283ff454b1a268afd54b077d917043f2e1e5615372cb5f200", size = 811747, upload-time = "2026-07-10T19:48:16.197Z" },
+ { url = "https://files.pythonhosted.org/packages/06/98/16c255c909714de1ee04da6ae30f3ee04170f300cdc0dcf57a314ee4816a/regex-2026.7.10-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21150500b970b12202879dfd82e7fd809d8e853140fff84d08e57a90cf1e154e", size = 871203, upload-time = "2026-07-10T19:48:18.12Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/32/423ed27c9bae2092a453e853da2b6628a658d08bb5a6117db8d591183d85/regex-2026.7.10-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a68b637451d64ba30ed8ae125c973fa834cc2d37dfa7f154c2b479015d477ba8", size = 917334, upload-time = "2026-07-10T19:48:19.952Z" },
+ { url = "https://files.pythonhosted.org/packages/73/87/74dac8efb500db31cb000fda6bae2be45fc2fbf1fa9412f445fbb8acbe37/regex-2026.7.10-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e23458d8903e33e7d27196d7a311523dc4e2f4137a5f34e4dbd30c8d37ff33e", size = 816379, upload-time = "2026-07-10T19:48:21.616Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/9f/1859403654e3e030b288f06d49233c6a4f889d62b84c4ef3f3a28653173d/regex-2026.7.10-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae27622c094558e519abf3242cf4272db961d12c5c9a9ffb7a1b44b2627d5c6", size = 785563, upload-time = "2026-07-10T19:48:23.643Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d8/35d30d6bdf1ef6a5430e8982607b3a6db4df1ddedbe001e43435585d88ba/regex-2026.7.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ee877b6d78f9dff1da94fef51ae8cf9cce0967e043fdcc864c40b85cf293c192", size = 801415, upload-time = "2026-07-10T19:48:25.499Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/22/630f31f5ea4826167b2b064d9cac2093a5b3222af380aa432cfe1a5dabcd/regex-2026.7.10-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:2c66a8a1969cfd506d1e203c0005fd0fc3fe6efc83c945606566b6f9611d4851", size = 866560, upload-time = "2026-07-10T19:48:27.789Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/14/f5914a6d9c5bc63b9bed8c9a1169fb0be35dbe05cdc460e17d953031a366/regex-2026.7.10-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2bc350e1c5fa250f30ab0c3e38e5cfdffcd82cb8af224df69955cab4e3003812", size = 772877, upload-time = "2026-07-10T19:48:29.563Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/0f/7c13999eef3e4186f7c79d4950fa56f041bf4de107682fb82c80db605ff9/regex-2026.7.10-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:53f54993b462f3f91fea0f2076b46deb6619a5f45d70dbd1f543f789d8b900ef", size = 856648, upload-time = "2026-07-10T19:48:31.282Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/71/a48e43909b6450fb48fa94e783bef2d9a37179258bc32ef2283955df7be7/regex-2026.7.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cfcec18f7da682c4e2d82112829ce906569cb8d69fa6c26f3a50dfbed5ceb682", size = 803520, upload-time = "2026-07-10T19:48:33.275Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/b8/f037d1bf2c133cb24ceb6e7d81d08417080390eddab6ddfd701aa7091874/regex-2026.7.10-cp313-cp313t-win32.whl", hash = "sha256:a2d6d30be35ddd70ce0f8ee259a4c25f24d6d689a45a5ac440f03e6bcc5a21d1", size = 269168, upload-time = "2026-07-10T19:48:35.353Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/9c/eaac34f8452a838956e7e89852ad049678cdc1af5d14f72d3b3b658b1ea5/regex-2026.7.10-cp313-cp313t-win_amd64.whl", hash = "sha256:c57b6ad3f7a1bdd101b2966f29dc161adf49727b1e8d3e1e89db2eda8a75c344", size = 280004, upload-time = "2026-07-10T19:48:37.106Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/a9/e22e997587bc1d588b0b2cd0572027d39dd3a006216e40bbf0361688c51c/regex-2026.7.10-cp313-cp313t-win_arm64.whl", hash = "sha256:3d8ef9df02c8083c7b4b855e3cb87c8e0ebbcfea088d98c7a886aaefdf88d837", size = 279308, upload-time = "2026-07-10T19:48:38.907Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/4a/a7fa3ada9bd2d2ce20d56dfceec6b2a51afeed9bf3d8286355ceec5f0628/regex-2026.7.10-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:39f81d1fdf594446495f2f4edd8e62d8eda0f7a802c77ac596dc8448ad4cc5ca", size = 497087, upload-time = "2026-07-10T19:48:40.543Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/7e/ca0b1a87192e5828dbc16f16ae6caca9b67f25bf729a3348468a5ff52755/regex-2026.7.10-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:441edc66a54063f8269d1494fc8474d06605e71e8a918f4bcfd079ebda4ce042", size = 297307, upload-time = "2026-07-10T19:48:42.213Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/6c/fb40bb34275d3cd4d7a376d5fb2ea1f0f4a96fd884fa83c0c4ae869001bf/regex-2026.7.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cfeb11990f59e59a0df26c648f0adfcbf27be77241250636f5769eb08db662be", size = 292163, upload-time = "2026-07-10T19:48:43.929Z" },
+ { url = "https://files.pythonhosted.org/packages/18/0b/34cbea16c8fea9a18475a7e8f5837c70af451e738bfeb4eb5b029b7dc07a/regex-2026.7.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:460176b2db044a292baaee6891106566739657877af89a251cded228689015a6", size = 797064, upload-time = "2026-07-10T19:48:45.623Z" },
+ { url = "https://files.pythonhosted.org/packages/87/77/f6805d97f15f5a710bdfd56a768f3468c978239daf9e1b15efd8935e1967/regex-2026.7.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9dc55698737aca028848bde418d6c51d74f2a5fd44872d3c8b56b626729adb89", size = 866155, upload-time = "2026-07-10T19:48:47.589Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/e3/a2a905807bba3bcd90d6ebbb67d27af2adf7d41708175cbc6b956a0c75f1/regex-2026.7.10-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d3e10779f60c000213a5b53f518824bd07b3dc119333b26d70c6be1c27b5c794", size = 911596, upload-time = "2026-07-10T19:48:49.473Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/ca/a3126888b2c6f33c7e29144fedf85f6d5a52a400024fa045ad8fc0550ef1/regex-2026.7.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38a5926601aaccf379512746b86eb0ac1d29121f6c776dac6ac5b31077432f2c", size = 800713, upload-time = "2026-07-10T19:48:51.452Z" },
+ { url = "https://files.pythonhosted.org/packages/66/19/9d252fd969f726c8b56b4bacf910811cc70495a110907b3a7ccb96cd9cad/regex-2026.7.10-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a72ecf5bfd3fc8d57927f7e3ded2487e144472f39010c3acaec3f6f3ff53f361", size = 777286, upload-time = "2026-07-10T19:48:53.443Z" },
+ { url = "https://files.pythonhosted.org/packages/40/7a/5f1bf433fa446ecb3aab87bb402603dc9e171ef8052c1bb8690bb4e255a3/regex-2026.7.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d50714405845c1010c871098558cfe5718fe39d2a2fab5f95c8863caeb7a82b3", size = 785826, upload-time = "2026-07-10T19:48:55.381Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ca/69f3a7281d86f1b592338007f3e535cc219d771448e2b61c0b56e4f9d05b/regex-2026.7.10-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ec1c44cf9bd22079aac37a07cb49a29ced9050ab5bddf24e50aba298f1e34d90", size = 860957, upload-time = "2026-07-10T19:48:57.962Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/11/487ff55c8d515ec9dd60d7ba3c129eeaa9e527358ed9e8a054a9e9430f81/regex-2026.7.10-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9e9aaef25a40d1f1e1bbb1d0eb0190c4a64a7a1750f7eb67b8399bed6f4fd2a6", size = 765959, upload-time = "2026-07-10T19:49:00.27Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e1/fa034e6fa8896a09bd0d5e19c81fdc024411ab37980950a0401dccee8f6d/regex-2026.7.10-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e54e088dc64dd2766014e7cfe5f8bc45399400fd486816e494f93e3f0f55da06", size = 851447, upload-time = "2026-07-10T19:49:02.128Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/a5/b9427ed53b0e14c540dc436d56aaf57a19fb9183c6e7abd66f4b4368fbad/regex-2026.7.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:834271b1ff2cfa1f67fcd65a48bf11d11e9ab837e21bf79ce554efb648599ae8", size = 789418, upload-time = "2026-07-10T19:49:03.949Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/52/aab92420c8aa845c7bcbe68dc65023d4a9e9ea785abf0beb2198f0de5ba1/regex-2026.7.10-cp314-cp314-win32.whl", hash = "sha256:f988a1cec68058f71a38471813fba9e87dffe855582682e8a10e40ece12567a2", size = 272538, upload-time = "2026-07-10T19:49:05.833Z" },
+ { url = "https://files.pythonhosted.org/packages/99/16/5c7050e0ef7dd8889441924ff0a2c33b7f0587c0ccb0953fe7ca997d673b/regex-2026.7.10-cp314-cp314-win_amd64.whl", hash = "sha256:2129e4a5e86f26926982d883dff815056f2e98220fdf630e59f961b578a26c43", size = 280796, upload-time = "2026-07-10T19:49:07.593Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/1a/4f6099d2ba271502fdb97e697bae2ed0213c0d87f2273fe7d21e2e401d12/regex-2026.7.10-cp314-cp314-win_arm64.whl", hash = "sha256:9cd5b6805396157b4cf993a6940cbb8663161f29b4df2458c1c9991f099299c5", size = 281017, upload-time = "2026-07-10T19:49:09.767Z" },
+ { url = "https://files.pythonhosted.org/packages/19/02/4061fc71f64703e0df61e782c2894c3fbc089d277767eff6e16099581c73/regex-2026.7.10-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:103e8f3acc3dcede88c0331c8612766bdcfc47c9250c5477f0e10e0550b9da49", size = 501467, upload-time = "2026-07-10T19:49:11.952Z" },
+ { url = "https://files.pythonhosted.org/packages/73/a5/8d42b2f3fd672908a05582effd0f88438bf9bb4e8e02d69a62c723e23601/regex-2026.7.10-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:538ddb143f5ca085e372def17ef3ed9d74b50ad7fc431bd85dc50a9af1a7076f", size = 299700, upload-time = "2026-07-10T19:49:14.067Z" },
+ { url = "https://files.pythonhosted.org/packages/65/70/36fa4b46f73d268c0dbe77c40e62da2cd4833ee206d3b2e438c2034e1f36/regex-2026.7.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e3448e86b05ce87d4eb50f9c680860830f3b32493660b39f43957d6263e2eba", size = 294590, upload-time = "2026-07-10T19:49:15.883Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/a7/b6db1823f3a233c2a46f854fdc986f4fd424a84ed557b7751f2998efb266/regex-2026.7.10-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5eab9d3f981c423afd1a61db055cfe83553c3f6455949e334db04722469dd0a2", size = 811925, upload-time = "2026-07-10T19:49:17.97Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/7d/f8bee4c210c42c7e8b952bb9fb7099dd7fb2f4bd0f33d0d65a8ab08aafc0/regex-2026.7.10-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:177f930af3ad72e1045f8877540e0c43a38f7d328cf05f31963d0bd5f7ecf067", size = 871257, upload-time = "2026-07-10T19:49:19.943Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/78/22adf72e614ba0216b996e9aaef5712c23699e360ea127bb3d5ee1a7666f/regex-2026.7.10-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd3b6d97beb39afb412f2c79522b9e099463c31f4c49ab8347c5a2ca3531c478", size = 917551, upload-time = "2026-07-10T19:49:22.069Z" },
+ { url = "https://files.pythonhosted.org/packages/03/f7/ebc15a39e81e6b58da5f913b91fc293a25c6700d353c14d5cd25fc85712a/regex-2026.7.10-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8679f0652a183d93da646fcec8da8228db0be40d1595da37e6d74c2dc8c4713c", size = 816436, upload-time = "2026-07-10T19:49:24.131Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/33/20bc2bdd57f7e0fcc51be37e4c4d1bca7f0b4af8dc0a148c23220e689da8/regex-2026.7.10-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:494b19a5805438aeb582de99f9d97603d8fd48e6f4cc74d0088bb292b4da3b70", size = 785935, upload-time = "2026-07-10T19:49:26.265Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/51/87ff99c849b56309c40214a72b54b0eef320d0516a8a516970cc8be1b725/regex-2026.7.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0911e34151a5429d0325dae538ba9851ec0b62426bdfd613060cda8f1c36ec7f", size = 801494, upload-time = "2026-07-10T19:49:28.493Z" },
+ { url = "https://files.pythonhosted.org/packages/16/11/fde67d49083fef489b7e0f841e2e5736516795b166c9867f05956c1e494b/regex-2026.7.10-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b862572b7a5f5ed47d2ba5921e63bf8d9e3b682f859d8f11e0e5ca46f7e82173", size = 866549, upload-time = "2026-07-10T19:49:30.592Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/b5/31a156c36acf10181d88f55a66c688d5454a344e53ccc03d49f4a48a2297/regex-2026.7.10-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3f361215e000d68a4aff375106637b83c80be36091d83ee5107ad3b32bd73f48", size = 773089, upload-time = "2026-07-10T19:49:32.661Z" },
+ { url = "https://files.pythonhosted.org/packages/27/bb/734e978c904726664df47ae36ce5eca5065de5141185ae46efec063476a2/regex-2026.7.10-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4533af6099543db32ef26abc2b2f824781d4eebb309ab9296150fd1a0c7eb07d", size = 856710, upload-time = "2026-07-10T19:49:35.289Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/e5/dc35cea074dbdcb9776c4b0542a3bc326ff08454af0768ef35f3fc66e7fa/regex-2026.7.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:668ab85105361d0200e3545bec198a1acfc6b0aeb5fff8897647a826e5a171be", size = 803621, upload-time = "2026-07-10T19:49:37.704Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/b2/124564af46bc0b592785610b3985315610af0a07f4cf21fa36e06c2398dd/regex-2026.7.10-cp314-cp314t-win32.whl", hash = "sha256:dd7715817a187edd7e2a2390908757f7ba42148e59cad755fb8ee1160c628eca", size = 274558, upload-time = "2026-07-10T19:49:39.926Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/9c/cd813ce9f3404c0443915175c1e339c5afd8fcda04310102eaf233015eef/regex-2026.7.10-cp314-cp314t-win_amd64.whl", hash = "sha256:78712d4954234df5ca24fdadb65a2ab034213f0cdfde376c272f9fc5e09866bb", size = 283687, upload-time = "2026-07-10T19:49:41.872Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/d3/3dae6a6ce46144940e64425e32b8573a393a009aeaf75fa6752a35399056/regex-2026.7.10-cp314-cp314t-win_arm64.whl", hash = "sha256:749b92640e1970e881fdf22a411d74bf9d049b154f4ef7232eeb9a90dd8be7f3", size = 283377, upload-time = "2026-07-10T19:49:43.985Z" },
+]
+
+[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }