From cf4a4ea68eca91aa044556806d3d91369182702c Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:12:42 +0530 Subject: =?UTF-8?q?chore:=20codebase-wide=20rule=20audit=20=E2=80=94=20eli?= =?UTF-8?q?minate=20Any/object=20where=20non-protocol,=20hoist=20in-functi?= =?UTF-8?q?on=20imports,=20rename=20compound/folder-repeat=20files,=20repl?= =?UTF-8?q?ace=20generic-exception=20raises,=20bind=20chained=20comprehens?= =?UTF-8?q?ions,=20strip=20=5F=5Finit=5F=5F=20docstrings,=20remove=20pragm?= =?UTF-8?q?as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- edify/__init__.py | 2 +- edify/builder/__init__.py | 2 +- edify/builder/builder.py | 61 --------- edify/builder/fluent.py | 61 +++++++++ edify/builder/mixins/captures.py | 2 +- edify/builder/mixins/chars.py | 4 +- edify/builder/mixins/groups.py | 5 +- edify/builder/mixins/subexpression.py | 2 +- edify/builder/mixins/testing.py | 6 +- edify/builder/reverse.py | 102 ++++++++++----- edify/compile/backend.py | 2 +- edify/elements/walk.py | 12 +- edify/errors/integration.py | 33 ++--- edify/errors/introspect.py | 6 +- edify/errors/serialize.py | 29 ++++- edify/errors/testing.py | 6 +- edify/integrations/__init__.py | 3 - edify/integrations/django.py | 29 ++--- edify/integrations/fastapi.py | 52 ++++---- edify/integrations/pydantic.py | 61 +++------ edify/introspect/explain.py | 6 +- edify/library/__init__.py | 7 -- edify/library/address/__init__.py | 2 +- edify/library/address/zip.py | 30 +++++ edify/library/address/zip_code.py | 30 ----- edify/library/auth/password.py | 3 +- edify/library/color/__init__.py | 2 +- edify/library/color/any.py | 128 +++++++++++++++++++ edify/library/color/color.py | 128 ------------------- edify/library/medical/__init__.py | 2 +- edify/library/medical/any.py | 35 ++++++ edify/library/medical/medical.py | 35 ------ edify/library/temporal/__init__.py | 2 +- edify/library/temporal/iso.py | 32 +++++ edify/library/temporal/iso_date.py | 32 ----- edify/pattern/composition.py | 5 +- edify/pattern/factories/__init__.py | 2 - edify/pattern/factories/groups.py | 3 +- edify/result/match.py | 82 ++++++++++-- edify/result/regex.py | 5 +- edify/serialize/dump.py | 8 +- edify/serialize/load.py | 33 +++-- pyproject.toml | 8 +- tests/builder/cache.test.py | 58 ++++++++- tests/builder/cold_warm.test.py | 69 ---------- tests/builder/flags.test.py | 31 +++-- tests/builder/insertion_order.test.py | 27 ---- tests/builder/lookbehind.test.py | 6 +- tests/builder/ordering.test.py | 27 ++++ tests/builder/passthrough.test.py | 7 +- tests/builder/validation.test.py | 8 +- tests/compile/redos.test.py | 12 +- tests/discipline/coverage.test.py | 3 +- tests/discipline/orphans.test.py | 6 +- tests/docs/snapshots.test.py | 6 +- tests/errors/errors.test.py | 228 ---------------------------------- tests/errors/frames.test.py | 5 +- tests/errors/generic.test.py | 228 ++++++++++++++++++++++++++++++++++ tests/fixtures/edge_cases.test.py | 130 ------------------- tests/fixtures/fixtures.test.py | 129 +++++++++++++++++++ tests/integrations/django.test.py | 26 +--- tests/integrations/fastapi.test.py | 46 ++----- tests/integrations/pydantic.test.py | 55 +++----- tests/property/emitted.test.py | 3 +- tests/result/match.test.py | 86 ++++++++++++- tests/serialize/errors.test.py | 5 +- tests/testing/snapshots.test.py | 3 +- 67 files changed, 1203 insertions(+), 1101 deletions(-) delete mode 100644 edify/builder/builder.py create mode 100644 edify/builder/fluent.py create mode 100644 edify/library/address/zip.py delete mode 100644 edify/library/address/zip_code.py create mode 100644 edify/library/color/any.py delete mode 100644 edify/library/color/color.py create mode 100644 edify/library/medical/any.py delete mode 100644 edify/library/medical/medical.py create mode 100644 edify/library/temporal/iso.py delete mode 100644 edify/library/temporal/iso_date.py delete mode 100644 tests/builder/cold_warm.test.py delete mode 100644 tests/builder/insertion_order.test.py create mode 100644 tests/builder/ordering.test.py delete mode 100644 tests/errors/errors.test.py create mode 100644 tests/errors/generic.test.py delete mode 100644 tests/fixtures/edge_cases.test.py create mode 100644 tests/fixtures/fixtures.test.py 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/builder.py deleted file mode 100644 index cde72d9..0000000 --- a/edify/builder/builder.py +++ /dev/null @@ -1,61 +0,0 @@ -"""The composition root for :class:`RegexBuilder` — the fluent regex-builder class.""" - -from __future__ import annotations - -import edify.builder.reverse as reverse_module -from edify.builder.core import BuilderCore -from edify.builder.mixins.anchors import AnchorsMixin -from edify.builder.mixins.assertions import AssertionsMixin -from edify.builder.mixins.captures import CapturesMixin -from edify.builder.mixins.chain import ChainMixin -from edify.builder.mixins.chars import CharsMixin -from edify.builder.mixins.classes import ClassesMixin -from edify.builder.mixins.flags import FlagsMixin -from edify.builder.mixins.groups import GroupsMixin -from edify.builder.mixins.matcher import MatcherMixin -from edify.builder.mixins.operators import OperatorsMixin -from edify.builder.mixins.quantifiers import QuantifiersMixin -from edify.builder.mixins.subexpression import SubexpressionMixin -from edify.builder.mixins.terminals import TerminalsMixin -from edify.builder.mixins.testing import TestingMixin - - -class RegexBuilder( - BuilderCore, - AnchorsMixin, - AssertionsMixin, - CapturesMixin, - ChainMixin, - CharsMixin, - ClassesMixin, - FlagsMixin, - GroupsMixin, - MatcherMixin, - OperatorsMixin, - QuantifiersMixin, - SubexpressionMixin, - TerminalsMixin, - TestingMixin, -): - """A fluent, immutable, strongly-typed regex builder. - - Immutability is a hard contract: every chain method returns a *new* builder - and leaves the receiver untouched. A base builder held in a constant, closure, - or attribute can be extended along multiple independent paths without any of - them observing another's state. Repeat no-kwargs ``.to_regex()`` calls on the - same builder return the same cached :class:`Regex`; a chain step or ``fork()`` - yields a fresh builder with its own empty cache. - """ - - @classmethod - def from_regex(cls, pattern_text: str) -> RegexBuilder: - """Return a builder chain whose emitted pattern is equivalent to ``pattern_text``. - - Args: - pattern_text: Raw regex source, exactly what would be handed to :func:`re.compile`. - - Raises: - edify.builder.reverse.UnsupportedReverseParseError: when the source uses a - construct the reverse parser cannot translate yet. - """ - return reverse_module.build_from_regex(pattern_text) diff --git a/edify/builder/fluent.py b/edify/builder/fluent.py new file mode 100644 index 0000000..cde72d9 --- /dev/null +++ b/edify/builder/fluent.py @@ -0,0 +1,61 @@ +"""The composition root for :class:`RegexBuilder` — the fluent regex-builder class.""" + +from __future__ import annotations + +import edify.builder.reverse as reverse_module +from edify.builder.core import BuilderCore +from edify.builder.mixins.anchors import AnchorsMixin +from edify.builder.mixins.assertions import AssertionsMixin +from edify.builder.mixins.captures import CapturesMixin +from edify.builder.mixins.chain import ChainMixin +from edify.builder.mixins.chars import CharsMixin +from edify.builder.mixins.classes import ClassesMixin +from edify.builder.mixins.flags import FlagsMixin +from edify.builder.mixins.groups import GroupsMixin +from edify.builder.mixins.matcher import MatcherMixin +from edify.builder.mixins.operators import OperatorsMixin +from edify.builder.mixins.quantifiers import QuantifiersMixin +from edify.builder.mixins.subexpression import SubexpressionMixin +from edify.builder.mixins.terminals import TerminalsMixin +from edify.builder.mixins.testing import TestingMixin + + +class RegexBuilder( + BuilderCore, + AnchorsMixin, + AssertionsMixin, + CapturesMixin, + ChainMixin, + CharsMixin, + ClassesMixin, + FlagsMixin, + GroupsMixin, + MatcherMixin, + OperatorsMixin, + QuantifiersMixin, + SubexpressionMixin, + TerminalsMixin, + TestingMixin, +): + """A fluent, immutable, strongly-typed regex builder. + + Immutability is a hard contract: every chain method returns a *new* builder + and leaves the receiver untouched. A base builder held in a constant, closure, + or attribute can be extended along multiple independent paths without any of + them observing another's state. Repeat no-kwargs ``.to_regex()`` calls on the + same builder return the same cached :class:`Regex`; a chain step or ``fork()`` + yields a fresh builder with its own empty cache. + """ + + @classmethod + def from_regex(cls, pattern_text: str) -> RegexBuilder: + """Return a builder chain whose emitted pattern is equivalent to ``pattern_text``. + + Args: + pattern_text: Raw regex source, exactly what would be handed to :func:`re.compile`. + + Raises: + edify.builder.reverse.UnsupportedReverseParseError: when the source uses a + construct the reverse parser cannot translate yet. + """ + return reverse_module.build_from_regex(pattern_text) diff --git a/edify/builder/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.`` 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.py b/edify/library/address/zip.py new file mode 100644 index 0000000..4603724 --- /dev/null +++ b/edify/library/address/zip.py @@ -0,0 +1,30 @@ +"""``zip_code`` — US postal-code shape (5 digits, optionally + 4).""" + +from __future__ import annotations + +from edify import Pattern + +zip_code = ( + Pattern() + .start_of_input() + .exactly(5) + .digit() + .optional() + .group() + .char("-") + .exactly(4) + .digit() + .end() + .end_of_input() +) +"""Callable :class:`Pattern` for the US ZIP or ZIP+4 postal-code shape. + +Guarantees: + * Five decimal digits, optionally followed by ``-`` and four more decimal digits. + * Anchored at both ends. + +Does not guarantee: + * Postal-service validity — accepts every 5- and 5+4-digit string, including + unassigned or reserved ranges. + * Non-US postal codes — those live under a country-specific validator. +""" diff --git a/edify/library/address/zip_code.py b/edify/library/address/zip_code.py deleted file mode 100644 index 4603724..0000000 --- a/edify/library/address/zip_code.py +++ /dev/null @@ -1,30 +0,0 @@ -"""``zip_code`` — US postal-code shape (5 digits, optionally + 4).""" - -from __future__ import annotations - -from edify import Pattern - -zip_code = ( - Pattern() - .start_of_input() - .exactly(5) - .digit() - .optional() - .group() - .char("-") - .exactly(4) - .digit() - .end() - .end_of_input() -) -"""Callable :class:`Pattern` for the US ZIP or ZIP+4 postal-code shape. - -Guarantees: - * Five decimal digits, optionally followed by ``-`` and four more decimal digits. - * Anchored at both ends. - -Does not guarantee: - * Postal-service validity — accepts every 5- and 5+4-digit string, including - unassigned or reserved ranges. - * Non-US postal codes — those live under a country-specific validator. -""" 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/any.py b/edify/library/color/any.py new file mode 100644 index 0000000..3102afd --- /dev/null +++ b/edify/library/color/any.py @@ -0,0 +1,128 @@ +"""``color`` — CSS colour shape (hex/rgb/rgba/hsl/hsla/named).""" + +from __future__ import annotations + +from edify import Pattern, any_of + +color = any_of( + Pattern() + .start_of_input() + .char("#") + .any_of() + .between(3, 4) + .any_of() + .range("0", "9") + .range("A", "F") + .range("a", "f") + .end() + .exactly(6) + .any_of() + .range("0", "9") + .range("A", "F") + .range("a", "f") + .end() + .exactly(8) + .any_of() + .range("0", "9") + .range("A", "F") + .range("a", "f") + .end() + .end() + .end_of_input(), + Pattern() + .start_of_input() + .string("rgb") + .optional() + .char("a") + .char("(") + .zero_or_more() + .whitespace_char() + .between(1, 3) + .digit() + .optional() + .char("%") + .zero_or_more() + .whitespace_char() + .char(",") + .zero_or_more() + .whitespace_char() + .between(1, 3) + .digit() + .optional() + .char("%") + .zero_or_more() + .whitespace_char() + .char(",") + .zero_or_more() + .whitespace_char() + .between(1, 3) + .digit() + .optional() + .char("%") + .optional() + .group() + .zero_or_more() + .whitespace_char() + .char(",") + .zero_or_more() + .whitespace_char() + .one_or_more() + .any_of() + .digit() + .char(".") + .end() + .end() + .zero_or_more() + .whitespace_char() + .char(")") + .end_of_input(), + Pattern() + .start_of_input() + .string("hsl") + .optional() + .char("a") + .char("(") + .zero_or_more() + .whitespace_char() + .between(1, 3) + .digit() + .optional() + .group() + .string("deg") + .end() + .zero_or_more() + .whitespace_char() + .char(",") + .zero_or_more() + .whitespace_char() + .between(1, 3) + .digit() + .char("%") + .zero_or_more() + .whitespace_char() + .char(",") + .zero_or_more() + .whitespace_char() + .between(1, 3) + .digit() + .char("%") + .optional() + .group() + .zero_or_more() + .whitespace_char() + .char(",") + .zero_or_more() + .whitespace_char() + .one_or_more() + .any_of() + .digit() + .char(".") + .end() + .end() + .zero_or_more() + .whitespace_char() + .char(")") + .end_of_input(), + Pattern().start_of_input().between(3, 20).letter().end_of_input(), +) +"""Callable :class:`Pattern` for any common CSS colour shape.""" diff --git a/edify/library/color/color.py b/edify/library/color/color.py deleted file mode 100644 index 3102afd..0000000 --- a/edify/library/color/color.py +++ /dev/null @@ -1,128 +0,0 @@ -"""``color`` — CSS colour shape (hex/rgb/rgba/hsl/hsla/named).""" - -from __future__ import annotations - -from edify import Pattern, any_of - -color = any_of( - Pattern() - .start_of_input() - .char("#") - .any_of() - .between(3, 4) - .any_of() - .range("0", "9") - .range("A", "F") - .range("a", "f") - .end() - .exactly(6) - .any_of() - .range("0", "9") - .range("A", "F") - .range("a", "f") - .end() - .exactly(8) - .any_of() - .range("0", "9") - .range("A", "F") - .range("a", "f") - .end() - .end() - .end_of_input(), - Pattern() - .start_of_input() - .string("rgb") - .optional() - .char("a") - .char("(") - .zero_or_more() - .whitespace_char() - .between(1, 3) - .digit() - .optional() - .char("%") - .zero_or_more() - .whitespace_char() - .char(",") - .zero_or_more() - .whitespace_char() - .between(1, 3) - .digit() - .optional() - .char("%") - .zero_or_more() - .whitespace_char() - .char(",") - .zero_or_more() - .whitespace_char() - .between(1, 3) - .digit() - .optional() - .char("%") - .optional() - .group() - .zero_or_more() - .whitespace_char() - .char(",") - .zero_or_more() - .whitespace_char() - .one_or_more() - .any_of() - .digit() - .char(".") - .end() - .end() - .zero_or_more() - .whitespace_char() - .char(")") - .end_of_input(), - Pattern() - .start_of_input() - .string("hsl") - .optional() - .char("a") - .char("(") - .zero_or_more() - .whitespace_char() - .between(1, 3) - .digit() - .optional() - .group() - .string("deg") - .end() - .zero_or_more() - .whitespace_char() - .char(",") - .zero_or_more() - .whitespace_char() - .between(1, 3) - .digit() - .char("%") - .zero_or_more() - .whitespace_char() - .char(",") - .zero_or_more() - .whitespace_char() - .between(1, 3) - .digit() - .char("%") - .optional() - .group() - .zero_or_more() - .whitespace_char() - .char(",") - .zero_or_more() - .whitespace_char() - .one_or_more() - .any_of() - .digit() - .char(".") - .end() - .end() - .zero_or_more() - .whitespace_char() - .char(")") - .end_of_input(), - Pattern().start_of_input().between(3, 20).letter().end_of_input(), -) -"""Callable :class:`Pattern` for any common CSS colour shape.""" 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/any.py b/edify/library/medical/any.py new file mode 100644 index 0000000..97c4d16 --- /dev/null +++ b/edify/library/medical/any.py @@ -0,0 +1,35 @@ +"""``medical`` — medical-coding-system code shape (SNOMED, ICD, NPI, RxNorm, LOINC).""" + +from __future__ import annotations + +from edify import Pattern, any_of + +_snomed = Pattern().between(6, 18).digit() +_icd = ( + Pattern() + .any_of() + .range("A", "T") + .range("V", "Z") + .end() + .digit() + .any_of() + .range("A", "Z") + .range("0", "9") + .end() + .optional() + .group() + .char(".") + .between(1, 4) + .any_of() + .range("A", "Z") + .range("0", "9") + .end() + .end() +) +_npi = Pattern().exactly(10).digit() +_loinc = Pattern().between(1, 7).digit().char("-").digit() + +medical = ( + Pattern().start_of_input().subexpression(any_of(_snomed, _icd, _npi, _loinc)).end_of_input() +) +"""Callable :class:`Pattern` for medical-coding-system codes.""" diff --git a/edify/library/medical/medical.py b/edify/library/medical/medical.py deleted file mode 100644 index 97c4d16..0000000 --- a/edify/library/medical/medical.py +++ /dev/null @@ -1,35 +0,0 @@ -"""``medical`` — medical-coding-system code shape (SNOMED, ICD, NPI, RxNorm, LOINC).""" - -from __future__ import annotations - -from edify import Pattern, any_of - -_snomed = Pattern().between(6, 18).digit() -_icd = ( - Pattern() - .any_of() - .range("A", "T") - .range("V", "Z") - .end() - .digit() - .any_of() - .range("A", "Z") - .range("0", "9") - .end() - .optional() - .group() - .char(".") - .between(1, 4) - .any_of() - .range("A", "Z") - .range("0", "9") - .end() - .end() -) -_npi = Pattern().exactly(10).digit() -_loinc = Pattern().between(1, 7).digit().char("-").digit() - -medical = ( - Pattern().start_of_input().subexpression(any_of(_snomed, _icd, _npi, _loinc)).end_of_input() -) -"""Callable :class:`Pattern` for medical-coding-system codes.""" 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.py b/edify/library/temporal/iso.py new file mode 100644 index 0000000..204d087 --- /dev/null +++ b/edify/library/temporal/iso.py @@ -0,0 +1,32 @@ +"""``iso_date`` — strict ISO 8601 date shape (``YYYY-MM-DD``).""" + +from __future__ import annotations + +from edify import Pattern + +iso_date = ( + Pattern() + .start_of_input() + .exactly(4) + .digit() + .char("-") + .exactly(2) + .digit() + .char("-") + .exactly(2) + .digit() + .end_of_input() +) +"""Callable :class:`Pattern` for the ISO 8601 calendar-date shape ``YYYY-MM-DD``. + +Guarantees: + * Exactly four year digits, two month digits, two day digits. + * Hyphen separators, no whitespace, no time component. + * Anchored at both ends. + +Does not guarantee: + * Calendar validity — accepts shapes like ``2026-02-30`` that the ISO + grammar allows but the calendar does not. + * ISO 8601 time or datetime shapes — use :data:`edify.library.datetime` + for those. +""" diff --git a/edify/library/temporal/iso_date.py b/edify/library/temporal/iso_date.py deleted file mode 100644 index 204d087..0000000 --- a/edify/library/temporal/iso_date.py +++ /dev/null @@ -1,32 +0,0 @@ -"""``iso_date`` — strict ISO 8601 date shape (``YYYY-MM-DD``).""" - -from __future__ import annotations - -from edify import Pattern - -iso_date = ( - Pattern() - .start_of_input() - .exactly(4) - .digit() - .char("-") - .exactly(2) - .digit() - .char("-") - .exactly(2) - .digit() - .end_of_input() -) -"""Callable :class:`Pattern` for the ISO 8601 calendar-date shape ``YYYY-MM-DD``. - -Guarantees: - * Exactly four year digits, two month digits, two day digits. - * Hyphen separators, no whitespace, no time component. - * Anchored at both ends. - -Does not guarantee: - * Calendar validity — accepts shapes like ``2026-02-30`` that the ISO - grammar allows but the calendar does not. - * ISO 8601 time or datetime shapes — use :data:`edify.library.datetime` - for those. -""" 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.``.""" 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 ```` 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" @@ -164,6 +166,10 @@ ignore_missing_imports = true 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/insertion_order.test.py b/tests/builder/insertion_order.test.py deleted file mode 100644 index 4feae65..0000000 --- a/tests/builder/insertion_order.test.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Pinned property: character-class members preserve insertion order across calls.""" - -from edify import RegexBuilder - - -def test_any_of_chars_preserves_the_exact_insertion_order_across_runs(): - first = RegexBuilder().any_of_chars("zyxabc321").to_regex_string() - second = RegexBuilder().any_of_chars("zyxabc321").to_regex_string() - third = RegexBuilder().any_of_chars("zyxabc321").to_regex_string() - assert first == second == third == "[zyxabc321]" - - -def test_anything_but_chars_preserves_the_exact_insertion_order_across_runs(): - first = RegexBuilder().anything_but_chars("zyxabc321").to_regex_string() - second = RegexBuilder().anything_but_chars("zyxabc321").to_regex_string() - assert first == second == "[^zyxabc321]" - - -def test_any_of_chars_preserves_duplicated_members_in_declaration_order(): - emitted = RegexBuilder().any_of_chars("aabbcc").to_regex_string() - assert emitted == "[aabbcc]" - - -def test_any_of_chars_never_alphabetically_sorts_its_members(): - emitted = RegexBuilder().any_of_chars("cba").to_regex_string() - assert emitted == "[cba]" - assert emitted != "[abc]" 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/ordering.test.py b/tests/builder/ordering.test.py new file mode 100644 index 0000000..4feae65 --- /dev/null +++ b/tests/builder/ordering.test.py @@ -0,0 +1,27 @@ +"""Pinned property: character-class members preserve insertion order across calls.""" + +from edify import RegexBuilder + + +def test_any_of_chars_preserves_the_exact_insertion_order_across_runs(): + first = RegexBuilder().any_of_chars("zyxabc321").to_regex_string() + second = RegexBuilder().any_of_chars("zyxabc321").to_regex_string() + third = RegexBuilder().any_of_chars("zyxabc321").to_regex_string() + assert first == second == third == "[zyxabc321]" + + +def test_anything_but_chars_preserves_the_exact_insertion_order_across_runs(): + first = RegexBuilder().anything_but_chars("zyxabc321").to_regex_string() + second = RegexBuilder().anything_but_chars("zyxabc321").to_regex_string() + assert first == second == "[^zyxabc321]" + + +def test_any_of_chars_preserves_duplicated_members_in_declaration_order(): + emitted = RegexBuilder().any_of_chars("aabbcc").to_regex_string() + assert emitted == "[aabbcc]" + + +def test_any_of_chars_never_alphabetically_sorts_its_members(): + emitted = RegexBuilder().any_of_chars("cba").to_regex_string() + assert emitted == "[cba]" + assert emitted != "[abc]" 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/.regex`` — must correspond to a :class:`edify.Pattern` exported from :mod:`edify.library`. * ``tests/snapshots/fixtures/.regex`` — must correspond to a fixture - registered in ``tests/fixtures/edge_cases.test.py``. + registered in ``tests/fixtures/fixtures.test.py``. * ``tests/snapshots/docs//.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/errors.test.py b/tests/errors/errors.test.py deleted file mode 100644 index 82eb8fe..0000000 --- a/tests/errors/errors.test.py +++ /dev/null @@ -1,228 +0,0 @@ -from edify.errors.anchors import ( - CannotDefineStartAfterEndError, - EndInputAlreadyDefinedError, - StartInputAlreadyDefinedError, -) -from edify.errors.captures import InvalidTotalCaptureGroupsIndexError -from edify.errors.input import ( - MustBeAStringError, - MustBeAtLeastOneLiteralError, - MustBeAtLeastTwoOperandsError, - MustBeInstanceError, - MustBeIntegerGreaterThanZeroError, - MustBeLessThanError, - MustBeOneCharacterError, - MustBePositiveIntegerError, - MustBeSingleCharacterError, - MustHaveASmallerValueError, -) -from edify.errors.naming import ( - CannotCreateDuplicateNamedGroupError, - NamedGroupDoesNotExistError, - NameNotValidError, -) -from edify.errors.structure import ( - CannotCallSubexpressionError, - CannotEndWhileBuildingRootExpressionError, - OpenFrameInfo, -) - - -def test_start_input_already_defined_outside_subexpression(): - error = StartInputAlreadyDefinedError() - text = str(error) - assert "start_of_input has already been added" in text - assert "help: remove the duplicate .start_of_input()" in text - assert "ignore_start_and_end" not in text - - -def test_start_input_already_defined_in_subexpression(): - error = StartInputAlreadyDefinedError(in_subexpression=True) - text = str(error) - assert "start_of_input has already been added" in text - assert "ignore_start_and_end=True" in text - - -def test_end_input_already_defined_outside_subexpression(): - error = EndInputAlreadyDefinedError() - text = str(error) - assert "end_of_input has already been added" in text - assert "help: remove the duplicate .end_of_input()" in text - assert "ignore_start_and_end" not in text - - -def test_end_input_already_defined_in_subexpression(): - error = EndInputAlreadyDefinedError(in_subexpression=True) - text = str(error) - assert "end_of_input has already been added" in text - assert "ignore_start_and_end=True" in text - - -def test_cannot_define_start_after_end(): - error = CannotDefineStartAfterEndError() - text = str(error) - assert "start_of_input cannot follow end_of_input" in text - assert "move .start_of_input() to before" in text - - -def test_invalid_total_capture_groups_index_reports_out_of_range(): - error = InvalidTotalCaptureGroupsIndexError(5, 3) - text = str(error) - assert "back_reference index #5 is out of range" in text - assert "3 capture groups" in text - assert "valid indices are 1 to 3" in text - - -def test_invalid_total_capture_groups_index_with_no_capture_groups_prompts_add_one(): - error = InvalidTotalCaptureGroupsIndexError(1, 0) - text = str(error) - assert "no capture groups yet" in text - assert "add a .capture()" in text - - -def test_must_be_a_string(): - error = MustBeAStringError("Name", "int") - text = str(error) - assert "Name must be a string" in text - assert "int" in text - assert "convert the value with str(...)" in text - - -def test_must_be_one_character(): - error = MustBeOneCharacterError("Value") - text = str(error) - assert "Value must be one character long" in text - - -def test_must_be_single_character(): - error = MustBeSingleCharacterError("Value", "str") - text = str(error) - assert "Value must be a single character" in text - assert "str" in text - - -def test_must_be_positive_integer(): - error = MustBePositiveIntegerError("count") - text = str(error) - assert "count must be a positive integer" in text - - -def test_must_be_integer_greater_than_zero(): - error = MustBeIntegerGreaterThanZeroError("x") - text = str(error) - assert "x must be an integer greater than zero" in text - - -def test_must_be_instance(): - error = MustBeInstanceError("Expression", "str", "RegexBuilder") - text = str(error) - assert "Expression must be an instance of RegexBuilder" in text - assert "str" in text - - -def test_must_have_a_smaller_value_reports_the_codepoints(): - error = MustHaveASmallerValueError("z", "a") - text = str(error) - assert "range bounds are inverted" in text - assert "'z'" in text - assert "'a'" in text - assert "= 122" in text - assert "= 97" in text - - -def test_must_be_less_than(): - error = MustBeLessThanError("X", "Y") - text = str(error) - assert "X must be less than Y" in text - - -def test_must_be_at_least_two_operands(): - error = MustBeAtLeastTwoOperandsError("any_of") - text = str(error) - assert "any_of requires at least two operands" in text - - -def test_must_be_at_least_one_literal(): - error = MustBeAtLeastOneLiteralError("one_of") - text = str(error) - assert "one_of requires at least one literal" in text - - -def test_name_not_valid(): - error = NameNotValidError("bad name") - text = str(error) - assert "'bad name' is not a valid identifier" in text - assert "letters, digits, and underscores" in text - - -def test_cannot_create_duplicate_named_group(): - error = CannotCreateDuplicateNamedGroupError("dup") - text = str(error) - assert "named group 'dup' already exists" in text - assert ".named_back_reference('dup')" in text - - -def test_named_group_does_not_exist(): - error = NamedGroupDoesNotExistError("missing") - text = str(error) - assert "named group 'missing' does not exist" in text - assert ".named_capture('missing')" in text - - -def test_cannot_end_while_building_root_expression(): - error = CannotEndWhileBuildingRootExpressionError() - text = str(error) - assert "cannot .end() while building the root expression" in text - assert "no matching opener" in text or "no frame to close" in text - - -def test_cannot_call_subexpression(): - frames = (OpenFrameInfo(kind="capture", opened_at=None),) - error = CannotCallSubexpressionError(frames) - text = str(error) - assert "cannot merge a subexpression that has an unclosed frame" in text - assert "1 open frame" in text - assert ".capture()" in text - - -def test_cannot_call_subexpression_lists_every_open_frame_in_the_stack(): - frames = ( - OpenFrameInfo(kind="capture", opened_at=None), - OpenFrameInfo(kind="assert_ahead", opened_at=None), - OpenFrameInfo(kind="group", opened_at=None), - ) - error = CannotCallSubexpressionError(frames) - text = str(error) - assert "3 open frame" in text - assert "1. .capture()" in text - assert "2. .assert_ahead()" in text - assert "3. .group()" in text - - -def test_every_annotated_error_message_starts_with_error_prefix(): - errors = [ - StartInputAlreadyDefinedError(), - CannotDefineStartAfterEndError(), - EndInputAlreadyDefinedError(), - InvalidTotalCaptureGroupsIndexError(1, 0), - MustBeAStringError("X", "int"), - MustBeOneCharacterError("X"), - MustBeSingleCharacterError("X", "int"), - MustBePositiveIntegerError("X"), - MustBeIntegerGreaterThanZeroError("X"), - MustBeInstanceError("X", "int", "Y"), - MustHaveASmallerValueError("z", "a"), - MustBeLessThanError("A", "B"), - MustBeAtLeastTwoOperandsError("f"), - MustBeAtLeastOneLiteralError("f"), - NameNotValidError("x"), - CannotCreateDuplicateNamedGroupError("x"), - NamedGroupDoesNotExistError("x"), - CannotEndWhileBuildingRootExpressionError(), - CannotCallSubexpressionError((OpenFrameInfo(kind="capture", opened_at=None),)), - ] - for error in errors: - text = str(error) - assert text.startswith("error:") - assert "help:" in text - assert "= note:" in text 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/generic.test.py b/tests/errors/generic.test.py new file mode 100644 index 0000000..82eb8fe --- /dev/null +++ b/tests/errors/generic.test.py @@ -0,0 +1,228 @@ +from edify.errors.anchors import ( + CannotDefineStartAfterEndError, + EndInputAlreadyDefinedError, + StartInputAlreadyDefinedError, +) +from edify.errors.captures import InvalidTotalCaptureGroupsIndexError +from edify.errors.input import ( + MustBeAStringError, + MustBeAtLeastOneLiteralError, + MustBeAtLeastTwoOperandsError, + MustBeInstanceError, + MustBeIntegerGreaterThanZeroError, + MustBeLessThanError, + MustBeOneCharacterError, + MustBePositiveIntegerError, + MustBeSingleCharacterError, + MustHaveASmallerValueError, +) +from edify.errors.naming import ( + CannotCreateDuplicateNamedGroupError, + NamedGroupDoesNotExistError, + NameNotValidError, +) +from edify.errors.structure import ( + CannotCallSubexpressionError, + CannotEndWhileBuildingRootExpressionError, + OpenFrameInfo, +) + + +def test_start_input_already_defined_outside_subexpression(): + error = StartInputAlreadyDefinedError() + text = str(error) + assert "start_of_input has already been added" in text + assert "help: remove the duplicate .start_of_input()" in text + assert "ignore_start_and_end" not in text + + +def test_start_input_already_defined_in_subexpression(): + error = StartInputAlreadyDefinedError(in_subexpression=True) + text = str(error) + assert "start_of_input has already been added" in text + assert "ignore_start_and_end=True" in text + + +def test_end_input_already_defined_outside_subexpression(): + error = EndInputAlreadyDefinedError() + text = str(error) + assert "end_of_input has already been added" in text + assert "help: remove the duplicate .end_of_input()" in text + assert "ignore_start_and_end" not in text + + +def test_end_input_already_defined_in_subexpression(): + error = EndInputAlreadyDefinedError(in_subexpression=True) + text = str(error) + assert "end_of_input has already been added" in text + assert "ignore_start_and_end=True" in text + + +def test_cannot_define_start_after_end(): + error = CannotDefineStartAfterEndError() + text = str(error) + assert "start_of_input cannot follow end_of_input" in text + assert "move .start_of_input() to before" in text + + +def test_invalid_total_capture_groups_index_reports_out_of_range(): + error = InvalidTotalCaptureGroupsIndexError(5, 3) + text = str(error) + assert "back_reference index #5 is out of range" in text + assert "3 capture groups" in text + assert "valid indices are 1 to 3" in text + + +def test_invalid_total_capture_groups_index_with_no_capture_groups_prompts_add_one(): + error = InvalidTotalCaptureGroupsIndexError(1, 0) + text = str(error) + assert "no capture groups yet" in text + assert "add a .capture()" in text + + +def test_must_be_a_string(): + error = MustBeAStringError("Name", "int") + text = str(error) + assert "Name must be a string" in text + assert "int" in text + assert "convert the value with str(...)" in text + + +def test_must_be_one_character(): + error = MustBeOneCharacterError("Value") + text = str(error) + assert "Value must be one character long" in text + + +def test_must_be_single_character(): + error = MustBeSingleCharacterError("Value", "str") + text = str(error) + assert "Value must be a single character" in text + assert "str" in text + + +def test_must_be_positive_integer(): + error = MustBePositiveIntegerError("count") + text = str(error) + assert "count must be a positive integer" in text + + +def test_must_be_integer_greater_than_zero(): + error = MustBeIntegerGreaterThanZeroError("x") + text = str(error) + assert "x must be an integer greater than zero" in text + + +def test_must_be_instance(): + error = MustBeInstanceError("Expression", "str", "RegexBuilder") + text = str(error) + assert "Expression must be an instance of RegexBuilder" in text + assert "str" in text + + +def test_must_have_a_smaller_value_reports_the_codepoints(): + error = MustHaveASmallerValueError("z", "a") + text = str(error) + assert "range bounds are inverted" in text + assert "'z'" in text + assert "'a'" in text + assert "= 122" in text + assert "= 97" in text + + +def test_must_be_less_than(): + error = MustBeLessThanError("X", "Y") + text = str(error) + assert "X must be less than Y" in text + + +def test_must_be_at_least_two_operands(): + error = MustBeAtLeastTwoOperandsError("any_of") + text = str(error) + assert "any_of requires at least two operands" in text + + +def test_must_be_at_least_one_literal(): + error = MustBeAtLeastOneLiteralError("one_of") + text = str(error) + assert "one_of requires at least one literal" in text + + +def test_name_not_valid(): + error = NameNotValidError("bad name") + text = str(error) + assert "'bad name' is not a valid identifier" in text + assert "letters, digits, and underscores" in text + + +def test_cannot_create_duplicate_named_group(): + error = CannotCreateDuplicateNamedGroupError("dup") + text = str(error) + assert "named group 'dup' already exists" in text + assert ".named_back_reference('dup')" in text + + +def test_named_group_does_not_exist(): + error = NamedGroupDoesNotExistError("missing") + text = str(error) + assert "named group 'missing' does not exist" in text + assert ".named_capture('missing')" in text + + +def test_cannot_end_while_building_root_expression(): + error = CannotEndWhileBuildingRootExpressionError() + text = str(error) + assert "cannot .end() while building the root expression" in text + assert "no matching opener" in text or "no frame to close" in text + + +def test_cannot_call_subexpression(): + frames = (OpenFrameInfo(kind="capture", opened_at=None),) + error = CannotCallSubexpressionError(frames) + text = str(error) + assert "cannot merge a subexpression that has an unclosed frame" in text + assert "1 open frame" in text + assert ".capture()" in text + + +def test_cannot_call_subexpression_lists_every_open_frame_in_the_stack(): + frames = ( + OpenFrameInfo(kind="capture", opened_at=None), + OpenFrameInfo(kind="assert_ahead", opened_at=None), + OpenFrameInfo(kind="group", opened_at=None), + ) + error = CannotCallSubexpressionError(frames) + text = str(error) + assert "3 open frame" in text + assert "1. .capture()" in text + assert "2. .assert_ahead()" in text + assert "3. .group()" in text + + +def test_every_annotated_error_message_starts_with_error_prefix(): + errors = [ + StartInputAlreadyDefinedError(), + CannotDefineStartAfterEndError(), + EndInputAlreadyDefinedError(), + InvalidTotalCaptureGroupsIndexError(1, 0), + MustBeAStringError("X", "int"), + MustBeOneCharacterError("X"), + MustBeSingleCharacterError("X", "int"), + MustBePositiveIntegerError("X"), + MustBeIntegerGreaterThanZeroError("X"), + MustBeInstanceError("X", "int", "Y"), + MustHaveASmallerValueError("z", "a"), + MustBeLessThanError("A", "B"), + MustBeAtLeastTwoOperandsError("f"), + MustBeAtLeastOneLiteralError("f"), + NameNotValidError("x"), + CannotCreateDuplicateNamedGroupError("x"), + NamedGroupDoesNotExistError("x"), + CannotEndWhileBuildingRootExpressionError(), + CannotCallSubexpressionError((OpenFrameInfo(kind="capture", opened_at=None),)), + ] + for error in errors: + text = str(error) + assert text.startswith("error:") + assert "help:" in text + assert "= note:" in text diff --git a/tests/fixtures/edge_cases.test.py b/tests/fixtures/edge_cases.test.py deleted file mode 100644 index 18200b6..0000000 --- a/tests/fixtures/edge_cases.test.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Snapshot suite for the compile-path edge-case fixtures. - -Each fixture builds a small, intentionally-tricky pattern and pins the -emitted regex string. The fixtures are the ones most likely to silently -drift when the compile path is refactored: deeply nested alternation, -combined lookaround, item-4 named backreferences, and constructs that -:mod:`edify` flags for ReDoS. -""" - -from pathlib import Path - -import pytest - -from edify import Pattern, RegexBuilder -from edify.testing import assert_snapshot - -_SNAPSHOT_ROOT = Path(__file__).parent.parent / "snapshots" / "fixtures" - - -def _deeply_nested_alternation(): - inner_alt = RegexBuilder().any_of("abc", "def", "ghi") - outer_alt = RegexBuilder().any_of("first", "second", "third") - return ( - RegexBuilder() - .start_of_input() - .subexpression(inner_alt) - .string("-") - .subexpression(outer_alt) - .end_of_input() - ) - - -def _combined_lookaround_positive_and_negative(): - return ( - RegexBuilder() - .assert_ahead() - .digit() - .end() - .assert_not_ahead() - .string("0") - .end() - .assert_behind() - .string("$") - .end() - .digit() - .digit() - .digit() - ) - - -def _named_backreference_pair(): - return ( - RegexBuilder() - .named_capture("open") - .any_of("[", "(", "{") - .end() - .zero_or_more() - .word() - .named_back_reference("open") - ) - - -def _nested_unbounded_quantifier_redos(): - return RegexBuilder().one_or_more().group().one_or_more().digit().end() - - -def _alternation_with_overlapping_prefixes_redos(): - return RegexBuilder().one_or_more().any_of("aa", "aaa", "aaaa") - - -def _hex_color_pattern(): - return ( - RegexBuilder() - .start_of_input() - .string("#") - .any_of_chars("0123456789abcdefABCDEF") - .exactly(6) - .word() - .end_of_input() - ) - - -def _email_shape_pattern(): - return ( - RegexBuilder() - .start_of_input() - .one_or_more() - .any_of_chars("a-zA-Z0-9._%+-") - .string("@") - .one_or_more() - .any_of_chars("a-zA-Z0-9.-") - .string(".") - .at_least(2) - .letter() - .end_of_input() - ) - - -def _pattern_composition_via_use(): - reusable = Pattern().at_least(2).letter() - return RegexBuilder().start_of_input().subexpression(reusable).end_of_input() - - -_FIXTURES = [ - ("deeply_nested_alternation", _deeply_nested_alternation), - ("combined_lookaround", _combined_lookaround_positive_and_negative), - ("named_backreference_pair", _named_backreference_pair), - ("nested_unbounded_quantifier_redos", _nested_unbounded_quantifier_redos), - ("alternation_with_overlapping_prefixes_redos", _alternation_with_overlapping_prefixes_redos), - ("hex_color_pattern", _hex_color_pattern), - ("email_shape_pattern", _email_shape_pattern), - ("pattern_composition_via_use", _pattern_composition_via_use), -] - - -@pytest.mark.parametrize( - ("fixture_name", "builder_factory"), _FIXTURES, ids=[name for name, _ in _FIXTURES] -) -def test_edge_case_fixture_emits_the_snapshotted_regex(fixture_name, builder_factory): - builder = builder_factory() - emitted = builder.to_regex_string() - snapshot_path = _SNAPSHOT_ROOT / f"{fixture_name}.regex" - assert_snapshot(emitted, snapshot_path) - - -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/fixtures/fixtures.test.py b/tests/fixtures/fixtures.test.py new file mode 100644 index 0000000..24b5a6f --- /dev/null +++ b/tests/fixtures/fixtures.test.py @@ -0,0 +1,129 @@ +"""Snapshot suite for the compile-path edge-case fixtures. + +Each fixture builds a small, intentionally-tricky pattern and pins the +emitted regex string. The fixtures are the ones most likely to silently +drift when the compile path is refactored: deeply nested alternation, +combined lookaround, item-4 named backreferences, and constructs that +:mod:`edify` flags for ReDoS. +""" + +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" + + +def _deeply_nested_alternation(): + inner_alt = RegexBuilder().any_of("abc", "def", "ghi") + outer_alt = RegexBuilder().any_of("first", "second", "third") + return ( + RegexBuilder() + .start_of_input() + .subexpression(inner_alt) + .string("-") + .subexpression(outer_alt) + .end_of_input() + ) + + +def _combined_lookaround_positive_and_negative(): + return ( + RegexBuilder() + .assert_ahead() + .digit() + .end() + .assert_not_ahead() + .string("0") + .end() + .assert_behind() + .string("$") + .end() + .digit() + .digit() + .digit() + ) + + +def _named_backreference_pair(): + return ( + RegexBuilder() + .named_capture("open") + .any_of("[", "(", "{") + .end() + .zero_or_more() + .word() + .named_back_reference("open") + ) + + +def _nested_unbounded_quantifier_redos(): + return RegexBuilder().one_or_more().group().one_or_more().digit().end() + + +def _alternation_with_overlapping_prefixes_redos(): + return RegexBuilder().one_or_more().any_of("aa", "aaa", "aaaa") + + +def _hex_color_pattern(): + return ( + RegexBuilder() + .start_of_input() + .string("#") + .any_of_chars("0123456789abcdefABCDEF") + .exactly(6) + .word() + .end_of_input() + ) + + +def _email_shape_pattern(): + return ( + RegexBuilder() + .start_of_input() + .one_or_more() + .any_of_chars("a-zA-Z0-9._%+-") + .string("@") + .one_or_more() + .any_of_chars("a-zA-Z0-9.-") + .string(".") + .at_least(2) + .letter() + .end_of_input() + ) + + +def _pattern_composition_via_use(): + reusable = Pattern().at_least(2).letter() + return RegexBuilder().start_of_input().subexpression(reusable).end_of_input() + + +_FIXTURES = [ + ("deeply_nested_alternation", _deeply_nested_alternation), + ("combined_lookaround", _combined_lookaround_positive_and_negative), + ("named_backreference_pair", _named_backreference_pair), + ("nested_unbounded_quantifier_redos", _nested_unbounded_quantifier_redos), + ("alternation_with_overlapping_prefixes_redos", _alternation_with_overlapping_prefixes_redos), + ("hex_color_pattern", _hex_color_pattern), + ("email_shape_pattern", _email_shape_pattern), + ("pattern_composition_via_use", _pattern_composition_via_use), +] + + +@pytest.mark.parametrize( + ("fixture_name", "builder_factory"), _FIXTURES, ids=[name for name, _ in _FIXTURES] +) +def test_edge_case_fixture_emits_the_snapshotted_regex(fixture_name, builder_factory): + builder = builder_factory() + emitted = builder.to_regex_string() + snapshot_path = _SNAPSHOT_ROOT / f"{fixture_name}.regex" + assert_snapshot(emitted, snapshot_path) + + +def test_nested_unbounded_quantifier_fixture_raises_the_redos_warning(): + 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") == "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) -- cgit v1.2.3