diff options
| author | natsuoto <[email protected]> | 2026-07-15 18:12:42 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-15 18:12:42 +0530 |
| commit | cf4a4ea68eca91aa044556806d3d91369182702c (patch) | |
| tree | 2daa50e0f80eda81ec08a4174be56f7ebdc78825 | |
| parent | 93371d459ad47a46310f729950ec383372cd2706 (diff) | |
| download | edify-cf4a4ea68eca91aa044556806d3d91369182702c.tar.xz edify-cf4a4ea68eca91aa044556806d3d91369182702c.zip | |
chore: codebase-wide rule audit — eliminate Any/object where non-protocol, hoist in-function imports, rename compound/folder-repeat files, replace generic-exception raises, bind chained comprehensions, strip __init__ docstrings, remove pragmas
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) |
