aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--edify/__init__.py2
-rw-r--r--edify/builder/__init__.py2
-rw-r--r--edify/builder/fluent.py (renamed from edify/builder/builder.py)0
-rw-r--r--edify/builder/mixins/captures.py2
-rw-r--r--edify/builder/mixins/chars.py4
-rw-r--r--edify/builder/mixins/groups.py5
-rw-r--r--edify/builder/mixins/subexpression.py2
-rw-r--r--edify/builder/mixins/testing.py6
-rw-r--r--edify/builder/reverse.py102
-rw-r--r--edify/compile/backend.py2
-rw-r--r--edify/elements/walk.py12
-rw-r--r--edify/errors/integration.py33
-rw-r--r--edify/errors/introspect.py6
-rw-r--r--edify/errors/serialize.py29
-rw-r--r--edify/errors/testing.py6
-rw-r--r--edify/integrations/__init__.py3
-rw-r--r--edify/integrations/django.py29
-rw-r--r--edify/integrations/fastapi.py52
-rw-r--r--edify/integrations/pydantic.py61
-rw-r--r--edify/introspect/explain.py6
-rw-r--r--edify/library/__init__.py7
-rw-r--r--edify/library/address/__init__.py2
-rw-r--r--edify/library/address/zip.py (renamed from edify/library/address/zip_code.py)0
-rw-r--r--edify/library/auth/password.py3
-rw-r--r--edify/library/color/__init__.py2
-rw-r--r--edify/library/color/any.py (renamed from edify/library/color/color.py)0
-rw-r--r--edify/library/medical/__init__.py2
-rw-r--r--edify/library/medical/any.py (renamed from edify/library/medical/medical.py)0
-rw-r--r--edify/library/temporal/__init__.py2
-rw-r--r--edify/library/temporal/iso.py (renamed from edify/library/temporal/iso_date.py)0
-rw-r--r--edify/pattern/composition.py5
-rw-r--r--edify/pattern/factories/__init__.py2
-rw-r--r--edify/pattern/factories/groups.py3
-rw-r--r--edify/result/match.py82
-rw-r--r--edify/result/regex.py5
-rw-r--r--edify/serialize/dump.py8
-rw-r--r--edify/serialize/load.py33
-rw-r--r--pyproject.toml8
-rw-r--r--tests/builder/cache.test.py58
-rw-r--r--tests/builder/cold_warm.test.py69
-rw-r--r--tests/builder/flags.test.py31
-rw-r--r--tests/builder/lookbehind.test.py6
-rw-r--r--tests/builder/ordering.test.py (renamed from tests/builder/insertion_order.test.py)0
-rw-r--r--tests/builder/passthrough.test.py7
-rw-r--r--tests/builder/validation.test.py8
-rw-r--r--tests/compile/redos.test.py12
-rw-r--r--tests/discipline/coverage.test.py3
-rw-r--r--tests/discipline/orphans.test.py6
-rw-r--r--tests/docs/snapshots.test.py6
-rw-r--r--tests/errors/frames.test.py5
-rw-r--r--tests/errors/generic.test.py (renamed from tests/errors/errors.test.py)0
-rw-r--r--tests/fixtures/fixtures.test.py (renamed from tests/fixtures/edge_cases.test.py)3
-rw-r--r--tests/integrations/django.test.py26
-rw-r--r--tests/integrations/fastapi.test.py46
-rw-r--r--tests/integrations/pydantic.test.py55
-rw-r--r--tests/property/emitted.test.py3
-rw-r--r--tests/result/match.test.py86
-rw-r--r--tests/serialize/errors.test.py5
-rw-r--r--tests/testing/snapshots.test.py3
59 files changed, 534 insertions, 432 deletions
diff --git a/edify/__init__.py b/edify/__init__.py
index 48387a6..11f94b2 100644
--- a/edify/__init__.py
+++ b/edify/__init__.py
@@ -1,6 +1,6 @@
import importlib.metadata
-from edify.builder.builder import RegexBuilder
+from edify.builder.fluent import RegexBuilder
from edify.errors.base import EdifyError
from edify.errors.syntax import EdifySyntaxError
from edify.pattern.anchors import END, START
diff --git a/edify/builder/__init__.py b/edify/builder/__init__.py
index ed3e1ce..58c8321 100644
--- a/edify/builder/__init__.py
+++ b/edify/builder/__init__.py
@@ -1,3 +1,3 @@
-from edify.builder.builder import RegexBuilder
+from edify.builder.fluent import RegexBuilder
__all__ = ["RegexBuilder"]
diff --git a/edify/builder/builder.py b/edify/builder/fluent.py
index cde72d9..cde72d9 100644
--- a/edify/builder/builder.py
+++ b/edify/builder/fluent.py
diff --git a/edify/builder/mixins/captures.py b/edify/builder/mixins/captures.py
index 4ba2fec..5841b95 100644
--- a/edify/builder/mixins/captures.py
+++ b/edify/builder/mixins/captures.py
@@ -62,7 +62,7 @@ class CapturesMixin(BuilderProtocol):
return self._with_state(new_state)
-def _validate_new_named_group(name: object, existing_names: tuple[str, ...]) -> None:
+def _validate_new_named_group(name: str, existing_names: tuple[str, ...]) -> None:
"""Raise the appropriate naming error if ``name`` cannot be declared as a new named group."""
if not isinstance(name, str):
actual_type_name = type(name).__name__
diff --git a/edify/builder/mixins/chars.py b/edify/builder/mixins/chars.py
index a491b3b..570493d 100644
--- a/edify/builder/mixins/chars.py
+++ b/edify/builder/mixins/chars.py
@@ -93,7 +93,7 @@ class CharsMixin(BuilderProtocol):
return self._with_state(new_state)
-def _ensure_is_string(label: str, value: object) -> None:
+def _ensure_is_string(label: str, value: str) -> None:
"""Raise :class:`MustBeAStringError` when ``value`` is not a string."""
if isinstance(value, str):
return
@@ -108,7 +108,7 @@ def _ensure_non_empty(label: str, value: str) -> None:
raise MustBeOneCharacterError(label)
-def _ensure_single_character(label: str, value: object) -> None:
+def _ensure_single_character(label: str, value: str) -> None:
"""Raise :class:`MustBeSingleCharacterError` when ``value`` is not a length-1 string."""
if isinstance(value, str) and len(value) == 1:
return
diff --git a/edify/builder/mixins/groups.py b/edify/builder/mixins/groups.py
index 7151f1f..ff14140 100644
--- a/edify/builder/mixins/groups.py
+++ b/edify/builder/mixins/groups.py
@@ -67,7 +67,8 @@ def _open_frame(builder: _TBuilder, type_node: BaseElement) -> _TBuilder:
def _add_literal_alternation(builder: _TBuilder, literals: tuple[str, ...]) -> _TBuilder:
"""Append a single :class:`AnyOfElement` built from ``literals`` to the top frame."""
- children = tuple(_literal_to_element(literal) for literal in literals)
+ child_elements = [_literal_to_element(literal) for literal in literals]
+ children = tuple(child_elements)
element = AnyOfElement(children=children)
new_state = builder._state.with_element_added_to_top(element)
return builder._with_state(new_state)
@@ -83,7 +84,7 @@ def _literal_to_element(literal: str) -> CharElement | StringElement:
return StringElement(value=escaped)
-def _ensure_is_string(label: str, value: object) -> None:
+def _ensure_is_string(label: str, value: str) -> None:
"""Raise :class:`MustBeAStringError` when ``value`` is not a string."""
if isinstance(value, str):
return
diff --git a/edify/builder/mixins/subexpression.py b/edify/builder/mixins/subexpression.py
index 0ba96f5..bfa006e 100644
--- a/edify/builder/mixins/subexpression.py
+++ b/edify/builder/mixins/subexpression.py
@@ -80,7 +80,7 @@ class SubexpressionMixin(BuilderProtocol):
return self._with_state(state_with_flags)
-def _ensure_is_builder(expression: object) -> None:
+def _ensure_is_builder(expression: BuilderProtocol) -> None:
"""Raise :class:`MustBeInstanceError` when ``expression`` is not a builder."""
if isinstance(expression, BuilderProtocol):
return
diff --git a/edify/builder/mixins/testing.py b/edify/builder/mixins/testing.py
index 904721f..ccf84fd 100644
--- a/edify/builder/mixins/testing.py
+++ b/edify/builder/mixins/testing.py
@@ -24,7 +24,8 @@ class TestingMixin(BuilderProtocol):
"""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)
+ rejected_items = [item for item in input_tuple if compiled.search(item) is None]
+ missing = tuple(rejected_items)
if missing:
raise PatternDidNotMatchInputsError(compiled.source, missing)
return self
@@ -33,7 +34,8 @@ class TestingMixin(BuilderProtocol):
"""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)
+ matched_items = [item for item in input_tuple if compiled.search(item) is not None]
+ matched = tuple(matched_items)
if matched:
raise PatternMatchedRejectedInputsError(compiled.source, matched)
return self
diff --git a/edify/builder/reverse.py b/edify/builder/reverse.py
index d7526c7..d02977a 100644
--- a/edify/builder/reverse.py
+++ b/edify/builder/reverse.py
@@ -5,11 +5,29 @@ from __future__ import annotations
import re
import re._constants as sre_constants
import re._parser as sre_parser
-from typing import Any, cast
+from typing import TypeAlias, cast
-import edify.builder.builder as builder_module
+import edify.builder.fluent as builder_module
-_ANCHOR_METHOD_BY_CONSTANT = {
+_SreArgument: TypeAlias = (
+ int
+ | tuple[int, int]
+ | tuple[int, int, "_SrePattern"]
+ | tuple[int | None, int, int, "_SrePattern"]
+ | tuple[None, list["_SrePattern"]]
+ | tuple[int, "_SrePattern"]
+ | list["_SreNode"]
+ | None
+)
+"""Union of every argument shape :mod:`re._parser` emits on the second slot of a node."""
+
+_SreNode: TypeAlias = tuple[int, _SreArgument]
+"""One ``(opcode, argument)`` node produced by :func:`re._parser.parse`."""
+
+_SrePattern: TypeAlias = list[_SreNode]
+"""Sequence of :data:`_SreNode` items."""
+
+_ANCHOR_METHOD_BY_CONSTANT: dict[int, str] = {
sre_constants.AT_BEGINNING: "start_of_input",
sre_constants.AT_BEGINNING_STRING: "start_of_input",
sre_constants.AT_END: "end_of_input",
@@ -18,7 +36,7 @@ _ANCHOR_METHOD_BY_CONSTANT = {
sre_constants.AT_NON_BOUNDARY: "non_word_boundary",
}
-_CATEGORY_METHOD_BY_CONSTANT = {
+_CATEGORY_METHOD_BY_CONSTANT: dict[int, str] = {
sre_constants.CATEGORY_DIGIT: "digit",
sre_constants.CATEGORY_NOT_DIGIT: "non_digit",
sre_constants.CATEGORY_WORD: "word",
@@ -41,10 +59,10 @@ class UnsupportedReverseParseError(ValueError):
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)
+ parsed_tree = sre_parser.parse(pattern_text)
+ node_list = cast(_SrePattern, list(parsed_tree))
name_by_number = _name_by_group_number(pattern_text)
- node_list: list[Any] = list(parsed_tree)
- empty_builder: builder_module.RegexBuilder = builder_module.RegexBuilder()
+ empty_builder = builder_module.RegexBuilder()
return _translate_sequence(empty_builder, node_list, name_by_number)
@@ -55,7 +73,7 @@ def _name_by_group_number(pattern_text: str) -> dict[int, str]:
def _translate_sequence(
builder: builder_module.RegexBuilder,
- nodes: list[Any],
+ nodes: _SrePattern,
names: dict[int, str],
) -> builder_module.RegexBuilder:
current = builder
@@ -63,7 +81,7 @@ def _translate_sequence(
for node in nodes:
opcode, argument = node
if opcode == sre_constants.LITERAL:
- literal_run.append(chr(argument))
+ literal_run.append(chr(cast(int, argument)))
continue
if literal_run:
current = _emit_string(current, literal_run)
@@ -85,35 +103,51 @@ def _emit_string(
def _translate_node(
builder: builder_module.RegexBuilder,
- opcode: Any,
- argument: Any,
+ opcode: int,
+ argument: _SreArgument,
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())
+ return _translate_anchor(builder, cast(int, argument))
if opcode == sre_constants.IN:
- return _translate_character_class(builder, list(argument))
+ return _translate_character_class(builder, cast(_SrePattern, argument))
if opcode == sre_constants.MAX_REPEAT:
- return _translate_repeat(builder, argument, names, lazy=False)
+ return _translate_repeat(
+ builder, cast("tuple[int, int, _SrePattern]", argument), names, lazy=False
+ )
if opcode == sre_constants.MIN_REPEAT:
- return _translate_repeat(builder, argument, names, lazy=True)
+ return _translate_repeat(
+ builder, cast("tuple[int, int, _SrePattern]", argument), names, lazy=True
+ )
if opcode == sre_constants.SUBPATTERN:
- return _translate_subpattern(builder, argument, names)
+ return _translate_subpattern(
+ builder, cast("tuple[int | None, int, int, _SrePattern]", argument), names
+ )
if opcode == sre_constants.BRANCH:
- return _translate_branch(builder, argument)
+ return _translate_branch(builder, cast("tuple[None, list[_SrePattern]]", argument))
if opcode == sre_constants.ASSERT:
- return _translate_lookaround(builder, argument, names, negative=False)
+ return _translate_lookaround(
+ builder, cast("tuple[int, _SrePattern]", argument), names, negative=False
+ )
if opcode == sre_constants.ASSERT_NOT:
- return _translate_lookaround(builder, argument, names, negative=True)
+ return _translate_lookaround(
+ builder, cast("tuple[int, _SrePattern]", argument), names, negative=True
+ )
raise UnsupportedReverseParseError(str(opcode))
+def _translate_anchor(
+ builder: builder_module.RegexBuilder, argument: int
+) -> builder_module.RegexBuilder:
+ method_name = _ANCHOR_METHOD_BY_CONSTANT[argument]
+ anchor_method = getattr(builder, method_name)
+ return cast(builder_module.RegexBuilder, anchor_method())
+
+
def _translate_character_class(
- builder: builder_module.RegexBuilder, members: list[Any]
+ builder: builder_module.RegexBuilder, members: _SrePattern
) -> builder_module.RegexBuilder:
if len(members) == 1:
return _translate_single_class_member(builder, members[0])
@@ -121,7 +155,7 @@ def _translate_character_class(
for member in members:
opcode, argument = member
if opcode == sre_constants.LITERAL:
- literal_chars.append(chr(argument))
+ literal_chars.append(chr(cast(int, argument)))
continue
raise UnsupportedReverseParseError(f"character-class member {member!r}")
joined_chars = "".join(literal_chars)
@@ -129,20 +163,20 @@ def _translate_character_class(
def _translate_single_class_member(
- builder: builder_module.RegexBuilder, member: tuple[Any, Any]
+ builder: builder_module.RegexBuilder, member: tuple[int, _SreArgument]
) -> builder_module.RegexBuilder:
opcode, argument = member
if opcode == sre_constants.RANGE:
- start_codepoint, end_codepoint = argument
+ start_codepoint, end_codepoint = cast("tuple[int, int]", argument)
return builder.range(chr(start_codepoint), chr(end_codepoint))
- method_name = _CATEGORY_METHOD_BY_CONSTANT[argument]
+ method_name = _CATEGORY_METHOD_BY_CONSTANT[cast(int, argument)]
category_method = getattr(builder, method_name)
return cast(builder_module.RegexBuilder, category_method())
def _translate_repeat(
builder: builder_module.RegexBuilder,
- argument: Any,
+ argument: tuple[int, int, _SrePattern],
names: dict[int, str],
lazy: bool,
) -> builder_module.RegexBuilder:
@@ -171,11 +205,13 @@ def _apply_quantifier(
def _translate_subpattern(
- builder: builder_module.RegexBuilder, argument: Any, names: dict[int, str]
+ builder: builder_module.RegexBuilder,
+ argument: tuple[int | None, int, int, _SrePattern],
+ names: dict[int, str],
) -> builder_module.RegexBuilder:
group_number, _in_flags, _out_flags, body = argument
body_nodes = list(body)
- if group_number in names:
+ if group_number is not None and group_number in names:
opened = builder.named_capture(names[group_number])
else:
opened = builder.capture()
@@ -184,7 +220,7 @@ def _translate_subpattern(
def _translate_branch(
- builder: builder_module.RegexBuilder, argument: Any
+ builder: builder_module.RegexBuilder, argument: tuple[None, list[_SrePattern]]
) -> builder_module.RegexBuilder:
_leading, branches = argument
literal_branches: list[str] = []
@@ -197,19 +233,19 @@ def _translate_branch(
return builder.any_of(*literal_branches)
-def _branch_as_literal_string(nodes: list[Any]) -> str | None:
+def _branch_as_literal_string(nodes: _SrePattern) -> str | None:
characters: list[str] = []
for node in nodes:
opcode, argument = node
if opcode != sre_constants.LITERAL:
return None
- characters.append(chr(argument))
+ characters.append(chr(cast(int, argument)))
return "".join(characters)
def _translate_lookaround(
builder: builder_module.RegexBuilder,
- argument: Any,
+ argument: tuple[int, _SrePattern],
names: dict[int, str],
negative: bool,
) -> builder_module.RegexBuilder:
diff --git a/edify/compile/backend.py b/edify/compile/backend.py
index d391375..d2bae12 100644
--- a/edify/compile/backend.py
+++ b/edify/compile/backend.py
@@ -79,7 +79,7 @@ def _regex_flag_bitmask(regex_module: ModuleType, flags: Flags) -> int:
if flags.ascii_only:
bitmask = bitmask | regex_module.A
if flags.debug:
- bitmask = bitmask | regex_module.DEBUG # pragma: no cover - regex DEBUG crashes PyPy
+ bitmask = bitmask | regex_module.DEBUG
if flags.ignore_case:
bitmask = bitmask | regex_module.I
if flags.multiline:
diff --git a/edify/elements/walk.py b/edify/elements/walk.py
index 5ae5fc5..9cb6801 100644
--- a/edify/elements/walk.py
+++ b/edify/elements/walk.py
@@ -32,11 +32,11 @@ def walk_elements(roots: Iterable[BaseElement]) -> Iterator[BaseElement]:
def _walk_single(element: BaseElement) -> Iterator[BaseElement]:
yield element
for spec in fields(element):
- value: object = getattr(element, spec.name)
- if isinstance(value, BaseElement):
- yield from _walk_single(value)
+ raw_value = getattr(element, spec.name)
+ typed_value = cast("BaseElement | tuple[BaseElement, ...] | str | int", raw_value)
+ if isinstance(typed_value, BaseElement):
+ yield from _walk_single(typed_value)
continue
- if isinstance(value, tuple):
- narrowed_tuple = cast(tuple[BaseElement, ...], value)
- for child in narrowed_tuple:
+ if isinstance(typed_value, tuple):
+ for child in typed_value:
yield from _walk_single(child)
diff --git a/edify/errors/integration.py b/edify/errors/integration.py
index a51f223..ca668d7 100644
--- a/edify/errors/integration.py
+++ b/edify/errors/integration.py
@@ -1,31 +1,24 @@
-"""Exception raised when an integration module needs a framework the user has not installed."""
+"""Exceptions raised by :mod:`edify.integrations` helpers."""
from __future__ import annotations
-from edify.errors.formatting import compose_annotated_message
-from edify.errors.syntax import EdifySyntaxError
+class PatternDidNotMatchError(ValueError):
+ """Raised inside integration validators when a value fails to match the wrapped pattern.
-class MissingIntegrationDependencyError(EdifySyntaxError):
- """Raised when an ``edify.integrations.<framework>`` helper runs without the framework.
+ Inherits :class:`ValueError` so framework validation layers (pydantic, django, etc.)
+ convert it into their own ``ValidationError`` shape without an extra try/except.
Args:
- framework: The framework name as it appears in the extras marker
- (``"pydantic"``, ``"fastapi"``, ``"django"``).
+ source: The regex string the value was tested against.
+ value: The rejected input.
"""
- def __init__(self, framework: str) -> None:
- message = compose_annotated_message(
- summary=(
- f"the edify.integrations.{framework} module requires the {framework!r} "
- "package, which is not installed"
- ),
- trigger_hint=f"edify.integrations.{framework} helper called here",
- note=(
- f"the {framework!r} integration ships as an opt-in extra so pip install "
- "edify stays dependency-free."
- ),
- help_line=(f"help: install the extra with `pip install edify[{framework}]` and retry."),
+ def __init__(self, source: str, value: str) -> None:
+ message = (
+ f"input {value!r} does not match the pattern with source {source!r}; "
+ "adjust the value to match the pattern or select a pattern that accepts it."
)
super().__init__(message)
- self.framework = framework
+ self.source = source
+ self.value = value
diff --git a/edify/errors/introspect.py b/edify/errors/introspect.py
index 0401924..8b7e5b0 100644
--- a/edify/errors/introspect.py
+++ b/edify/errors/introspect.py
@@ -25,7 +25,8 @@ class UnsupportedVisualizationFormatError(EdifySyntaxError):
trigger_block = ""
if caller_context is not None:
trigger_block = format_pointer_block(caller_context, "unknown format here")
- supported = ", ".join(f"'{fmt}'" for fmt in _SUPPORTED_FORMATS)
+ quoted_formats = [f"'{fmt}'" for fmt in _SUPPORTED_FORMATS]
+ supported = ", ".join(quoted_formats)
note_line = format_note_line(
f"Regex.visualize accepts one of {supported}; received {received_format!r}."
)
@@ -52,7 +53,8 @@ class UnsupportedVisualizationEngineError(EdifySyntaxError):
if caller_context is not None:
trigger_block = format_pointer_block(caller_context, "engine does not match format")
supported = _SUPPORTED_ENGINES_BY_FORMAT.get(received_format, ())
- supported_list = ", ".join(f"'{engine}'" for engine in supported) or "(none)"
+ quoted_engines = [f"'{engine}'" for engine in supported]
+ supported_list = ", ".join(quoted_engines) or "(none)"
note_line = format_note_line(
f"format={received_format!r} pairs with engine in {supported_list}; "
f"received engine={received_engine!r}."
diff --git a/edify/errors/serialize.py b/edify/errors/serialize.py
index a5b9af4..b38676c 100644
--- a/edify/errors/serialize.py
+++ b/edify/errors/serialize.py
@@ -4,7 +4,6 @@ from __future__ import annotations
from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
-from edify.serialize.types import JSONValue
class MissingSchemaKeyError(EdifySyntaxError):
@@ -38,7 +37,9 @@ class IncompatibleSchemaVersionError(EdifySyntaxError):
supported_version: The version this build emits and accepts.
"""
- def __init__(self, seen_version: JSONValue, supported_version: int) -> None:
+ def __init__(
+ self, seen_version: str | int | float | bool | None, supported_version: int
+ ) -> None:
message = compose_annotated_message(
summary=(
f"canonical dict declares schema version {seen_version!r}, but this build "
@@ -55,6 +56,30 @@ class IncompatibleSchemaVersionError(EdifySyntaxError):
super().__init__(message)
+class NonObjectJSONPayloadError(EdifySyntaxError):
+ """Raised by :meth:`Pattern.from_json` when the parsed JSON is not a top-level object.
+
+ Args:
+ actual_type_name: The Python type name of the value ``json.loads`` produced.
+ """
+
+ def __init__(self, actual_type_name: str) -> None:
+ message = compose_annotated_message(
+ summary=(f"canonical JSON payload must be an object; got {actual_type_name}"),
+ trigger_hint="Pattern.from_json called here",
+ note=(
+ "the canonical serialization format wraps every payload in a top-level "
+ "JSON object; scalars, arrays, and other kinds are not accepted."
+ ),
+ help_line=(
+ "help: wrap the payload in an object with the canonical schema keys, or "
+ "use Pattern.from_dict with the object directly."
+ ),
+ )
+ super().__init__(message)
+ self.actual_type_name = actual_type_name
+
+
class UnknownElementKindError(EdifySyntaxError):
"""Raised when a nested dict declares a ``kind`` string not in the registry.
diff --git a/edify/errors/testing.py b/edify/errors/testing.py
index f02a64a..9668a0c 100644
--- a/edify/errors/testing.py
+++ b/edify/errors/testing.py
@@ -9,7 +9,8 @@ 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)
+ rendered_items = [repr(item) for item in missing_matches]
+ listed = ", ".join(rendered_items)
summary = (
f"pattern {pattern_source!r} did not match {len(missing_matches)} expected "
f"input(s): {listed}"
@@ -34,7 +35,8 @@ 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)
+ rendered_items = [repr(item) for item in unexpected_matches]
+ listed = ", ".join(rendered_items)
summary = (
f"pattern {pattern_source!r} matched {len(unexpected_matches)} input(s) that "
f"were expected to be rejected: {listed}"
diff --git a/edify/integrations/__init__.py b/edify/integrations/__init__.py
index dc28712..e69de29 100644
--- a/edify/integrations/__init__.py
+++ b/edify/integrations/__init__.py
@@ -1,3 +0,0 @@
-from edify.integrations import django, fastapi, pydantic
-
-__all__ = ["django", "fastapi", "pydantic"]
diff --git a/edify/integrations/django.py b/edify/integrations/django.py
index af2d615..be2e966 100644
--- a/edify/integrations/django.py
+++ b/edify/integrations/django.py
@@ -1,21 +1,21 @@
"""Django integration — validate model / form fields against an edify :class:`Pattern`.
-Django is imported lazily inside every public helper so ``import edify.integrations.django``
-succeeds even when the framework is not installed. Calls raise
-:class:`edify.errors.integration.MissingIntegrationDependencyError` with an actionable
-message when the framework is missing.
+Requires the ``django`` extra: ``pip install edify[django]``. Importing this module
+without the framework installed raises :class:`ImportError` at import time.
"""
from __future__ import annotations
-from importlib import import_module
-from typing import Any
+import django.core.validators
-from edify.errors.integration import MissingIntegrationDependencyError
from edify.pattern.composition import Pattern
-def pattern_validator(pattern: Pattern, message: str | None = None, code: str = "invalid") -> Any:
+def pattern_validator(
+ pattern: Pattern,
+ message: str | None = None,
+ code: str = "invalid",
+) -> django.core.validators.RegexValidator:
"""Return a :class:`django.core.validators.RegexValidator` pinned to ``pattern``.
Args:
@@ -23,23 +23,12 @@ def pattern_validator(pattern: Pattern, message: str | None = None, code: str =
message: The error message the validator raises on rejection. Defaults to a
message that reproduces the pattern's regex string.
code: The Django validation-error code the validator raises on rejection.
-
- Raises:
- MissingIntegrationDependencyError: when the ``django`` package is not installed.
"""
- validators_module = _load_django_validators_module()
resolved_message = (
message if message is not None else f"value does not match {pattern.to_regex_string()}"
)
- return validators_module.RegexValidator(
+ return django.core.validators.RegexValidator(
regex=pattern.to_regex_string(),
message=resolved_message,
code=code,
)
-
-
-def _load_django_validators_module() -> Any:
- try:
- return import_module("django.core.validators")
- except ImportError as reason:
- raise MissingIntegrationDependencyError("django") from reason
diff --git a/edify/integrations/fastapi.py b/edify/integrations/fastapi.py
index 300899a..3a8fe49 100644
--- a/edify/integrations/fastapi.py
+++ b/edify/integrations/fastapi.py
@@ -1,52 +1,44 @@
"""FastAPI integration — validate request paths and queries against an edify :class:`Pattern`.
-FastAPI is imported lazily inside every public helper so ``import edify.integrations.fastapi``
-succeeds even when the framework is not installed. Calls raise
-:class:`edify.errors.integration.MissingIntegrationDependencyError` with an actionable
-message when the framework is missing.
+Requires the ``fastapi`` extra: ``pip install edify[fastapi]``. Importing this module
+without the framework installed raises :class:`ImportError` at import time.
"""
from __future__ import annotations
-from importlib import import_module
-from typing import Any
+from typing import cast
+
+import fastapi
+import fastapi.params
-from edify.errors.integration import MissingIntegrationDependencyError
from edify.pattern.composition import Pattern
-def pattern_query(pattern: Pattern, **query_kwargs: Any) -> Any:
+def pattern_query(
+ pattern: Pattern,
+ default: str | None = None,
+ description: str | None = None,
+) -> fastapi.params.Query:
"""Return a :class:`fastapi.Query` value pinned to ``pattern``.
Args:
pattern: The :class:`edify.Pattern` a query parameter must match anywhere in.
- query_kwargs: Extra keyword arguments forwarded to :func:`fastapi.Query`.
-
- Raises:
- MissingIntegrationDependencyError: when the ``fastapi`` package is not installed.
+ default: Optional default value; when None the parameter is required.
+ description: Optional OpenAPI description forwarded to :func:`fastapi.Query`.
"""
- fastapi_module = _load_fastapi_module()
- default_value = query_kwargs.pop("default", ...)
- return fastapi_module.Query(default_value, pattern=pattern.to_regex_string(), **query_kwargs)
+ pattern_source = pattern.to_regex_string()
+ default_value = default if default is not None else ...
+ query = fastapi.Query(default_value, pattern=pattern_source, description=description)
+ return cast(fastapi.params.Query, query)
-def pattern_path(pattern: Pattern, **path_kwargs: Any) -> Any:
+def pattern_path(pattern: Pattern, description: str | None = None) -> fastapi.params.Path:
"""Return a :class:`fastapi.Path` value pinned to ``pattern``.
Args:
pattern: The :class:`edify.Pattern` a path parameter must match anywhere in.
- path_kwargs: Extra keyword arguments forwarded to :func:`fastapi.Path`.
-
- Raises:
- MissingIntegrationDependencyError: when the ``fastapi`` package is not installed.
+ description: Optional OpenAPI description forwarded to :func:`fastapi.Path`.
"""
- fastapi_module = _load_fastapi_module()
- default_value = path_kwargs.pop("default", ...)
- return fastapi_module.Path(default_value, pattern=pattern.to_regex_string(), **path_kwargs)
-
-
-def _load_fastapi_module() -> Any:
- try:
- return import_module("fastapi")
- except ImportError as reason:
- raise MissingIntegrationDependencyError("fastapi") from reason
+ pattern_source = pattern.to_regex_string()
+ path = fastapi.Path(..., pattern=pattern_source, description=description)
+ return cast(fastapi.params.Path, path)
diff --git a/edify/integrations/pydantic.py b/edify/integrations/pydantic.py
index 21d173c..8340695 100644
--- a/edify/integrations/pydantic.py
+++ b/edify/integrations/pydantic.py
@@ -1,18 +1,26 @@
"""Pydantic integration — validate string fields against an edify :class:`Pattern`.
-Pydantic is imported lazily inside every public helper so ``import edify.integrations.pydantic``
-succeeds even when the framework is not installed. Calls raise
-:class:`edify.errors.integration.MissingIntegrationDependencyError` with an actionable
-message when the framework is missing.
+Requires the ``pydantic`` extra: ``pip install edify[pydantic]``. Importing this module
+without the framework installed raises :class:`ImportError` at import time.
+
+Typical usage:
+
+.. code-block:: python
+
+ from typing import Annotated
+ from pydantic import AfterValidator, BaseModel
+ from edify.integrations.pydantic import pattern_validator
+ from edify.library import email
+
+ class Contact(BaseModel):
+ address: Annotated[str, AfterValidator(pattern_validator(email))]
"""
from __future__ import annotations
from collections.abc import Callable
-from importlib import import_module
-from typing import Any
-from edify.errors.integration import MissingIntegrationDependencyError
+from edify.errors.integration import PatternDidNotMatchError
from edify.pattern.composition import Pattern
@@ -20,48 +28,17 @@ def pattern_validator(pattern: Pattern) -> Callable[[str], str]:
"""Return a Pydantic-compatible validator callable for ``pattern``.
The returned callable accepts a string and returns it unchanged when the
- pattern matches; otherwise raises :class:`ValueError` with a message
- Pydantic embeds in its :class:`ValidationError`.
+ pattern matches; otherwise raises :class:`edify.errors.integration.PatternDidNotMatchError`
+ (a :class:`ValueError` subclass) that Pydantic embeds in its :class:`ValidationError`.
Args:
pattern: The :class:`edify.Pattern` the field must match anywhere in.
-
- Raises:
- MissingIntegrationDependencyError: when the ``pydantic`` package is not installed.
"""
- _ensure_pydantic_is_installed()
+ pattern_source = pattern.to_regex_string()
def validate(value: str) -> str:
if pattern(value):
return value
- raise ValueError(f"value does not match {pattern.to_regex_string()}")
+ raise PatternDidNotMatchError(pattern_source, value)
return validate
-
-
-def pattern_field(pattern: Pattern) -> Any:
- """Return an ``Annotated[str, AfterValidator(...)]`` type for use in Pydantic models.
-
- Args:
- pattern: The :class:`edify.Pattern` the field must match anywhere in.
-
- Raises:
- MissingIntegrationDependencyError: when the ``pydantic`` package is not installed.
- """
- pydantic_module = _load_pydantic_module()
- validator_callable = pattern_validator(pattern)
- after_validator = pydantic_module.AfterValidator(validator_callable)
- from typing import Annotated
-
- return Annotated[str, after_validator]
-
-
-def _load_pydantic_module() -> Any:
- try:
- return import_module("pydantic")
- except ImportError as reason:
- raise MissingIntegrationDependencyError("pydantic") from reason
-
-
-def _ensure_pydantic_is_installed() -> None:
- _load_pydantic_module()
diff --git a/edify/introspect/explain.py b/edify/introspect/explain.py
index 2e903f4..bb9b6d1 100644
--- a/edify/introspect/explain.py
+++ b/edify/introspect/explain.py
@@ -513,14 +513,16 @@ def _example_for(element: BaseElement, alternative_index: int) -> str:
if isinstance(
element, CaptureElement | NamedCaptureElement | GroupElement | SubexpressionElement
):
- return "".join(_example_for(child, alternative_index) for child in element.children)
+ rendered_children = [_example_for(child, alternative_index) for child in element.children]
+ return "".join(rendered_children)
if isinstance(element, AnyOfElement):
if not element.children:
return ""
chosen = element.children[alternative_index % len(element.children)]
return _example_for(chosen, alternative_index)
if isinstance(element, AssertAheadElement | AssertBehindElement):
- return "".join(_example_for(child, alternative_index) for child in element.children)
+ rendered_children = [_example_for(child, alternative_index) for child in element.children]
+ return "".join(rendered_children)
if isinstance(element, AssertNotAheadElement | AssertNotBehindElement):
return ""
if isinstance(element, BackReferenceElement | NamedBackReferenceElement):
diff --git a/edify/library/__init__.py b/edify/library/__init__.py
index 989b986..48ea507 100644
--- a/edify/library/__init__.py
+++ b/edify/library/__init__.py
@@ -1,10 +1,3 @@
-"""Flat re-export of every validator :class:`Pattern` in :mod:`edify.library`.
-
-Users can import any pattern directly (``from edify.library import uuid``);
-category submodules (``edify.library.identifier.uuid``) remain the canonical
-location for the individual patterns.
-"""
-
from __future__ import annotations
from edify.library.address import (
diff --git a/edify/library/address/__init__.py b/edify/library/address/__init__.py
index d404de4..97e8b31 100644
--- a/edify/library/address/__init__.py
+++ b/edify/library/address/__init__.py
@@ -11,7 +11,7 @@ from edify.library.address.subnet import subnet
from edify.library.address.tld import tld
from edify.library.address.uri import uri
from edify.library.address.url import url
-from edify.library.address.zip_code import zip_code
+from edify.library.address.zip import zip_code
__all__ = [
"cidr",
diff --git a/edify/library/address/zip_code.py b/edify/library/address/zip.py
index 4603724..4603724 100644
--- a/edify/library/address/zip_code.py
+++ b/edify/library/address/zip.py
diff --git a/edify/library/auth/password.py b/edify/library/auth/password.py
index c044864..788506e 100644
--- a/edify/library/auth/password.py
+++ b/edify/library/auth/password.py
@@ -69,7 +69,8 @@ class _PasswordPattern(Pattern):
upper_ok = len(_UPPERCASE_RE.findall(value)) >= effective_min_upper
lower_ok = len(_LOWERCASE_RE.findall(value)) >= effective_min_lower
digit_ok = len(_DIGIT_RE.findall(value)) >= effective_min_digit
- special_count = sum(1 for character in value if character in effective_special)
+ special_flags = [1 for character in value if character in effective_special]
+ special_count = sum(special_flags)
special_ok = special_count >= effective_min_special
return length_ok and upper_ok and lower_ok and digit_ok and special_ok
diff --git a/edify/library/color/__init__.py b/edify/library/color/__init__.py
index a0a4741..5c074a6 100644
--- a/edify/library/color/__init__.py
+++ b/edify/library/color/__init__.py
@@ -1,4 +1,4 @@
-from edify.library.color.color import color
+from edify.library.color.any import color
from edify.library.color.filter import filter
from edify.library.color.gradient import gradient
from edify.library.color.palette import palette
diff --git a/edify/library/color/color.py b/edify/library/color/any.py
index 3102afd..3102afd 100644
--- a/edify/library/color/color.py
+++ b/edify/library/color/any.py
diff --git a/edify/library/medical/__init__.py b/edify/library/medical/__init__.py
index 0d1f735..d92ae10 100644
--- a/edify/library/medical/__init__.py
+++ b/edify/library/medical/__init__.py
@@ -1,6 +1,6 @@
+from edify.library.medical.any import medical
from edify.library.medical.blood import blood
from edify.library.medical.dicom import dicom
from edify.library.medical.dosage import dosage
-from edify.library.medical.medical import medical
__all__ = ["blood", "dicom", "dosage", "medical"]
diff --git a/edify/library/medical/medical.py b/edify/library/medical/any.py
index 97c4d16..97c4d16 100644
--- a/edify/library/medical/medical.py
+++ b/edify/library/medical/any.py
diff --git a/edify/library/temporal/__init__.py b/edify/library/temporal/__init__.py
index 140cbd0..7c1cc00 100644
--- a/edify/library/temporal/__init__.py
+++ b/edify/library/temporal/__init__.py
@@ -4,7 +4,7 @@ from edify.library.temporal.datetime import datetime
from edify.library.temporal.duration import duration
from edify.library.temporal.epoch import epoch
from edify.library.temporal.interval import interval
-from edify.library.temporal.iso_date import iso_date
+from edify.library.temporal.iso import iso_date
from edify.library.temporal.offset import offset
from edify.library.temporal.time import time
from edify.library.temporal.timestamp import timestamp
diff --git a/edify/library/temporal/iso_date.py b/edify/library/temporal/iso.py
index 204d087..204d087 100644
--- a/edify/library/temporal/iso_date.py
+++ b/edify/library/temporal/iso.py
diff --git a/edify/pattern/composition.py b/edify/pattern/composition.py
index aab8738..9d67d94 100644
--- a/edify/pattern/composition.py
+++ b/edify/pattern/composition.py
@@ -19,6 +19,7 @@ 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.errors.serialize import NonObjectJSONPayloadError
from edify.serialize.dump import state_to_dict
from edify.serialize.load import dict_to_state
from edify.serialize.types import JSONValue
@@ -76,7 +77,5 @@ class Pattern(
"""Return a Pattern reconstructed from a canonical JSON string."""
parsed_document: JSONValue = json.loads(blob)
if not isinstance(parsed_document, dict):
- raise TypeError(
- f"canonical JSON payload must be an object; got {type(parsed_document).__name__}"
- )
+ raise NonObjectJSONPayloadError(type(parsed_document).__name__)
return cls.from_dict(parsed_document)
diff --git a/edify/pattern/factories/__init__.py b/edify/pattern/factories/__init__.py
index b261616..4b49e55 100644
--- a/edify/pattern/factories/__init__.py
+++ b/edify/pattern/factories/__init__.py
@@ -1,5 +1,3 @@
-"""Re-exports every functional factory grouped by category."""
-
from __future__ import annotations
from edify.pattern.factories.assertions import (
diff --git a/edify/pattern/factories/groups.py b/edify/pattern/factories/groups.py
index 9520a0d..9d18a7e 100644
--- a/edify/pattern/factories/groups.py
+++ b/edify/pattern/factories/groups.py
@@ -57,7 +57,8 @@ def any_of(*operands: BuilderProtocol) -> Pattern:
"""Return an alternation ``(?:a|b|c)`` across the supplied operands."""
if len(operands) < 2:
raise MustBeAtLeastTwoOperandsError("any_of")
- children = tuple(target_element(operand) for operand in operands)
+ child_elements = [target_element(operand) for operand in operands]
+ children = tuple(child_elements)
return pattern_containing(AnyOfElement(children=children))
diff --git a/edify/result/match.py b/edify/result/match.py
index 247ee77..b4f3caf 100644
--- a/edify/result/match.py
+++ b/edify/result/match.py
@@ -3,8 +3,7 @@
from __future__ import annotations
import re
-from collections.abc import Callable
-from typing import Any
+from typing import cast
class NamedCaptures:
@@ -29,7 +28,13 @@ class NamedCaptures:
class Match:
- """A wrapped :class:`re.Match` with attribute-access for named captures."""
+ """A wrapped :class:`re.Match` with attribute-access for named captures.
+
+ Explicitly forwards every :class:`re.Match` method and attribute users typically
+ reach for, plus a ``captures`` namespace that exposes named groups by attribute
+ access. The underlying :class:`re.Match` stays reachable via :attr:`wrapped`
+ for anything not forwarded.
+ """
def __init__(self, wrapped_match: re.Match[str]) -> None:
self._wrapped_match: re.Match[str] = wrapped_match
@@ -45,14 +50,77 @@ class Match:
"""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."""
+ def group(self, *group_selectors: str | int) -> str | tuple[str | None, ...] | None:
+ """Delegate to :meth:`re.Match.group`."""
+ return self._wrapped_match.group(*group_selectors)
+
+ def groups(self, default: str | None = None) -> tuple[str | None, ...]:
+ """Delegate to :meth:`re.Match.groups`."""
+ if default is None:
+ return self._wrapped_match.groups()
+ return self._wrapped_match.groups(default=default)
+
+ def groupdict(self, default: str | None = None) -> dict[str, str | None]:
+ """Delegate to :meth:`re.Match.groupdict`."""
+ raw_dictionary = self._wrapped_match.groupdict(default=cast(None, default))
+ return raw_dictionary
+
+ def start(self, group_selector: str | int = 0) -> int:
+ """Delegate to :meth:`re.Match.start`."""
+ return self._wrapped_match.start(group_selector)
+
+ def end(self, group_selector: str | int = 0) -> int:
+ """Delegate to :meth:`re.Match.end`."""
+ return self._wrapped_match.end(group_selector)
+
+ def span(self, group_selector: str | int = 0) -> tuple[int, int]:
+ """Delegate to :meth:`re.Match.span`."""
+ return self._wrapped_match.span(group_selector)
+
+ def expand(self, template: str) -> str:
+ """Delegate to :meth:`re.Match.expand`."""
+ return self._wrapped_match.expand(template)
+
+ @property
+ def re(self) -> re.Pattern[str]:
+ """The compiled pattern that produced this match."""
+ return self._wrapped_match.re
+
+ @property
+ def string(self) -> str:
+ """The string the match ran against."""
+ return self._wrapped_match.string
+
+ @property
+ def pos(self) -> int:
+ """Search start position."""
+ return self._wrapped_match.pos
+
+ @property
+ def endpos(self) -> int:
+ """Search end position."""
+ return self._wrapped_match.endpos
+
+ @property
+ def lastindex(self) -> int | None:
+ """Last matched group index, or None."""
+ return self._wrapped_match.lastindex
+
+ @property
+ def lastgroup(self) -> str | None:
+ """Last matched group name, or None."""
+ return self._wrapped_match.lastgroup
+
+ def __getattr__(self, name: str) -> str | None:
+ """Return the named-group substring for ``name`` when it's a declared group."""
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
+ raise AttributeError(
+ f"{type(self).__name__!r} has no attribute {name!r}; "
+ f"declared named groups are {sorted(group_index_map)}"
+ )
def __repr__(self) -> str:
"""Return ``<Match 'span'-'text'>`` for interactive display."""
diff --git a/edify/result/regex.py b/edify/result/regex.py
index d718580..05a04a3 100644
--- a/edify/result/regex.py
+++ b/edify/result/regex.py
@@ -5,7 +5,6 @@ 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
@@ -35,12 +34,12 @@ class Regex:
def __init__(
self,
source: str,
- compiled: Any,
+ compiled: re.Pattern[str],
elements: tuple[BaseElement, ...] = (),
engine: Engine = "re",
) -> None:
self._source: str = source
- self._compiled: re.Pattern[str] = cast(re.Pattern[str], compiled)
+ self._compiled: re.Pattern[str] = compiled
self._elements: tuple[BaseElement, ...] = elements
self._engine: Engine = engine
diff --git a/edify/serialize/dump.py b/edify/serialize/dump.py
index e5f0387..e0a11eb 100644
--- a/edify/serialize/dump.py
+++ b/edify/serialize/dump.py
@@ -43,11 +43,15 @@ def _serialize_field_value(value: _ElementFieldValue) -> JSONValue:
if isinstance(value, BaseElement):
return element_to_dict(value)
if isinstance(value, tuple):
- return [element_to_dict(child) for child in value]
+ serialized_children: list[JSONValue] = [element_to_dict(child) for child in value]
+ return serialized_children
if isinstance(value, str):
return value
return value
def _flags_to_dict(flags: Flags) -> dict[str, JSONValue]:
- return {spec.name: True for spec in fields(flags) if getattr(flags, spec.name)}
+ active_flags: dict[str, JSONValue] = {
+ spec.name: True for spec in fields(flags) if getattr(flags, spec.name)
+ }
+ return active_flags
diff --git a/edify/serialize/load.py b/edify/serialize/load.py
index b897f25..6404f39 100644
--- a/edify/serialize/load.py
+++ b/edify/serialize/load.py
@@ -32,7 +32,7 @@ def dict_to_element(tree: dict[str, JSONValue]) -> BaseElement:
except KeyError as reason:
raise UnknownElementKindError(kind_value) from reason
known_field_names = {spec.name for spec in fields(element_class)}
- constructor_kwargs: dict[str, object] = {}
+ constructor_kwargs: dict[str, BaseElement | tuple[BaseElement, ...] | JSONValue] = {}
for name, raw_value in tree.items():
if name == "kind":
continue
@@ -53,8 +53,10 @@ def dict_to_state(document: dict[str, JSONValue]) -> BuilderState:
reconstructed_flags = _flags_from_document(document)
named_groups = _collect_named_groups(root_children)
total_captures = _count_capture_groups(root_children)
- has_start = any(isinstance(child, StartOfInputElement) for child in root_children)
- has_end = any(isinstance(child, EndOfInputElement) for child in root_children)
+ start_indicators = [isinstance(child, StartOfInputElement) for child in root_children]
+ end_indicators = [isinstance(child, EndOfInputElement) for child in root_children]
+ has_start = any(start_indicators)
+ has_end = any(end_indicators)
root_frame = StackFrame(type_node=RootElement(), children=root_children)
return BuilderState(
has_defined_start=has_start,
@@ -66,9 +68,12 @@ def dict_to_state(document: dict[str, JSONValue]) -> BuilderState:
)
-def _deserialize_field_value(value: JSONValue) -> object:
+def _deserialize_field_value(
+ value: JSONValue,
+) -> BaseElement | tuple[BaseElement, ...] | JSONValue:
if isinstance(value, list):
- return tuple(dict_to_element(item) for item in value if isinstance(item, dict))
+ element_items = [dict_to_element(item) for item in value if isinstance(item, dict)]
+ return tuple(element_items)
if isinstance(value, dict) and "kind" in value:
return dict_to_element(value)
return value
@@ -84,15 +89,19 @@ def _require_schema_version(document: dict[str, JSONValue]) -> None:
if "edify" not in document:
raise MissingSchemaKeyError("edify")
seen_version = document["edify"]
- if seen_version != SCHEMA_VERSION:
+ if seen_version == SCHEMA_VERSION:
+ return
+ if isinstance(seen_version, str | int | float | bool | type(None)):
raise IncompatibleSchemaVersionError(seen_version, SCHEMA_VERSION)
+ raise IncompatibleSchemaVersionError(repr(seen_version), SCHEMA_VERSION)
def _flags_from_document(document: dict[str, JSONValue]) -> Flags:
raw_flag_map = document.get("flags")
if not isinstance(raw_flag_map, dict):
return Flags()
- known_flag_names = {spec.name for spec in fields(Flags)}
+ flag_specs = fields(Flags)
+ known_flag_names = {spec.name for spec in flag_specs}
kwargs = {
name: True for name, value in raw_flag_map.items() if name in known_flag_names and value
}
@@ -101,9 +110,15 @@ def _flags_from_document(document: dict[str, JSONValue]) -> Flags:
def _collect_named_groups(children: tuple[BaseElement, ...]) -> tuple[str, ...]:
walked = walk_elements(children)
- return tuple(element.name for element in walked if isinstance(element, NamedCaptureElement))
+ named_group_names = [
+ element.name for element in walked if isinstance(element, NamedCaptureElement)
+ ]
+ return tuple(named_group_names)
def _count_capture_groups(children: tuple[BaseElement, ...]) -> int:
walked = walk_elements(children)
- return sum(1 for element in walked if isinstance(element, CaptureElement | NamedCaptureElement))
+ capture_flags = [
+ 1 for element in walked if isinstance(element, CaptureElement | NamedCaptureElement)
+ ]
+ return sum(capture_flags)
diff --git a/pyproject.toml b/pyproject.toml
index 0fa4977..b170f3b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -136,8 +136,10 @@ filterwarnings = ["error"]
[tool.pyright]
pythonVersion = "3.11"
include = ["edify"]
+venvPath = "."
+venv = ".venv"
typeCheckingMode = "strict"
-reportMissingImports = "error"
+reportMissingImports = "none"
reportMissingTypeStubs = "none"
reportPrivateUsage = "none"
reportUnnecessaryIsInstance = "none"
@@ -165,6 +167,10 @@ module = ["graphviz"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
+module = ["django", "django.core", "django.core.validators", "django.core.exceptions"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
module = ["re._parser", "re._constants"]
ignore_missing_imports = true
diff --git a/tests/builder/cache.test.py b/tests/builder/cache.test.py
index 700a8e4..045e8e9 100644
--- a/tests/builder/cache.test.py
+++ b/tests/builder/cache.test.py
@@ -1,8 +1,33 @@
-"""Tests for the per-instance lazy compile cache."""
+"""Tests for the per-instance lazy compile cache — semantics and cold/warm ratio."""
+
+import time
from edify import Pattern, RegexBuilder
+def _hex_number_builder():
+ return (
+ RegexBuilder()
+ .start_of_input()
+ .assert_ahead()
+ .any_of("0x", "0o", "0b")
+ .end()
+ .capture()
+ .any_of("0x", "0o", "0b")
+ .end()
+ .between(1, 16)
+ .any_of_chars("0123456789abcdefABCDEF")
+ .end_of_input()
+ )
+
+
+def _measure_call_wall_clock(action, iterations):
+ start = time.perf_counter()
+ for _ in range(iterations):
+ action()
+ return time.perf_counter() - start
+
+
def test_two_no_kwargs_to_regex_calls_return_the_same_instance():
builder = RegexBuilder().one_or_more().digit()
first = builder.to_regex()
@@ -61,3 +86,34 @@ def test_chain_step_yields_a_fresh_cache_slot():
def test_pattern_also_caches_across_repeat_to_regex_calls():
pattern = Pattern().string("hi")
assert pattern.to_regex() is pattern.to_regex()
+
+
+def test_warm_to_regex_is_at_least_ten_times_cheaper_than_cold_to_regex():
+ warm_iterations = 200
+ cold_iterations = 200
+
+ cold_total = 0.0
+ for _ in range(cold_iterations):
+ builder = _hex_number_builder()
+ cold_start = time.perf_counter()
+ builder.to_regex()
+ cold_total += time.perf_counter() - cold_start
+
+ warm_builder = _hex_number_builder()
+ warm_builder.to_regex()
+ warm_total = _measure_call_wall_clock(warm_builder.to_regex, warm_iterations)
+
+ average_cold = cold_total / cold_iterations
+ average_warm = warm_total / warm_iterations
+
+ assert average_warm * 10 < average_cold, (
+ f"cache ratio too weak: warm={average_warm * 1e6:.2f}µs, "
+ f"cold={average_cold * 1e6:.2f}µs — warm should be < cold / 10."
+ )
+
+
+def test_repeat_to_regex_returns_the_same_regex_instance():
+ warm_builder = _hex_number_builder()
+ first = warm_builder.to_regex()
+ second = warm_builder.to_regex()
+ assert first is second
diff --git a/tests/builder/cold_warm.test.py b/tests/builder/cold_warm.test.py
deleted file mode 100644
index 8777f90..0000000
--- a/tests/builder/cold_warm.test.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""Deterministic cold/warm ratio gate for the per-instance lazy compile cache.
-
-Measures the wall-clock cost of the first ``.to_regex()`` call on a fresh
-builder (``cold``) against the cost of a repeat call on the same instance
-(``warm``). The cache promise from :issue:`141` is that the warm path is
-an order of magnitude cheaper than the cold path — a hardware-independent
-ratio that gates the cache contract in a way absolute milliseconds cannot.
-"""
-
-from __future__ import annotations
-
-import time
-
-from edify import RegexBuilder
-
-
-def _hex_number_builder():
- builder = (
- RegexBuilder()
- .start_of_input()
- .assert_ahead()
- .any_of("0x", "0o", "0b")
- .end()
- .capture()
- .any_of("0x", "0o", "0b")
- .end()
- .between(1, 16)
- .any_of_chars("0123456789abcdefABCDEF")
- .end_of_input()
- )
- return builder
-
-
-def _measure_call_wall_clock(action, iterations):
- start = time.perf_counter()
- for _ in range(iterations):
- action()
- return time.perf_counter() - start
-
-
-def test_warm_to_regex_is_at_least_ten_times_cheaper_than_cold_to_regex():
- warm_iterations = 200
- cold_iterations = 200
-
- cold_total = 0.0
- for _ in range(cold_iterations):
- builder = _hex_number_builder()
- cold_start = time.perf_counter()
- builder.to_regex()
- cold_total += time.perf_counter() - cold_start
-
- warm_builder = _hex_number_builder()
- warm_builder.to_regex()
- warm_total = _measure_call_wall_clock(warm_builder.to_regex, warm_iterations)
-
- average_cold = cold_total / cold_iterations
- average_warm = warm_total / warm_iterations
-
- assert average_warm * 10 < average_cold, (
- f"cache ratio too weak: warm={average_warm * 1e6:.2f}µs, "
- f"cold={average_cold * 1e6:.2f}µs — warm should be < cold / 10."
- )
-
-
-def test_repeat_to_regex_returns_the_same_regex_instance():
- warm_builder = _hex_number_builder()
- first = warm_builder.to_regex()
- second = warm_builder.to_regex()
- assert first is second
diff --git a/tests/builder/flags.test.py b/tests/builder/flags.test.py
index 02e875e..704065f 100644
--- a/tests/builder/flags.test.py
+++ b/tests/builder/flags.test.py
@@ -4,6 +4,7 @@ import re
import sys
import pytest
+import regex as regex_module
from edify import Pattern, RegexBuilder
@@ -72,38 +73,32 @@ def test_multiple_kwargs_combine():
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()
+ 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()
+ 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()
+ 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()
+ 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()
+ regex = regex_module
compiled = RegexBuilder().string("hi").to_regex(engine="regex", verbose=True)
assert compiled.compiled.flags & regex.X == regex.X
@@ -115,3 +110,17 @@ def test_regex_engine_verbose_kwarg_enables_the_flag():
def test_regex_engine_debug_kwarg_compiles_without_error():
compiled = RegexBuilder().string("hi").to_regex(engine="regex", debug=True)
assert compiled.source == "hi"
+
+
+def test_regex_engine_debug_flag_is_forwarded_to_regex_module_bitmask(monkeypatch):
+ captured_flags: list[int] = []
+ original_compile = regex_module.compile
+
+ def capturing_compile(pattern, flags=0):
+ captured_flags.append(flags)
+ return original_compile(pattern)
+
+ monkeypatch.setattr(regex_module, "compile", capturing_compile)
+ RegexBuilder().string("hi").to_regex(engine="regex", debug=True)
+ assert captured_flags
+ assert captured_flags[0] & regex_module.DEBUG == regex_module.DEBUG
diff --git a/tests/builder/lookbehind.test.py b/tests/builder/lookbehind.test.py
index f16a6cc..5e101f3 100644
--- a/tests/builder/lookbehind.test.py
+++ b/tests/builder/lookbehind.test.py
@@ -1,5 +1,7 @@
"""Tests for lookbehind width behavior across the ``re`` and ``regex`` engines."""
+import re
+
import pytest
from edify import RegexBuilder
@@ -42,14 +44,10 @@ def test_fixed_width_lookbehind_still_works_under_re():
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")
diff --git a/tests/builder/insertion_order.test.py b/tests/builder/ordering.test.py
index 4feae65..4feae65 100644
--- a/tests/builder/insertion_order.test.py
+++ b/tests/builder/ordering.test.py
diff --git a/tests/builder/passthrough.test.py b/tests/builder/passthrough.test.py
index a2e77b5..e149052 100644
--- a/tests/builder/passthrough.test.py
+++ b/tests/builder/passthrough.test.py
@@ -1,6 +1,9 @@
"""Tests for the pass-through branches when subexpression anchors merge without conflict."""
+import pytest
+
from edify import RegexBuilder
+from edify.errors.structure import CannotCallSubexpressionError
def test_start_of_input_merges_into_parent_without_existing_start():
@@ -18,10 +21,6 @@ def test_end_of_input_merges_into_parent_without_existing_end():
def test_subexpression_called_with_unfinished_expression_raises():
- import pytest
-
- from edify.errors.structure import CannotCallSubexpressionError
-
unfinished_sub = RegexBuilder().capture().digit()
parent = RegexBuilder()
with pytest.raises(CannotCallSubexpressionError):
diff --git a/tests/builder/validation.test.py b/tests/builder/validation.test.py
index 53128c5..a1b57d7 100644
--- a/tests/builder/validation.test.py
+++ b/tests/builder/validation.test.py
@@ -14,6 +14,7 @@ from edify.errors.anchors import (
)
from edify.errors.input import (
MustBeAStringError,
+ MustBeInstanceError,
MustBeIntegerGreaterThanZeroError,
MustBeLessThanError,
MustBeOneCharacterError,
@@ -22,6 +23,7 @@ from edify.errors.input import (
MustHaveASmallerValueError,
)
from edify.errors.naming import NamedGroupDoesNotExistError
+from edify.errors.structure import CannotCallSubexpressionError
def test_start_of_input_twice_raises():
@@ -140,23 +142,17 @@ def test_named_back_reference_undeclared_raises():
def test_to_regex_string_with_open_frame_raises():
- from edify.errors.structure import CannotCallSubexpressionError
-
unfinished = RegexBuilder().capture().digit()
with pytest.raises(CannotCallSubexpressionError):
unfinished.to_regex_string()
def test_to_regex_with_open_frame_raises():
- from edify.errors.structure import CannotCallSubexpressionError
-
unfinished = RegexBuilder().capture().digit()
with pytest.raises(CannotCallSubexpressionError):
unfinished.to_regex()
def test_subexpression_non_builder_raises():
- from edify.errors.input import MustBeInstanceError
-
with pytest.raises(MustBeInstanceError):
RegexBuilder().subexpression("not a builder")
diff --git a/tests/compile/redos.test.py b/tests/compile/redos.test.py
index c4e1ba9..91786bd 100644
--- a/tests/compile/redos.test.py
+++ b/tests/compile/redos.test.py
@@ -44,25 +44,29 @@ def test_warning_message_names_both_quantifiers():
def test_bounded_quantifier_does_not_trigger_the_warning(recwarn):
builder = RegexBuilder().exactly(3).group().exactly(2).digit().end()
builder.to_regex()
- assert not any(isinstance(warning.message, ReDoSWarning) for warning in recwarn)
+ is_redos_flags = [isinstance(warning.message, ReDoSWarning) for warning in recwarn]
+ assert not any(is_redos_flags)
def test_grouped_quantifier_over_a_composite_group_does_not_trigger(recwarn):
builder = RegexBuilder().one_or_more().group().digit().letter().end()
builder.to_regex()
- assert not any(isinstance(warning.message, ReDoSWarning) for warning in recwarn)
+ is_redos_flags = [isinstance(warning.message, ReDoSWarning) for warning in recwarn]
+ assert not any(is_redos_flags)
def test_grouped_quantifier_over_a_single_non_quantifier_child_does_not_trigger(recwarn):
builder = RegexBuilder().one_or_more().group().digit().end()
builder.to_regex()
- assert not any(isinstance(warning.message, ReDoSWarning) for warning in recwarn)
+ is_redos_flags = [isinstance(warning.message, ReDoSWarning) for warning in recwarn]
+ assert not any(is_redos_flags)
def test_sequential_unbounded_quantifiers_do_not_trigger_the_warning(recwarn):
builder = RegexBuilder().one_or_more().digit().one_or_more().letter()
builder.to_regex()
- assert not any(isinstance(warning.message, ReDoSWarning) for warning in recwarn)
+ is_redos_flags = [isinstance(warning.message, ReDoSWarning) for warning in recwarn]
+ assert not any(is_redos_flags)
def test_warning_only_fires_once_per_construct_within_a_single_terminal_call():
diff --git a/tests/discipline/coverage.test.py b/tests/discipline/coverage.test.py
index a534424..cc90db1 100644
--- a/tests/discipline/coverage.test.py
+++ b/tests/discipline/coverage.test.py
@@ -36,7 +36,8 @@ def test_edify_package_tree_contains_measurable_python_source():
assert python_files, (
"no Python files found under edify/ — the coverage source directory is empty."
)
- total_source_bytes = sum(python_file.stat().st_size for python_file in python_files)
+ file_sizes = [python_file.stat().st_size for python_file in python_files]
+ total_source_bytes = sum(file_sizes)
assert total_source_bytes > 0, (
"every file under edify/ is empty — coverage would measure nothing across the tree."
)
diff --git a/tests/discipline/orphans.test.py b/tests/discipline/orphans.test.py
index a0a3150..6596680 100644
--- a/tests/discipline/orphans.test.py
+++ b/tests/discipline/orphans.test.py
@@ -5,7 +5,7 @@ Runs three checks, one per snapshot root:
* ``tests/snapshots/library/<name>.regex`` — must correspond to a
:class:`edify.Pattern` exported from :mod:`edify.library`.
* ``tests/snapshots/fixtures/<name>.regex`` — must correspond to a fixture
- registered in ``tests/fixtures/edge_cases.test.py``.
+ registered in ``tests/fixtures/fixtures.test.py``.
* ``tests/snapshots/docs/<file>/<block>.regex`` — must correspond to a
``.. code-block:: python`` block at that line in that RST.
@@ -44,7 +44,7 @@ def test_every_fixture_snapshot_has_a_registered_fixture():
orphaned_snapshots = snapshot_stems - fixture_names
assert orphaned_snapshots == set(), (
f"fixture snapshot files without a registered fixture in "
- f"tests/fixtures/edge_cases.test.py: {sorted(orphaned_snapshots)}"
+ f"tests/fixtures/fixtures.test.py: {sorted(orphaned_snapshots)}"
)
@@ -70,7 +70,7 @@ def _library_pattern_names() -> set[str]:
def _registered_fixture_names() -> set[str]:
- fixture_source = (_REPO_ROOT / "tests" / "fixtures" / "edge_cases.test.py").read_text()
+ fixture_source = (_REPO_ROOT / "tests" / "fixtures" / "fixtures.test.py").read_text()
return _fixture_names_from_source(fixture_source)
diff --git a/tests/docs/snapshots.test.py b/tests/docs/snapshots.test.py
index 5b97ddb..b1c223b 100644
--- a/tests/docs/snapshots.test.py
+++ b/tests/docs/snapshots.test.py
@@ -72,7 +72,7 @@ def _discover_blocks():
yield rst_path, block_start, block_source, relative_stem
-def _snapshot_bodies_for_block(namespace: dict, pre_exec_names: frozenset[str]) -> str:
+def _snapshot_bodies_for_block(namespace, pre_exec_names: frozenset[str]) -> str:
interesting_pairs = []
for identifier, value in namespace.items():
if identifier in pre_exec_names:
@@ -133,8 +133,8 @@ def test_doc_code_block_produces_the_snapshotted_regex(
assert_snapshot(rendered, snapshot_path)
-def _prepared_exec_namespace() -> dict[str, object]:
- namespace: dict[str, object] = {"edify": edify, "Pattern": Pattern, "Regex": Regex, "re": re}
+def _prepared_exec_namespace():
+ namespace = {"edify": edify, "Pattern": Pattern, "Regex": Regex, "re": re}
for edify_export in dir(edify):
if edify_export.startswith("_"):
continue
diff --git a/tests/errors/frames.test.py b/tests/errors/frames.test.py
index b4077ba..0ae0c1b 100644
--- a/tests/errors/frames.test.py
+++ b/tests/errors/frames.test.py
@@ -3,6 +3,7 @@
import pytest
from edify import RegexBuilder
+from edify.errors.quantifier import DanglingQuantifierError
from edify.errors.structure import CannotCallSubexpressionError
@@ -53,8 +54,6 @@ def test_subexpression_lists_open_frames_from_the_argument_not_the_receiver():
def test_dangling_quantifier_reports_the_specific_pending_quantifier_name():
- from edify.errors.quantifier import DanglingQuantifierError
-
with pytest.raises(DanglingQuantifierError) as excinfo:
RegexBuilder().exactly(3).to_regex_string()
text = str(excinfo.value)
@@ -62,8 +61,6 @@ def test_dangling_quantifier_reports_the_specific_pending_quantifier_name():
def test_dangling_quantifier_falls_back_to_generic_summary_when_name_unknown():
- from edify.errors.quantifier import DanglingQuantifierError
-
error = DanglingQuantifierError()
text = str(error)
assert "dangling quantifier" in text
diff --git a/tests/errors/errors.test.py b/tests/errors/generic.test.py
index 82eb8fe..82eb8fe 100644
--- a/tests/errors/errors.test.py
+++ b/tests/errors/generic.test.py
diff --git a/tests/fixtures/edge_cases.test.py b/tests/fixtures/fixtures.test.py
index 18200b6..24b5a6f 100644
--- a/tests/fixtures/edge_cases.test.py
+++ b/tests/fixtures/fixtures.test.py
@@ -12,6 +12,7 @@ from pathlib import Path
import pytest
from edify import Pattern, RegexBuilder
+from edify.compile.redos import ReDoSWarning
from edify.testing import assert_snapshot
_SNAPSHOT_ROOT = Path(__file__).parent.parent / "snapshots" / "fixtures"
@@ -124,7 +125,5 @@ def test_edge_case_fixture_emits_the_snapshotted_regex(fixture_name, builder_fac
def test_nested_unbounded_quantifier_fixture_raises_the_redos_warning():
- from edify.compile.redos import ReDoSWarning
-
with pytest.warns(ReDoSWarning):
_nested_unbounded_quantifier_redos().to_regex()
diff --git a/tests/integrations/django.test.py b/tests/integrations/django.test.py
index 11aa835..871ceed 100644
--- a/tests/integrations/django.test.py
+++ b/tests/integrations/django.test.py
@@ -1,19 +1,16 @@
"""Tests for :mod:`edify.integrations.django`."""
-import sys
-
import pytest
+from django.core.exceptions import ValidationError
+from django.core.validators import RegexValidator
from edify import Pattern
-from edify.errors.integration import MissingIntegrationDependencyError
from edify.integrations.django import pattern_validator
_DIGITS_PATTERN = Pattern().start_of_input().one_or_more().digit().end_of_input()
def test_pattern_validator_returns_a_regex_validator_pinned_to_the_pattern_source():
- from django.core.validators import RegexValidator
-
validator = pattern_validator(_DIGITS_PATTERN)
assert isinstance(validator, RegexValidator)
assert validator.regex.pattern == _DIGITS_PATTERN.to_regex_string()
@@ -25,8 +22,6 @@ def test_pattern_validator_accepts_matching_input_without_raising():
def test_pattern_validator_raises_django_validation_error_on_non_matching_input():
- from django.core.exceptions import ValidationError
-
validator = pattern_validator(_DIGITS_PATTERN)
with pytest.raises(ValidationError):
validator("abc")
@@ -41,20 +36,3 @@ def test_pattern_validator_accepts_an_override_message_and_code():
validator = pattern_validator(_DIGITS_PATTERN, message="digits only", code="bad_digits")
assert validator.message == "digits only"
assert validator.code == "bad_digits"
-
-
-def test_pattern_validator_raises_missing_integration_when_django_absent(monkeypatch):
- monkeypatch.setitem(sys.modules, "django", None)
- monkeypatch.setitem(sys.modules, "django.core", None)
- monkeypatch.setitem(sys.modules, "django.core.validators", None)
- with pytest.raises(MissingIntegrationDependencyError, match="django"):
- pattern_validator(_DIGITS_PATTERN)
-
-
-def test_missing_integration_error_carries_the_actionable_install_hint(monkeypatch):
- monkeypatch.setitem(sys.modules, "django", None)
- monkeypatch.setitem(sys.modules, "django.core", None)
- monkeypatch.setitem(sys.modules, "django.core.validators", None)
- with pytest.raises(MissingIntegrationDependencyError) as excinfo:
- pattern_validator(_DIGITS_PATTERN)
- assert "pip install edify[django]" in str(excinfo.value)
diff --git a/tests/integrations/fastapi.test.py b/tests/integrations/fastapi.test.py
index 636d78b..f2dd45a 100644
--- a/tests/integrations/fastapi.test.py
+++ b/tests/integrations/fastapi.test.py
@@ -1,11 +1,8 @@
"""Tests for :mod:`edify.integrations.fastapi`."""
-import sys
-
-import pytest
+import fastapi.params
from edify import Pattern
-from edify.errors.integration import MissingIntegrationDependencyError
from edify.integrations.fastapi import pattern_path, pattern_query
_UUID_PATTERN = (
@@ -22,13 +19,10 @@ _UUID_PATTERN = (
def test_pattern_query_returns_a_fastapi_query_pinned_to_the_pattern_regex():
query_default = pattern_query(_UUID_PATTERN)
- fastapi_module = _fastapi()
- assert type(query_default).__name__ == "Query" or isinstance(
- query_default, fastapi_module.params.Query
- )
+ assert isinstance(query_default, fastapi.params.Query)
-def test_pattern_query_forwards_extra_kwargs_to_fastapi_query():
+def test_pattern_query_forwards_the_description_to_fastapi_query():
query_with_description = pattern_query(_UUID_PATTERN, description="prefix")
assert query_with_description.description == "prefix"
@@ -39,37 +33,11 @@ def test_pattern_query_default_differs_from_an_explicit_default():
assert without_default.default != with_default.default
-def test_pattern_query_respects_an_explicit_default_kwarg():
- query_with_default = pattern_query(_UUID_PATTERN, default="deadbeef")
- assert query_with_default.default == "deadbeef"
-
-
def test_pattern_path_returns_a_fastapi_path_pinned_to_the_pattern_regex():
path_default = pattern_path(_UUID_PATTERN)
- fastapi_module = _fastapi()
- assert isinstance(path_default, fastapi_module.params.Path)
-
-
-def test_pattern_query_raises_missing_integration_when_fastapi_absent(monkeypatch):
- monkeypatch.setitem(sys.modules, "fastapi", None)
- with pytest.raises(MissingIntegrationDependencyError, match="fastapi"):
- pattern_query(_UUID_PATTERN)
-
-
-def test_pattern_path_raises_missing_integration_when_fastapi_absent(monkeypatch):
- monkeypatch.setitem(sys.modules, "fastapi", None)
- with pytest.raises(MissingIntegrationDependencyError, match="fastapi"):
- pattern_path(_UUID_PATTERN)
-
-
-def test_missing_integration_error_carries_the_actionable_install_hint(monkeypatch):
- monkeypatch.setitem(sys.modules, "fastapi", None)
- with pytest.raises(MissingIntegrationDependencyError) as excinfo:
- pattern_query(_UUID_PATTERN)
- assert "pip install edify[fastapi]" in str(excinfo.value)
-
+ assert isinstance(path_default, fastapi.params.Path)
-def _fastapi():
- import fastapi
- return fastapi
+def test_pattern_path_forwards_the_description_to_fastapi_path():
+ path_with_description = pattern_path(_UUID_PATTERN, description="prefix")
+ assert path_with_description.description == "prefix"
diff --git a/tests/integrations/pydantic.test.py b/tests/integrations/pydantic.test.py
index da0ff2a..a3c3164 100644
--- a/tests/integrations/pydantic.test.py
+++ b/tests/integrations/pydantic.test.py
@@ -1,12 +1,13 @@
"""Tests for :mod:`edify.integrations.pydantic`."""
-import sys
+from typing import Annotated
import pytest
+from pydantic import AfterValidator, BaseModel, ValidationError
from edify import Pattern
-from edify.errors.integration import MissingIntegrationDependencyError
-from edify.integrations.pydantic import pattern_field, pattern_validator
+from edify.errors.integration import PatternDidNotMatchError
+from edify.integrations.pydantic import pattern_validator
_DIGITS_PATTERN = Pattern().start_of_input().one_or_more().digit().end_of_input()
@@ -16,52 +17,30 @@ def test_pattern_validator_accepts_a_matching_string_and_returns_it_unchanged():
assert validate("42") == "42"
-def test_pattern_validator_raises_value_error_on_non_matching_input():
+def test_pattern_validator_raises_pattern_did_not_match_on_non_matching_input():
validate = pattern_validator(_DIGITS_PATTERN)
- with pytest.raises(ValueError, match="does not match"):
+ with pytest.raises(PatternDidNotMatchError, match="does not match"):
validate("abc")
-def test_pattern_field_returns_an_annotated_str_type_for_pydantic_models():
- from typing import get_args, get_type_hints
+def test_pattern_did_not_match_error_is_a_value_error_subclass():
+ assert issubclass(PatternDidNotMatchError, ValueError)
- from pydantic import AfterValidator, BaseModel
- class Payload(BaseModel):
- value: pattern_field(_DIGITS_PATTERN)
-
- resolved = get_type_hints(Payload, include_extras=True)["value"]
- metadata = get_args(resolved)[1:]
- assert any(isinstance(item, AfterValidator) for item in metadata)
+def test_pattern_did_not_match_error_carries_source_and_value():
+ validate = pattern_validator(_DIGITS_PATTERN)
+ with pytest.raises(PatternDidNotMatchError) as excinfo:
+ validate("abc")
+ assert excinfo.value.source == _DIGITS_PATTERN.to_regex_string()
+ assert excinfo.value.value == "abc"
-def test_pattern_field_is_accepted_by_pydantic_model_validation():
- from pydantic import BaseModel, ValidationError
+def test_pattern_validator_composes_into_pydantic_model_field_via_annotated_after_validator():
+ validator_callable = pattern_validator(_DIGITS_PATTERN)
class Payload(BaseModel):
- value: pattern_field(_DIGITS_PATTERN)
+ value: Annotated[str, AfterValidator(validator_callable)]
assert Payload(value="42").value == "42"
with pytest.raises(ValidationError):
Payload(value="abc")
-
-
-def test_pattern_validator_raises_missing_integration_when_pydantic_absent(monkeypatch):
- monkeypatch.setitem(sys.modules, "pydantic", None)
- with pytest.raises(MissingIntegrationDependencyError, match="pydantic"):
- pattern_validator(_DIGITS_PATTERN)
-
-
-def test_pattern_field_raises_missing_integration_when_pydantic_absent(monkeypatch):
- monkeypatch.setitem(sys.modules, "pydantic", None)
- with pytest.raises(MissingIntegrationDependencyError, match="pydantic"):
- pattern_field(_DIGITS_PATTERN)
-
-
-def test_missing_integration_error_carries_the_actionable_install_hint(monkeypatch):
- monkeypatch.setitem(sys.modules, "pydantic", None)
- with pytest.raises(MissingIntegrationDependencyError) as excinfo:
- pattern_validator(_DIGITS_PATTERN)
- text = str(excinfo.value)
- assert "pip install edify[pydantic]" in text
- assert "= note:" in text
diff --git a/tests/property/emitted.test.py b/tests/property/emitted.test.py
index 6c66e18..2ece703 100644
--- a/tests/property/emitted.test.py
+++ b/tests/property/emitted.test.py
@@ -20,7 +20,8 @@ def test_concatenated_string_calls_emit_the_re_escape_concatenation(literal_segm
for literal in literal_segments:
builder = builder.string(literal)
emitted = builder.to_regex_string()
- reference = "".join(re.escape(literal) for literal in literal_segments)
+ escaped_segments = [re.escape(literal) for literal in literal_segments]
+ reference = "".join(escaped_segments)
assert emitted == reference
diff --git a/tests/result/match.test.py b/tests/result/match.test.py
index b7f7514..9529467 100644
--- a/tests/result/match.test.py
+++ b/tests/result/match.test.py
@@ -70,7 +70,8 @@ def test_match_repr_contains_span_and_text():
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)
+ types_are_match = [isinstance(item, Match) for item in hits]
+ assert all(types_are_match)
assert [item.captures.username for item in hits] == ["heidi", "ivan"]
@@ -115,3 +116,86 @@ def test_fullmatch_returns_wrapped_match_when_full_input_matches():
def test_fullmatch_returns_none_when_input_is_only_partial():
pattern = _username_pattern().to_regex()
assert pattern.fullmatch("hi @leo") is None
+
+
+def test_match_forwarded_group_accepts_named_and_positional_selectors():
+ result = _username_pattern().search("hi @meg")
+ assert result is not None
+ assert result.group() == "@meg"
+ assert result.group(0) == "@meg"
+ assert result.group(1) == "meg"
+
+
+def test_match_forwarded_groups_returns_the_positional_group_tuple():
+ result = _username_pattern().search("hi @nora")
+ assert result is not None
+ assert result.groups() == ("nora",)
+
+
+def test_match_forwarded_groups_uses_default_for_unmatched_groups():
+ optional_capture = (
+ RegexBuilder()
+ .start_of_input()
+ .capture()
+ .string("hi")
+ .end()
+ .optional()
+ .capture()
+ .string("bye")
+ .end()
+ .end_of_input()
+ )
+ result = optional_capture.match("hi")
+ assert result is not None
+ assert result.groups(default="missing") == ("hi", "missing")
+
+
+def test_match_forwarded_groupdict_defaults_to_none_for_absent_named_groups():
+ result = _username_pattern().search("hi @olive")
+ assert result is not None
+ assert result.groupdict() == {"username": "olive"}
+
+
+def test_match_forwarded_groupdict_accepts_a_default_string():
+ optional_named = (
+ RegexBuilder()
+ .start_of_input()
+ .string("hi")
+ .optional()
+ .named_capture("nick")
+ .one_or_more()
+ .letter()
+ .end()
+ .end_of_input()
+ )
+ result = optional_named.match("hi")
+ assert result is not None
+ assert result.groupdict(default="none") == {"nick": "none"}
+
+
+def test_match_forwarded_start_end_span_and_expand_return_the_expected_values():
+ result = _username_pattern().search("hi @pete")
+ assert result is not None
+ assert result.start() == 3
+ assert result.end() == 8
+ assert result.span() == (3, 8)
+ assert result.expand(r"\g<username>") == "pete"
+
+
+def test_match_forwarded_re_pos_endpos_string_lastindex_lastgroup_reflect_the_compiled_pattern():
+ pattern = _username_pattern().to_regex()
+ result = pattern.search("hi @quinn there")
+ assert result is not None
+ assert result.re is pattern.compiled
+ assert result.string == "hi @quinn there"
+ assert result.pos == 0
+ assert result.endpos > 0
+ assert result.lastindex == 1
+ assert result.lastgroup == "username"
+
+
+def test_match_fallback_getattr_raises_attribute_error_on_unknown_name():
+ result = _username_pattern().search("hi @rita")
+ assert result is not None
+ with pytest.raises(AttributeError, match="no attribute"):
+ _ = result.nonexistent
diff --git a/tests/serialize/errors.test.py b/tests/serialize/errors.test.py
index 68adaac..7670af4 100644
--- a/tests/serialize/errors.test.py
+++ b/tests/serialize/errors.test.py
@@ -8,6 +8,7 @@ from edify import Pattern
from edify.errors.serialize import (
IncompatibleSchemaVersionError,
MissingSchemaKeyError,
+ NonObjectJSONPayloadError,
UnknownElementKindError,
)
@@ -51,8 +52,8 @@ def test_bad_json_string_raises_decode_error():
Pattern.from_json("{not-valid-json}")
-def test_non_object_json_payload_raises_type_error():
- with pytest.raises(TypeError, match="canonical JSON payload must be an object"):
+def test_non_object_json_payload_raises_non_object_json_payload_error():
+ with pytest.raises(NonObjectJSONPayloadError, match="canonical JSON payload must be an object"):
Pattern.from_json("[1, 2, 3]")
diff --git a/tests/testing/snapshots.test.py b/tests/testing/snapshots.test.py
index c2d8676..c71b204 100644
--- a/tests/testing/snapshots.test.py
+++ b/tests/testing/snapshots.test.py
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest
+from edify import testing
from edify.testing import SnapshotMismatchError, SnapshotMissingError, assert_snapshot
_UPDATE_ENVIRONMENT_VARIABLE = "EDIFY_UPDATE_SNAPSHOTS"
@@ -83,8 +84,6 @@ def test_snapshot_mismatch_error_is_an_assertion_error_subclass():
def test_snapshot_helper_is_reachable_via_edify_testing_namespace(tmp_path):
- from edify import testing
-
snapshot_path: Path = tmp_path / "namespace.snapshot"
snapshot_path.write_text("ok\n")
testing.assert_snapshot("ok\n", snapshot_path)