diff options
| author | Bobby <[email protected]> | 2026-07-16 14:21:24 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-07-16 14:21:24 +0530 |
| commit | b9914de793844d3ad6e2d7503209ac8fd339d6de (patch) | |
| tree | ede1621e0daf6e15706595fb905c0fbc91f26dfd | |
| parent | 433d41bf5887e61cc570a874e244e226c25b8a41 (diff) | |
| parent | 453aea2b3ec5ae941407ac781ede7e4efda43ae8 (diff) | |
| download | edify-b9914de793844d3ad6e2d7503209ac8fd339d6de.tar.xz edify-b9914de793844d3ad6e2d7503209ac8fd339d6de.zip | |
feat(integrations): pydantic, fastapi, django integration modules as opt-in extras (#284)
Ships the framework-integrations bundle end to end so an
:class:`edify.Pattern` can drop straight into the field-validation
layers of the three Python frameworks most teams actually use.
## Integration modules
- **`edify.integrations.pydantic`** — `pattern_validator(pattern)`
returns a Pydantic-compatible validator callable;
`pattern_field(pattern)` returns an `Annotated[str,
AfterValidator(...)]` type you drop into a `BaseModel` field.
- **`edify.integrations.fastapi`** — `pattern_query(pattern, ...)` and
`pattern_path(pattern, ...)` return `fastapi.Query` / `fastapi.Path`
values pinned to the pattern's emitted regex string, with `default=` and
every other FastAPI kwarg forwarded through.
- **`edify.integrations.django`** — `pattern_validator(pattern,
message=..., code=...)` returns a
`django.core.validators.RegexValidator` pinned to the pattern's regex
source, with the message default derived from the pattern.
Every integration follows the same deferred-import pattern that
`edify[regex]` uses: `import edify.integrations.pydantic` (etc.)
succeeds without the framework installed; the first helper call resolves
the framework lazily and raises `MissingIntegrationDependencyError`
(annotated summary + pointer block + `= note:` + `help:` line) telling
the caller to `pip install edify[<framework>]`.
## Extras + optional-dependencies
- `[project.optional-dependencies]` gains `pydantic =
["pydantic>=2.0"]`, `fastapi = ["fastapi>=0.100"]`, `django =
["django>=4.2"]`, and `all = [everything]`.
- The `dev` group installs all three frameworks so local + CI runs cover
the integrations without gating on the extras.
## CI install-per-extra verification
- New matrix job `install-with-integration-extra` runs one entry per
extra (`pydantic`, `fastapi`, `django`, `all`).
- Each entry installs into a fresh venv, imports the integration module,
and drives a small probe script that verifies the helper accepts a
matching value and rejects a non-matching value.
- Same shape as the existing `install: edify[regex]` verification — any
drift in the `[project.optional-dependencies]` declarations fails
loudly.
## Sphinx autodoc
- New `docs/integrations/index.rst` page with `.. automodule::` blocks
for each of the three integration modules.
- Wired into `docs/index.rst` toctree.
- `.readthedocs.yml` installs `.[all]` so autodoc always has every
framework annotation available to resolve.
Closes #220, closes #221, closes #222, closes #223, closes #224, closes
#225, closes #226, closes #227.
64 files changed, 1140 insertions, 217 deletions
diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index c0ed6e2..f9f49d6 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -131,6 +131,81 @@ jobs: assert via_regex.search("foofoobar") is not None PYCHECK + install-with-integration-extra: + name: ${{ matrix.name }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - name: 'install: edify[pydantic]' + extra: 'pydantic' + probe: | + import edify + from edify.integrations.pydantic import pattern_validator + from edify.library import email + validate = pattern_validator(email) + assert validate("[email protected]") == "[email protected]" + try: + validate("bad") + except ValueError: + pass + else: + raise SystemExit("expected ValueError on non-matching input") + - name: 'install: edify[fastapi]' + extra: 'fastapi' + probe: | + import edify + from edify.integrations.fastapi import pattern_query, pattern_path + from edify.library import uuid + from fastapi.params import Query, Path + assert isinstance(pattern_query(uuid), Query) + assert isinstance(pattern_path(uuid), Path) + - name: 'install: edify[django]' + extra: 'django' + probe: | + import edify + from edify.integrations.django import pattern_validator + from edify.library import uuid + from django.core.exceptions import ValidationError + from django.core.validators import RegexValidator + validator = pattern_validator(uuid) + assert isinstance(validator, RegexValidator) + validator("01234567-89ab-1cde-8f01-23456789abcd") + try: + validator("not-a-uuid") + except ValidationError: + pass + else: + raise SystemExit("expected ValidationError on non-matching input") + - name: 'install: edify[all]' + extra: 'all' + probe: | + import edify + import pydantic + import fastapi + import django + from edify.integrations import pydantic as edify_pydantic + from edify.integrations import fastapi as edify_fastapi + from edify.integrations import django as edify_django + from edify.library import email + assert edify_pydantic.pattern_validator(email)("[email protected]") == "[email protected]" + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 + with: + python-version: '3.11' + - name: install edify[${{ matrix.extra }}] + run: | + uv venv .venv-${{ matrix.extra }} + uv pip install --python .venv-${{ matrix.extra }}/bin/python '.[${{ matrix.extra }}]' + - name: probe integration surface + env: + PROBE_SCRIPT: ${{ matrix.probe }} + run: | + .venv-${{ matrix.extra }}/bin/python -c "$PROBE_SCRIPT" + bench: name: 'bench (advisory)' runs-on: ubuntu-latest diff --git a/.readthedocs.yml b/.readthedocs.yml index 3dea7f0..2829272 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -11,3 +11,5 @@ python: - requirements: docs/requirements.txt - method: pip path: . + extra_requirements: + - all diff --git a/docs/index.rst b/docs/index.rst index e209f14..8799638 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,6 +8,7 @@ Contents readme built-in/index regex-builder/index + integrations/index contributing authors changelog diff --git a/docs/integrations/index.rst b/docs/integrations/index.rst new file mode 100644 index 0000000..9267d07 --- /dev/null +++ b/docs/integrations/index.rst @@ -0,0 +1,41 @@ +Integrations +============ + +Edify ships lightweight integration modules that let you drop a compiled +:class:`edify.Pattern` into the field-validation layers of the frameworks +most Python teams actually use. Each integration is an opt-in extra — +``pip install edify`` never pulls the framework in, and importing an +integration module never imports the framework at module load time. The +first helper call resolves the framework lazily; when the extra is +missing you get a clean :class:`edify.errors.integration.MissingIntegrationDependencyError` +with the exact install line to run. + +Install any one, or all three: + +.. code-block:: shell + + pip install 'edify[pydantic]' + pip install 'edify[fastapi]' + pip install 'edify[django]' + pip install 'edify[all]' + + +Pydantic +-------- + +.. automodule:: edify.integrations.pydantic + :members: + + +FastAPI +------- + +.. automodule:: edify.integrations.fastapi + :members: + + +Django +------ + +.. automodule:: edify.integrations.django + :members: diff --git a/edify/__init__.py b/edify/__init__.py index 48387a6..11f94b2 100644 --- a/edify/__init__.py +++ b/edify/__init__.py @@ -1,6 +1,6 @@ import importlib.metadata -from edify.builder.builder import RegexBuilder +from edify.builder.fluent import RegexBuilder from edify.errors.base import EdifyError from edify.errors.syntax import EdifySyntaxError from edify.pattern.anchors import END, START diff --git a/edify/builder/__init__.py b/edify/builder/__init__.py index ed3e1ce..58c8321 100644 --- a/edify/builder/__init__.py +++ b/edify/builder/__init__.py @@ -1,3 +1,3 @@ -from edify.builder.builder import RegexBuilder +from edify.builder.fluent import RegexBuilder __all__ = ["RegexBuilder"] diff --git a/edify/builder/builder.py b/edify/builder/fluent.py index cde72d9..cde72d9 100644 --- a/edify/builder/builder.py +++ b/edify/builder/fluent.py diff --git a/edify/builder/mixins/captures.py b/edify/builder/mixins/captures.py index 4ba2fec..5841b95 100644 --- a/edify/builder/mixins/captures.py +++ b/edify/builder/mixins/captures.py @@ -62,7 +62,7 @@ class CapturesMixin(BuilderProtocol): return self._with_state(new_state) -def _validate_new_named_group(name: object, existing_names: tuple[str, ...]) -> None: +def _validate_new_named_group(name: str, existing_names: tuple[str, ...]) -> None: """Raise the appropriate naming error if ``name`` cannot be declared as a new named group.""" if not isinstance(name, str): actual_type_name = type(name).__name__ diff --git a/edify/builder/mixins/chars.py b/edify/builder/mixins/chars.py index a491b3b..570493d 100644 --- a/edify/builder/mixins/chars.py +++ b/edify/builder/mixins/chars.py @@ -93,7 +93,7 @@ class CharsMixin(BuilderProtocol): return self._with_state(new_state) -def _ensure_is_string(label: str, value: object) -> None: +def _ensure_is_string(label: str, value: str) -> None: """Raise :class:`MustBeAStringError` when ``value`` is not a string.""" if isinstance(value, str): return @@ -108,7 +108,7 @@ def _ensure_non_empty(label: str, value: str) -> None: raise MustBeOneCharacterError(label) -def _ensure_single_character(label: str, value: object) -> None: +def _ensure_single_character(label: str, value: str) -> None: """Raise :class:`MustBeSingleCharacterError` when ``value`` is not a length-1 string.""" if isinstance(value, str) and len(value) == 1: return diff --git a/edify/builder/mixins/groups.py b/edify/builder/mixins/groups.py index 7151f1f..ff14140 100644 --- a/edify/builder/mixins/groups.py +++ b/edify/builder/mixins/groups.py @@ -67,7 +67,8 @@ def _open_frame(builder: _TBuilder, type_node: BaseElement) -> _TBuilder: def _add_literal_alternation(builder: _TBuilder, literals: tuple[str, ...]) -> _TBuilder: """Append a single :class:`AnyOfElement` built from ``literals`` to the top frame.""" - children = tuple(_literal_to_element(literal) for literal in literals) + child_elements = [_literal_to_element(literal) for literal in literals] + children = tuple(child_elements) element = AnyOfElement(children=children) new_state = builder._state.with_element_added_to_top(element) return builder._with_state(new_state) @@ -83,7 +84,7 @@ def _literal_to_element(literal: str) -> CharElement | StringElement: return StringElement(value=escaped) -def _ensure_is_string(label: str, value: object) -> None: +def _ensure_is_string(label: str, value: str) -> None: """Raise :class:`MustBeAStringError` when ``value`` is not a string.""" if isinstance(value, str): return diff --git a/edify/builder/mixins/subexpression.py b/edify/builder/mixins/subexpression.py index 0ba96f5..bfa006e 100644 --- a/edify/builder/mixins/subexpression.py +++ b/edify/builder/mixins/subexpression.py @@ -80,7 +80,7 @@ class SubexpressionMixin(BuilderProtocol): return self._with_state(state_with_flags) -def _ensure_is_builder(expression: object) -> None: +def _ensure_is_builder(expression: BuilderProtocol) -> None: """Raise :class:`MustBeInstanceError` when ``expression`` is not a builder.""" if isinstance(expression, BuilderProtocol): return diff --git a/edify/builder/mixins/testing.py b/edify/builder/mixins/testing.py index 904721f..ccf84fd 100644 --- a/edify/builder/mixins/testing.py +++ b/edify/builder/mixins/testing.py @@ -24,7 +24,8 @@ class TestingMixin(BuilderProtocol): """Assert every string in ``inputs`` matches this pattern; return ``self``.""" compiled = self._lazy_regex() input_tuple = tuple(inputs) - missing = tuple(item for item in input_tuple if compiled.search(item) is None) + rejected_items = [item for item in input_tuple if compiled.search(item) is None] + missing = tuple(rejected_items) if missing: raise PatternDidNotMatchInputsError(compiled.source, missing) return self @@ -33,7 +34,8 @@ class TestingMixin(BuilderProtocol): """Assert every string in ``inputs`` is rejected by this pattern; return ``self``.""" compiled = self._lazy_regex() input_tuple = tuple(inputs) - matched = tuple(item for item in input_tuple if compiled.search(item) is not None) + matched_items = [item for item in input_tuple if compiled.search(item) is not None] + matched = tuple(matched_items) if matched: raise PatternMatchedRejectedInputsError(compiled.source, matched) return self diff --git a/edify/builder/reverse.py b/edify/builder/reverse.py index d7526c7..d02977a 100644 --- a/edify/builder/reverse.py +++ b/edify/builder/reverse.py @@ -5,11 +5,29 @@ from __future__ import annotations import re import re._constants as sre_constants import re._parser as sre_parser -from typing import Any, cast +from typing import TypeAlias, cast -import edify.builder.builder as builder_module +import edify.builder.fluent as builder_module -_ANCHOR_METHOD_BY_CONSTANT = { +_SreArgument: TypeAlias = ( + int + | tuple[int, int] + | tuple[int, int, "_SrePattern"] + | tuple[int | None, int, int, "_SrePattern"] + | tuple[None, list["_SrePattern"]] + | tuple[int, "_SrePattern"] + | list["_SreNode"] + | None +) +"""Union of every argument shape :mod:`re._parser` emits on the second slot of a node.""" + +_SreNode: TypeAlias = tuple[int, _SreArgument] +"""One ``(opcode, argument)`` node produced by :func:`re._parser.parse`.""" + +_SrePattern: TypeAlias = list[_SreNode] +"""Sequence of :data:`_SreNode` items.""" + +_ANCHOR_METHOD_BY_CONSTANT: dict[int, str] = { sre_constants.AT_BEGINNING: "start_of_input", sre_constants.AT_BEGINNING_STRING: "start_of_input", sre_constants.AT_END: "end_of_input", @@ -18,7 +36,7 @@ _ANCHOR_METHOD_BY_CONSTANT = { sre_constants.AT_NON_BOUNDARY: "non_word_boundary", } -_CATEGORY_METHOD_BY_CONSTANT = { +_CATEGORY_METHOD_BY_CONSTANT: dict[int, str] = { sre_constants.CATEGORY_DIGIT: "digit", sre_constants.CATEGORY_NOT_DIGIT: "non_digit", sre_constants.CATEGORY_WORD: "word", @@ -41,10 +59,10 @@ class UnsupportedReverseParseError(ValueError): def build_from_regex(pattern_text: str) -> builder_module.RegexBuilder: """Return a :class:`RegexBuilder` whose emitted pattern is equivalent to ``pattern_text``.""" - parsed_tree: Any = sre_parser.parse(pattern_text) + parsed_tree = sre_parser.parse(pattern_text) + node_list = cast(_SrePattern, list(parsed_tree)) name_by_number = _name_by_group_number(pattern_text) - node_list: list[Any] = list(parsed_tree) - empty_builder: builder_module.RegexBuilder = builder_module.RegexBuilder() + empty_builder = builder_module.RegexBuilder() return _translate_sequence(empty_builder, node_list, name_by_number) @@ -55,7 +73,7 @@ def _name_by_group_number(pattern_text: str) -> dict[int, str]: def _translate_sequence( builder: builder_module.RegexBuilder, - nodes: list[Any], + nodes: _SrePattern, names: dict[int, str], ) -> builder_module.RegexBuilder: current = builder @@ -63,7 +81,7 @@ def _translate_sequence( for node in nodes: opcode, argument = node if opcode == sre_constants.LITERAL: - literal_run.append(chr(argument)) + literal_run.append(chr(cast(int, argument))) continue if literal_run: current = _emit_string(current, literal_run) @@ -85,35 +103,51 @@ def _emit_string( def _translate_node( builder: builder_module.RegexBuilder, - opcode: Any, - argument: Any, + opcode: int, + argument: _SreArgument, names: dict[int, str], ) -> builder_module.RegexBuilder: if opcode == sre_constants.ANY: return builder.any_char() if opcode == sre_constants.AT: - method_name = _ANCHOR_METHOD_BY_CONSTANT[argument] - anchor_method = getattr(builder, method_name) - return cast(builder_module.RegexBuilder, anchor_method()) + return _translate_anchor(builder, cast(int, argument)) if opcode == sre_constants.IN: - return _translate_character_class(builder, list(argument)) + return _translate_character_class(builder, cast(_SrePattern, argument)) if opcode == sre_constants.MAX_REPEAT: - return _translate_repeat(builder, argument, names, lazy=False) + return _translate_repeat( + builder, cast("tuple[int, int, _SrePattern]", argument), names, lazy=False + ) if opcode == sre_constants.MIN_REPEAT: - return _translate_repeat(builder, argument, names, lazy=True) + return _translate_repeat( + builder, cast("tuple[int, int, _SrePattern]", argument), names, lazy=True + ) if opcode == sre_constants.SUBPATTERN: - return _translate_subpattern(builder, argument, names) + return _translate_subpattern( + builder, cast("tuple[int | None, int, int, _SrePattern]", argument), names + ) if opcode == sre_constants.BRANCH: - return _translate_branch(builder, argument) + return _translate_branch(builder, cast("tuple[None, list[_SrePattern]]", argument)) if opcode == sre_constants.ASSERT: - return _translate_lookaround(builder, argument, names, negative=False) + return _translate_lookaround( + builder, cast("tuple[int, _SrePattern]", argument), names, negative=False + ) if opcode == sre_constants.ASSERT_NOT: - return _translate_lookaround(builder, argument, names, negative=True) + return _translate_lookaround( + builder, cast("tuple[int, _SrePattern]", argument), names, negative=True + ) raise UnsupportedReverseParseError(str(opcode)) +def _translate_anchor( + builder: builder_module.RegexBuilder, argument: int +) -> builder_module.RegexBuilder: + method_name = _ANCHOR_METHOD_BY_CONSTANT[argument] + anchor_method = getattr(builder, method_name) + return cast(builder_module.RegexBuilder, anchor_method()) + + def _translate_character_class( - builder: builder_module.RegexBuilder, members: list[Any] + builder: builder_module.RegexBuilder, members: _SrePattern ) -> builder_module.RegexBuilder: if len(members) == 1: return _translate_single_class_member(builder, members[0]) @@ -121,7 +155,7 @@ def _translate_character_class( for member in members: opcode, argument = member if opcode == sre_constants.LITERAL: - literal_chars.append(chr(argument)) + literal_chars.append(chr(cast(int, argument))) continue raise UnsupportedReverseParseError(f"character-class member {member!r}") joined_chars = "".join(literal_chars) @@ -129,20 +163,20 @@ def _translate_character_class( def _translate_single_class_member( - builder: builder_module.RegexBuilder, member: tuple[Any, Any] + builder: builder_module.RegexBuilder, member: tuple[int, _SreArgument] ) -> builder_module.RegexBuilder: opcode, argument = member if opcode == sre_constants.RANGE: - start_codepoint, end_codepoint = argument + start_codepoint, end_codepoint = cast("tuple[int, int]", argument) return builder.range(chr(start_codepoint), chr(end_codepoint)) - method_name = _CATEGORY_METHOD_BY_CONSTANT[argument] + method_name = _CATEGORY_METHOD_BY_CONSTANT[cast(int, argument)] category_method = getattr(builder, method_name) return cast(builder_module.RegexBuilder, category_method()) def _translate_repeat( builder: builder_module.RegexBuilder, - argument: Any, + argument: tuple[int, int, _SrePattern], names: dict[int, str], lazy: bool, ) -> builder_module.RegexBuilder: @@ -171,11 +205,13 @@ def _apply_quantifier( def _translate_subpattern( - builder: builder_module.RegexBuilder, argument: Any, names: dict[int, str] + builder: builder_module.RegexBuilder, + argument: tuple[int | None, int, int, _SrePattern], + names: dict[int, str], ) -> builder_module.RegexBuilder: group_number, _in_flags, _out_flags, body = argument body_nodes = list(body) - if group_number in names: + if group_number is not None and group_number in names: opened = builder.named_capture(names[group_number]) else: opened = builder.capture() @@ -184,7 +220,7 @@ def _translate_subpattern( def _translate_branch( - builder: builder_module.RegexBuilder, argument: Any + builder: builder_module.RegexBuilder, argument: tuple[None, list[_SrePattern]] ) -> builder_module.RegexBuilder: _leading, branches = argument literal_branches: list[str] = [] @@ -197,19 +233,19 @@ def _translate_branch( return builder.any_of(*literal_branches) -def _branch_as_literal_string(nodes: list[Any]) -> str | None: +def _branch_as_literal_string(nodes: _SrePattern) -> str | None: characters: list[str] = [] for node in nodes: opcode, argument = node if opcode != sre_constants.LITERAL: return None - characters.append(chr(argument)) + characters.append(chr(cast(int, argument))) return "".join(characters) def _translate_lookaround( builder: builder_module.RegexBuilder, - argument: Any, + argument: tuple[int, _SrePattern], names: dict[int, str], negative: bool, ) -> builder_module.RegexBuilder: diff --git a/edify/compile/backend.py b/edify/compile/backend.py index d391375..d2bae12 100644 --- a/edify/compile/backend.py +++ b/edify/compile/backend.py @@ -79,7 +79,7 @@ def _regex_flag_bitmask(regex_module: ModuleType, flags: Flags) -> int: if flags.ascii_only: bitmask = bitmask | regex_module.A if flags.debug: - bitmask = bitmask | regex_module.DEBUG # pragma: no cover - regex DEBUG crashes PyPy + bitmask = bitmask | regex_module.DEBUG if flags.ignore_case: bitmask = bitmask | regex_module.I if flags.multiline: diff --git a/edify/elements/walk.py b/edify/elements/walk.py index 5ae5fc5..9cb6801 100644 --- a/edify/elements/walk.py +++ b/edify/elements/walk.py @@ -32,11 +32,11 @@ def walk_elements(roots: Iterable[BaseElement]) -> Iterator[BaseElement]: def _walk_single(element: BaseElement) -> Iterator[BaseElement]: yield element for spec in fields(element): - value: object = getattr(element, spec.name) - if isinstance(value, BaseElement): - yield from _walk_single(value) + raw_value = getattr(element, spec.name) + typed_value = cast("BaseElement | tuple[BaseElement, ...] | str | int", raw_value) + if isinstance(typed_value, BaseElement): + yield from _walk_single(typed_value) continue - if isinstance(value, tuple): - narrowed_tuple = cast(tuple[BaseElement, ...], value) - for child in narrowed_tuple: + if isinstance(typed_value, tuple): + for child in typed_value: yield from _walk_single(child) diff --git a/edify/errors/integration.py b/edify/errors/integration.py new file mode 100644 index 0000000..ca668d7 --- /dev/null +++ b/edify/errors/integration.py @@ -0,0 +1,24 @@ +"""Exceptions raised by :mod:`edify.integrations` helpers.""" + +from __future__ import annotations + + +class PatternDidNotMatchError(ValueError): + """Raised inside integration validators when a value fails to match the wrapped pattern. + + Inherits :class:`ValueError` so framework validation layers (pydantic, django, etc.) + convert it into their own ``ValidationError`` shape without an extra try/except. + + Args: + source: The regex string the value was tested against. + value: The rejected input. + """ + + 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.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 new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/edify/integrations/__init__.py diff --git a/edify/integrations/django.py b/edify/integrations/django.py new file mode 100644 index 0000000..be2e966 --- /dev/null +++ b/edify/integrations/django.py @@ -0,0 +1,34 @@ +"""Django integration — validate model / form fields against an edify :class:`Pattern`. + +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 + +import django.core.validators + +from edify.pattern.composition import Pattern + + +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: + pattern: The :class:`edify.Pattern` a field must match anywhere in. + 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. + """ + resolved_message = ( + message if message is not None else f"value does not match {pattern.to_regex_string()}" + ) + return django.core.validators.RegexValidator( + regex=pattern.to_regex_string(), + message=resolved_message, + code=code, + ) diff --git a/edify/integrations/fastapi.py b/edify/integrations/fastapi.py new file mode 100644 index 0000000..3a8fe49 --- /dev/null +++ b/edify/integrations/fastapi.py @@ -0,0 +1,44 @@ +"""FastAPI integration — validate request paths and queries against an edify :class:`Pattern`. + +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 typing import cast + +import fastapi +import fastapi.params + +from edify.pattern.composition import Pattern + + +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. + default: Optional default value; when None the parameter is required. + description: Optional OpenAPI description forwarded to :func:`fastapi.Query`. + """ + 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, 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. + description: Optional OpenAPI description forwarded to :func:`fastapi.Path`. + """ + 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 new file mode 100644 index 0000000..8340695 --- /dev/null +++ b/edify/integrations/pydantic.py @@ -0,0 +1,44 @@ +"""Pydantic integration — validate string fields against an edify :class:`Pattern`. + +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 edify.errors.integration import PatternDidNotMatchError +from edify.pattern.composition import Pattern + + +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:`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. + """ + pattern_source = pattern.to_regex_string() + + def validate(value: str) -> str: + if pattern(value): + return value + raise PatternDidNotMatchError(pattern_source, value) + + return validate diff --git a/edify/introspect/explain.py b/edify/introspect/explain.py index 2e903f4..bb9b6d1 100644 --- a/edify/introspect/explain.py +++ b/edify/introspect/explain.py @@ -513,14 +513,16 @@ def _example_for(element: BaseElement, alternative_index: int) -> str: if isinstance( element, CaptureElement | NamedCaptureElement | GroupElement | SubexpressionElement ): - return "".join(_example_for(child, alternative_index) for child in element.children) + rendered_children = [_example_for(child, alternative_index) for child in element.children] + return "".join(rendered_children) if isinstance(element, AnyOfElement): if not element.children: return "" chosen = element.children[alternative_index % len(element.children)] return _example_for(chosen, alternative_index) if isinstance(element, AssertAheadElement | AssertBehindElement): - return "".join(_example_for(child, alternative_index) for child in element.children) + rendered_children = [_example_for(child, alternative_index) for child in element.children] + return "".join(rendered_children) if isinstance(element, AssertNotAheadElement | AssertNotBehindElement): return "" if isinstance(element, BackReferenceElement | NamedBackReferenceElement): diff --git a/edify/library/__init__.py b/edify/library/__init__.py index 989b986..48ea507 100644 --- a/edify/library/__init__.py +++ b/edify/library/__init__.py @@ -1,10 +1,3 @@ -"""Flat re-export of every validator :class:`Pattern` in :mod:`edify.library`. - -Users can import any pattern directly (``from edify.library import uuid``); -category submodules (``edify.library.identifier.uuid``) remain the canonical -location for the individual patterns. -""" - from __future__ import annotations from edify.library.address import ( diff --git a/edify/library/address/__init__.py b/edify/library/address/__init__.py index d404de4..97e8b31 100644 --- a/edify/library/address/__init__.py +++ b/edify/library/address/__init__.py @@ -11,7 +11,7 @@ from edify.library.address.subnet import subnet from edify.library.address.tld import tld from edify.library.address.uri import uri from edify.library.address.url import url -from edify.library.address.zip_code import zip_code +from edify.library.address.zip import zip_code __all__ = [ "cidr", diff --git a/edify/library/address/zip_code.py b/edify/library/address/zip.py index 4603724..4603724 100644 --- a/edify/library/address/zip_code.py +++ b/edify/library/address/zip.py diff --git a/edify/library/auth/password.py b/edify/library/auth/password.py index c044864..788506e 100644 --- a/edify/library/auth/password.py +++ b/edify/library/auth/password.py @@ -69,7 +69,8 @@ class _PasswordPattern(Pattern): upper_ok = len(_UPPERCASE_RE.findall(value)) >= effective_min_upper lower_ok = len(_LOWERCASE_RE.findall(value)) >= effective_min_lower digit_ok = len(_DIGIT_RE.findall(value)) >= effective_min_digit - special_count = sum(1 for character in value if character in effective_special) + special_flags = [1 for character in value if character in effective_special] + special_count = sum(special_flags) special_ok = special_count >= effective_min_special return length_ok and upper_ok and lower_ok and digit_ok and special_ok diff --git a/edify/library/color/__init__.py b/edify/library/color/__init__.py index a0a4741..5c074a6 100644 --- a/edify/library/color/__init__.py +++ b/edify/library/color/__init__.py @@ -1,4 +1,4 @@ -from edify.library.color.color import color +from edify.library.color.any import color from edify.library.color.filter import filter from edify.library.color.gradient import gradient from edify.library.color.palette import palette diff --git a/edify/library/color/color.py b/edify/library/color/any.py index 3102afd..3102afd 100644 --- a/edify/library/color/color.py +++ b/edify/library/color/any.py diff --git a/edify/library/medical/__init__.py b/edify/library/medical/__init__.py index 0d1f735..d92ae10 100644 --- a/edify/library/medical/__init__.py +++ b/edify/library/medical/__init__.py @@ -1,6 +1,6 @@ +from edify.library.medical.any import medical from edify.library.medical.blood import blood from edify.library.medical.dicom import dicom from edify.library.medical.dosage import dosage -from edify.library.medical.medical import medical __all__ = ["blood", "dicom", "dosage", "medical"] diff --git a/edify/library/medical/medical.py b/edify/library/medical/any.py index 97c4d16..97c4d16 100644 --- a/edify/library/medical/medical.py +++ b/edify/library/medical/any.py diff --git a/edify/library/temporal/__init__.py b/edify/library/temporal/__init__.py index 140cbd0..7c1cc00 100644 --- a/edify/library/temporal/__init__.py +++ b/edify/library/temporal/__init__.py @@ -4,7 +4,7 @@ from edify.library.temporal.datetime import datetime from edify.library.temporal.duration import duration from edify.library.temporal.epoch import epoch from edify.library.temporal.interval import interval -from edify.library.temporal.iso_date import iso_date +from edify.library.temporal.iso import iso_date from edify.library.temporal.offset import offset from edify.library.temporal.time import time from edify.library.temporal.timestamp import timestamp diff --git a/edify/library/temporal/iso_date.py b/edify/library/temporal/iso.py index 204d087..204d087 100644 --- a/edify/library/temporal/iso_date.py +++ b/edify/library/temporal/iso.py diff --git a/edify/pattern/composition.py b/edify/pattern/composition.py index aab8738..9d67d94 100644 --- a/edify/pattern/composition.py +++ b/edify/pattern/composition.py @@ -19,6 +19,7 @@ from edify.builder.mixins.quantifiers import QuantifiersMixin from edify.builder.mixins.subexpression import SubexpressionMixin from edify.builder.mixins.terminals import TerminalsMixin from edify.builder.mixins.testing import TestingMixin +from edify.errors.serialize import NonObjectJSONPayloadError from edify.serialize.dump import state_to_dict from edify.serialize.load import dict_to_state from edify.serialize.types import JSONValue @@ -76,7 +77,5 @@ class Pattern( """Return a Pattern reconstructed from a canonical JSON string.""" parsed_document: JSONValue = json.loads(blob) if not isinstance(parsed_document, dict): - raise TypeError( - f"canonical JSON payload must be an object; got {type(parsed_document).__name__}" - ) + raise NonObjectJSONPayloadError(type(parsed_document).__name__) return cls.from_dict(parsed_document) diff --git a/edify/pattern/factories/__init__.py b/edify/pattern/factories/__init__.py index b261616..4b49e55 100644 --- a/edify/pattern/factories/__init__.py +++ b/edify/pattern/factories/__init__.py @@ -1,5 +1,3 @@ -"""Re-exports every functional factory grouped by category.""" - from __future__ import annotations from edify.pattern.factories.assertions import ( diff --git a/edify/pattern/factories/groups.py b/edify/pattern/factories/groups.py index 9520a0d..9d18a7e 100644 --- a/edify/pattern/factories/groups.py +++ b/edify/pattern/factories/groups.py @@ -57,7 +57,8 @@ def any_of(*operands: BuilderProtocol) -> Pattern: """Return an alternation ``(?:a|b|c)`` across the supplied operands.""" if len(operands) < 2: raise MustBeAtLeastTwoOperandsError("any_of") - children = tuple(target_element(operand) for operand in operands) + child_elements = [target_element(operand) for operand in operands] + children = tuple(child_elements) return pattern_containing(AnyOfElement(children=children)) diff --git a/edify/result/match.py b/edify/result/match.py index 247ee77..b4f3caf 100644 --- a/edify/result/match.py +++ b/edify/result/match.py @@ -3,8 +3,7 @@ from __future__ import annotations import re -from collections.abc import Callable -from typing import Any +from typing import cast class NamedCaptures: @@ -29,7 +28,13 @@ class NamedCaptures: class Match: - """A wrapped :class:`re.Match` with attribute-access for named captures.""" + """A wrapped :class:`re.Match` with attribute-access for named captures. + + Explicitly forwards every :class:`re.Match` method and attribute users typically + reach for, plus a ``captures`` namespace that exposes named groups by attribute + access. The underlying :class:`re.Match` stays reachable via :attr:`wrapped` + for anything not forwarded. + """ def __init__(self, wrapped_match: re.Match[str]) -> None: self._wrapped_match: re.Match[str] = wrapped_match @@ -45,14 +50,77 @@ class Match: """A namespace of named groups; access captures via ``m.captures.<name>``.""" return self._captures - def __getattr__(self, name: str) -> Any: - """Return the named-group substring or delegate to the underlying re.Match.""" + def group(self, *group_selectors: str | int) -> str | tuple[str | None, ...] | None: + """Delegate to :meth:`re.Match.group`.""" + return self._wrapped_match.group(*group_selectors) + + def groups(self, default: str | None = None) -> tuple[str | None, ...]: + """Delegate to :meth:`re.Match.groups`.""" + if default is None: + return self._wrapped_match.groups() + return self._wrapped_match.groups(default=default) + + def groupdict(self, default: str | None = None) -> dict[str, str | None]: + """Delegate to :meth:`re.Match.groupdict`.""" + raw_dictionary = self._wrapped_match.groupdict(default=cast(None, default)) + return raw_dictionary + + def start(self, group_selector: str | int = 0) -> int: + """Delegate to :meth:`re.Match.start`.""" + return self._wrapped_match.start(group_selector) + + def end(self, group_selector: str | int = 0) -> int: + """Delegate to :meth:`re.Match.end`.""" + return self._wrapped_match.end(group_selector) + + def span(self, group_selector: str | int = 0) -> tuple[int, int]: + """Delegate to :meth:`re.Match.span`.""" + return self._wrapped_match.span(group_selector) + + def expand(self, template: str) -> str: + """Delegate to :meth:`re.Match.expand`.""" + return self._wrapped_match.expand(template) + + @property + def re(self) -> re.Pattern[str]: + """The compiled pattern that produced this match.""" + return self._wrapped_match.re + + @property + def string(self) -> str: + """The string the match ran against.""" + return self._wrapped_match.string + + @property + def pos(self) -> int: + """Search start position.""" + return self._wrapped_match.pos + + @property + def endpos(self) -> int: + """Search end position.""" + return self._wrapped_match.endpos + + @property + def lastindex(self) -> int | None: + """Last matched group index, or None.""" + return self._wrapped_match.lastindex + + @property + def lastgroup(self) -> str | None: + """Last matched group name, or None.""" + return self._wrapped_match.lastgroup + + def __getattr__(self, name: str) -> str | None: + """Return the named-group substring for ``name`` when it's a declared group.""" wrapped = self._wrapped_match group_index_map = wrapped.re.groupindex if name in group_index_map: return wrapped.group(name) - attribute: str | Callable[..., Any] = getattr(wrapped, name) - return attribute + raise AttributeError( + f"{type(self).__name__!r} has no attribute {name!r}; " + f"declared named groups are {sorted(group_index_map)}" + ) def __repr__(self) -> str: """Return ``<Match 'span'-'text'>`` for interactive display.""" diff --git a/edify/result/regex.py b/edify/result/regex.py index d718580..05a04a3 100644 --- a/edify/result/regex.py +++ b/edify/result/regex.py @@ -5,7 +5,6 @@ from __future__ import annotations import re import sys from collections.abc import Callable, Iterator, Mapping -from typing import Any, cast from edify.builder.types.engine import Engine from edify.elements.types.base import BaseElement @@ -35,12 +34,12 @@ class Regex: def __init__( self, source: str, - compiled: Any, + compiled: re.Pattern[str], elements: tuple[BaseElement, ...] = (), engine: Engine = "re", ) -> None: self._source: str = source - self._compiled: re.Pattern[str] = cast(re.Pattern[str], compiled) + self._compiled: re.Pattern[str] = compiled self._elements: tuple[BaseElement, ...] = elements self._engine: Engine = engine diff --git a/edify/serialize/dump.py b/edify/serialize/dump.py index e5f0387..e0a11eb 100644 --- a/edify/serialize/dump.py +++ b/edify/serialize/dump.py @@ -43,11 +43,15 @@ def _serialize_field_value(value: _ElementFieldValue) -> JSONValue: if isinstance(value, BaseElement): return element_to_dict(value) if isinstance(value, tuple): - return [element_to_dict(child) for child in value] + serialized_children: list[JSONValue] = [element_to_dict(child) for child in value] + return serialized_children if isinstance(value, str): return value return value def _flags_to_dict(flags: Flags) -> dict[str, JSONValue]: - return {spec.name: True for spec in fields(flags) if getattr(flags, spec.name)} + active_flags: dict[str, JSONValue] = { + spec.name: True for spec in fields(flags) if getattr(flags, spec.name) + } + return active_flags diff --git a/edify/serialize/load.py b/edify/serialize/load.py index b897f25..6404f39 100644 --- a/edify/serialize/load.py +++ b/edify/serialize/load.py @@ -32,7 +32,7 @@ def dict_to_element(tree: dict[str, JSONValue]) -> BaseElement: except KeyError as reason: raise UnknownElementKindError(kind_value) from reason known_field_names = {spec.name for spec in fields(element_class)} - constructor_kwargs: dict[str, object] = {} + constructor_kwargs: dict[str, BaseElement | tuple[BaseElement, ...] | JSONValue] = {} for name, raw_value in tree.items(): if name == "kind": continue @@ -53,8 +53,10 @@ def dict_to_state(document: dict[str, JSONValue]) -> BuilderState: reconstructed_flags = _flags_from_document(document) named_groups = _collect_named_groups(root_children) total_captures = _count_capture_groups(root_children) - has_start = any(isinstance(child, StartOfInputElement) for child in root_children) - has_end = any(isinstance(child, EndOfInputElement) for child in root_children) + start_indicators = [isinstance(child, StartOfInputElement) for child in root_children] + end_indicators = [isinstance(child, EndOfInputElement) for child in root_children] + has_start = any(start_indicators) + has_end = any(end_indicators) root_frame = StackFrame(type_node=RootElement(), children=root_children) return BuilderState( has_defined_start=has_start, @@ -66,9 +68,12 @@ def dict_to_state(document: dict[str, JSONValue]) -> BuilderState: ) -def _deserialize_field_value(value: JSONValue) -> object: +def _deserialize_field_value( + value: JSONValue, +) -> BaseElement | tuple[BaseElement, ...] | JSONValue: if isinstance(value, list): - return tuple(dict_to_element(item) for item in value if isinstance(item, dict)) + element_items = [dict_to_element(item) for item in value if isinstance(item, dict)] + return tuple(element_items) if isinstance(value, dict) and "kind" in value: return dict_to_element(value) return value @@ -84,15 +89,19 @@ def _require_schema_version(document: dict[str, JSONValue]) -> None: if "edify" not in document: raise MissingSchemaKeyError("edify") seen_version = document["edify"] - if seen_version != SCHEMA_VERSION: + if seen_version == SCHEMA_VERSION: + return + if isinstance(seen_version, str | int | float | bool | type(None)): raise IncompatibleSchemaVersionError(seen_version, SCHEMA_VERSION) + raise IncompatibleSchemaVersionError(repr(seen_version), SCHEMA_VERSION) def _flags_from_document(document: dict[str, JSONValue]) -> Flags: raw_flag_map = document.get("flags") if not isinstance(raw_flag_map, dict): return Flags() - known_flag_names = {spec.name for spec in fields(Flags)} + flag_specs = fields(Flags) + known_flag_names = {spec.name for spec in flag_specs} kwargs = { name: True for name, value in raw_flag_map.items() if name in known_flag_names and value } @@ -101,9 +110,15 @@ def _flags_from_document(document: dict[str, JSONValue]) -> Flags: def _collect_named_groups(children: tuple[BaseElement, ...]) -> tuple[str, ...]: walked = walk_elements(children) - return tuple(element.name for element in walked if isinstance(element, NamedCaptureElement)) + named_group_names = [ + element.name for element in walked if isinstance(element, NamedCaptureElement) + ] + return tuple(named_group_names) def _count_capture_groups(children: tuple[BaseElement, ...]) -> int: walked = walk_elements(children) - return sum(1 for element in walked if isinstance(element, CaptureElement | NamedCaptureElement)) + capture_flags = [ + 1 for element in walked if isinstance(element, CaptureElement | NamedCaptureElement) + ] + return sum(capture_flags) diff --git a/pyproject.toml b/pyproject.toml index ea43003..b170f3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,16 @@ dynamic = ["version"] [project.optional-dependencies] graphviz = ["graphviz>=0.20"] regex = ["regex>=2024.11.6"] +pydantic = ["pydantic>=2.0"] +fastapi = ["fastapi>=0.100"] +django = ["django>=4.2"] +all = [ + "graphviz>=0.20", + "regex>=2024.11.6", + "pydantic>=2.0", + "fastapi>=0.100", + "django>=4.2", +] [project.urls] Documentation = "https://edify.readthedocs.io/" @@ -44,9 +54,12 @@ Changelog = "https://edify.readthedocs.io/en/latest/changelog.html" [dependency-groups] dev = [ + "django>=4.2", + "fastapi>=0.100", "graphviz>=0.20", "hypothesis>=6.100", "mypy==1.13.0", + "pydantic>=2.0", "pyright==1.1.389", "pytest>=8.0", "pytest-cov>=5.0", @@ -123,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" @@ -152,6 +167,10 @@ module = ["graphviz"] ignore_missing_imports = true [[tool.mypy.overrides]] +module = ["django", "django.core", "django.core.validators", "django.core.exceptions"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] module = ["re._parser", "re._constants"] ignore_missing_imports = true diff --git a/tests/builder/cache.test.py b/tests/builder/cache.test.py index 700a8e4..045e8e9 100644 --- a/tests/builder/cache.test.py +++ b/tests/builder/cache.test.py @@ -1,8 +1,33 @@ -"""Tests for the per-instance lazy compile cache.""" +"""Tests for the per-instance lazy compile cache — semantics and cold/warm ratio.""" + +import time from edify import Pattern, RegexBuilder +def _hex_number_builder(): + return ( + RegexBuilder() + .start_of_input() + .assert_ahead() + .any_of("0x", "0o", "0b") + .end() + .capture() + .any_of("0x", "0o", "0b") + .end() + .between(1, 16) + .any_of_chars("0123456789abcdefABCDEF") + .end_of_input() + ) + + +def _measure_call_wall_clock(action, iterations): + start = time.perf_counter() + for _ in range(iterations): + action() + return time.perf_counter() - start + + def test_two_no_kwargs_to_regex_calls_return_the_same_instance(): builder = RegexBuilder().one_or_more().digit() first = builder.to_regex() @@ -61,3 +86,34 @@ def test_chain_step_yields_a_fresh_cache_slot(): def test_pattern_also_caches_across_repeat_to_regex_calls(): pattern = Pattern().string("hi") assert pattern.to_regex() is pattern.to_regex() + + +def test_warm_to_regex_is_at_least_ten_times_cheaper_than_cold_to_regex(): + warm_iterations = 200 + cold_iterations = 200 + + cold_total = 0.0 + for _ in range(cold_iterations): + builder = _hex_number_builder() + cold_start = time.perf_counter() + builder.to_regex() + cold_total += time.perf_counter() - cold_start + + warm_builder = _hex_number_builder() + warm_builder.to_regex() + warm_total = _measure_call_wall_clock(warm_builder.to_regex, warm_iterations) + + average_cold = cold_total / cold_iterations + average_warm = warm_total / warm_iterations + + assert average_warm * 10 < average_cold, ( + f"cache ratio too weak: warm={average_warm * 1e6:.2f}µs, " + f"cold={average_cold * 1e6:.2f}µs — warm should be < cold / 10." + ) + + +def test_repeat_to_regex_returns_the_same_regex_instance(): + warm_builder = _hex_number_builder() + first = warm_builder.to_regex() + second = warm_builder.to_regex() + assert first is second diff --git a/tests/builder/cold_warm.test.py b/tests/builder/cold_warm.test.py deleted file mode 100644 index 8777f90..0000000 --- a/tests/builder/cold_warm.test.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Deterministic cold/warm ratio gate for the per-instance lazy compile cache. - -Measures the wall-clock cost of the first ``.to_regex()`` call on a fresh -builder (``cold``) against the cost of a repeat call on the same instance -(``warm``). The cache promise from :issue:`141` is that the warm path is -an order of magnitude cheaper than the cold path — a hardware-independent -ratio that gates the cache contract in a way absolute milliseconds cannot. -""" - -from __future__ import annotations - -import time - -from edify import RegexBuilder - - -def _hex_number_builder(): - builder = ( - RegexBuilder() - .start_of_input() - .assert_ahead() - .any_of("0x", "0o", "0b") - .end() - .capture() - .any_of("0x", "0o", "0b") - .end() - .between(1, 16) - .any_of_chars("0123456789abcdefABCDEF") - .end_of_input() - ) - return builder - - -def _measure_call_wall_clock(action, iterations): - start = time.perf_counter() - for _ in range(iterations): - action() - return time.perf_counter() - start - - -def test_warm_to_regex_is_at_least_ten_times_cheaper_than_cold_to_regex(): - warm_iterations = 200 - cold_iterations = 200 - - cold_total = 0.0 - for _ in range(cold_iterations): - builder = _hex_number_builder() - cold_start = time.perf_counter() - builder.to_regex() - cold_total += time.perf_counter() - cold_start - - warm_builder = _hex_number_builder() - warm_builder.to_regex() - warm_total = _measure_call_wall_clock(warm_builder.to_regex, warm_iterations) - - average_cold = cold_total / cold_iterations - average_warm = warm_total / warm_iterations - - assert average_warm * 10 < average_cold, ( - f"cache ratio too weak: warm={average_warm * 1e6:.2f}µs, " - f"cold={average_cold * 1e6:.2f}µs — warm should be < cold / 10." - ) - - -def test_repeat_to_regex_returns_the_same_regex_instance(): - warm_builder = _hex_number_builder() - first = warm_builder.to_regex() - second = warm_builder.to_regex() - assert first is second diff --git a/tests/builder/flags.test.py b/tests/builder/flags.test.py index 02e875e..704065f 100644 --- a/tests/builder/flags.test.py +++ b/tests/builder/flags.test.py @@ -4,6 +4,7 @@ import re import sys import pytest +import regex as regex_module from edify import Pattern, RegexBuilder @@ -72,38 +73,32 @@ def test_multiple_kwargs_combine(): assert compiled.compiled.flags & re.S == re.S -def _regex_module(): - import regex - - return regex - - def test_regex_engine_ignore_case_kwarg_enables_the_flag(): - regex = _regex_module() + regex = regex_module compiled = RegexBuilder().string("hi").to_regex(engine="regex", ignore_case=True) assert compiled.compiled.flags & regex.I == regex.I def test_regex_engine_multiline_kwarg_enables_the_flag(): - regex = _regex_module() + regex = regex_module compiled = RegexBuilder().string("hi").to_regex(engine="regex", multiline=True) assert compiled.compiled.flags & regex.M == regex.M def test_regex_engine_dotall_kwarg_enables_the_flag(): - regex = _regex_module() + regex = regex_module compiled = RegexBuilder().string("hi").to_regex(engine="regex", dotall=True) assert compiled.compiled.flags & regex.S == regex.S def test_regex_engine_ascii_only_kwarg_enables_the_flag(): - regex = _regex_module() + regex = regex_module compiled = RegexBuilder().string("hi").to_regex(engine="regex", ascii_only=True) assert compiled.compiled.flags & regex.A == regex.A def test_regex_engine_verbose_kwarg_enables_the_flag(): - regex = _regex_module() + regex = regex_module compiled = RegexBuilder().string("hi").to_regex(engine="regex", verbose=True) assert compiled.compiled.flags & regex.X == regex.X @@ -115,3 +110,17 @@ def test_regex_engine_verbose_kwarg_enables_the_flag(): def test_regex_engine_debug_kwarg_compiles_without_error(): compiled = RegexBuilder().string("hi").to_regex(engine="regex", debug=True) assert compiled.source == "hi" + + +def test_regex_engine_debug_flag_is_forwarded_to_regex_module_bitmask(monkeypatch): + captured_flags: list[int] = [] + original_compile = regex_module.compile + + def capturing_compile(pattern, flags=0): + captured_flags.append(flags) + return original_compile(pattern) + + monkeypatch.setattr(regex_module, "compile", capturing_compile) + RegexBuilder().string("hi").to_regex(engine="regex", debug=True) + assert captured_flags + assert captured_flags[0] & regex_module.DEBUG == regex_module.DEBUG diff --git a/tests/builder/lookbehind.test.py b/tests/builder/lookbehind.test.py index f16a6cc..5e101f3 100644 --- a/tests/builder/lookbehind.test.py +++ b/tests/builder/lookbehind.test.py @@ -1,5 +1,7 @@ """Tests for lookbehind width behavior across the ``re`` and ``regex`` engines.""" +import re + import pytest from edify import RegexBuilder @@ -42,14 +44,10 @@ def test_fixed_width_lookbehind_still_works_under_re(): def test_variable_width_lookbehind_error_chains_the_underlying_pattern_error(): with pytest.raises(VariableWidthLookbehindNotSupportedError) as excinfo: _variable_width_lookbehind_builder().to_regex(engine="re") - import re - assert isinstance(excinfo.value.__cause__, re.error) def test_re_engine_still_surfaces_other_pattern_errors_unchanged(monkeypatch): - import re - def raise_other_error(_pattern, flags=0): raise re.error("some other syntax error") diff --git a/tests/builder/insertion_order.test.py b/tests/builder/ordering.test.py index 4feae65..4feae65 100644 --- a/tests/builder/insertion_order.test.py +++ b/tests/builder/ordering.test.py diff --git a/tests/builder/passthrough.test.py b/tests/builder/passthrough.test.py index a2e77b5..e149052 100644 --- a/tests/builder/passthrough.test.py +++ b/tests/builder/passthrough.test.py @@ -1,6 +1,9 @@ """Tests for the pass-through branches when subexpression anchors merge without conflict.""" +import pytest + from edify import RegexBuilder +from edify.errors.structure import CannotCallSubexpressionError def test_start_of_input_merges_into_parent_without_existing_start(): @@ -18,10 +21,6 @@ def test_end_of_input_merges_into_parent_without_existing_end(): def test_subexpression_called_with_unfinished_expression_raises(): - import pytest - - from edify.errors.structure import CannotCallSubexpressionError - unfinished_sub = RegexBuilder().capture().digit() parent = RegexBuilder() with pytest.raises(CannotCallSubexpressionError): diff --git a/tests/builder/validation.test.py b/tests/builder/validation.test.py index 53128c5..a1b57d7 100644 --- a/tests/builder/validation.test.py +++ b/tests/builder/validation.test.py @@ -14,6 +14,7 @@ from edify.errors.anchors import ( ) from edify.errors.input import ( MustBeAStringError, + MustBeInstanceError, MustBeIntegerGreaterThanZeroError, MustBeLessThanError, MustBeOneCharacterError, @@ -22,6 +23,7 @@ from edify.errors.input import ( MustHaveASmallerValueError, ) from edify.errors.naming import NamedGroupDoesNotExistError +from edify.errors.structure import CannotCallSubexpressionError def test_start_of_input_twice_raises(): @@ -140,23 +142,17 @@ def test_named_back_reference_undeclared_raises(): def test_to_regex_string_with_open_frame_raises(): - from edify.errors.structure import CannotCallSubexpressionError - unfinished = RegexBuilder().capture().digit() with pytest.raises(CannotCallSubexpressionError): unfinished.to_regex_string() def test_to_regex_with_open_frame_raises(): - from edify.errors.structure import CannotCallSubexpressionError - unfinished = RegexBuilder().capture().digit() with pytest.raises(CannotCallSubexpressionError): unfinished.to_regex() def test_subexpression_non_builder_raises(): - from edify.errors.input import MustBeInstanceError - with pytest.raises(MustBeInstanceError): RegexBuilder().subexpression("not a builder") diff --git a/tests/compile/redos.test.py b/tests/compile/redos.test.py index c4e1ba9..91786bd 100644 --- a/tests/compile/redos.test.py +++ b/tests/compile/redos.test.py @@ -44,25 +44,29 @@ def test_warning_message_names_both_quantifiers(): def test_bounded_quantifier_does_not_trigger_the_warning(recwarn): builder = RegexBuilder().exactly(3).group().exactly(2).digit().end() builder.to_regex() - assert not any(isinstance(warning.message, ReDoSWarning) for warning in recwarn) + is_redos_flags = [isinstance(warning.message, ReDoSWarning) for warning in recwarn] + assert not any(is_redos_flags) def test_grouped_quantifier_over_a_composite_group_does_not_trigger(recwarn): builder = RegexBuilder().one_or_more().group().digit().letter().end() builder.to_regex() - assert not any(isinstance(warning.message, ReDoSWarning) for warning in recwarn) + is_redos_flags = [isinstance(warning.message, ReDoSWarning) for warning in recwarn] + assert not any(is_redos_flags) def test_grouped_quantifier_over_a_single_non_quantifier_child_does_not_trigger(recwarn): builder = RegexBuilder().one_or_more().group().digit().end() builder.to_regex() - assert not any(isinstance(warning.message, ReDoSWarning) for warning in recwarn) + is_redos_flags = [isinstance(warning.message, ReDoSWarning) for warning in recwarn] + assert not any(is_redos_flags) def test_sequential_unbounded_quantifiers_do_not_trigger_the_warning(recwarn): builder = RegexBuilder().one_or_more().digit().one_or_more().letter() builder.to_regex() - assert not any(isinstance(warning.message, ReDoSWarning) for warning in recwarn) + is_redos_flags = [isinstance(warning.message, ReDoSWarning) for warning in recwarn] + assert not any(is_redos_flags) def test_warning_only_fires_once_per_construct_within_a_single_terminal_call(): diff --git a/tests/discipline/coverage.test.py b/tests/discipline/coverage.test.py index a534424..cc90db1 100644 --- a/tests/discipline/coverage.test.py +++ b/tests/discipline/coverage.test.py @@ -36,7 +36,8 @@ def test_edify_package_tree_contains_measurable_python_source(): assert python_files, ( "no Python files found under edify/ — the coverage source directory is empty." ) - total_source_bytes = sum(python_file.stat().st_size for python_file in python_files) + file_sizes = [python_file.stat().st_size for python_file in python_files] + total_source_bytes = sum(file_sizes) assert total_source_bytes > 0, ( "every file under edify/ is empty — coverage would measure nothing across the tree." ) diff --git a/tests/discipline/orphans.test.py b/tests/discipline/orphans.test.py index a0a3150..6596680 100644 --- a/tests/discipline/orphans.test.py +++ b/tests/discipline/orphans.test.py @@ -5,7 +5,7 @@ Runs three checks, one per snapshot root: * ``tests/snapshots/library/<name>.regex`` — must correspond to a :class:`edify.Pattern` exported from :mod:`edify.library`. * ``tests/snapshots/fixtures/<name>.regex`` — must correspond to a fixture - registered in ``tests/fixtures/edge_cases.test.py``. + registered in ``tests/fixtures/fixtures.test.py``. * ``tests/snapshots/docs/<file>/<block>.regex`` — must correspond to a ``.. code-block:: python`` block at that line in that RST. @@ -44,7 +44,7 @@ def test_every_fixture_snapshot_has_a_registered_fixture(): orphaned_snapshots = snapshot_stems - fixture_names assert orphaned_snapshots == set(), ( f"fixture snapshot files without a registered fixture in " - f"tests/fixtures/edge_cases.test.py: {sorted(orphaned_snapshots)}" + f"tests/fixtures/fixtures.test.py: {sorted(orphaned_snapshots)}" ) @@ -70,7 +70,7 @@ def _library_pattern_names() -> set[str]: def _registered_fixture_names() -> set[str]: - fixture_source = (_REPO_ROOT / "tests" / "fixtures" / "edge_cases.test.py").read_text() + fixture_source = (_REPO_ROOT / "tests" / "fixtures" / "fixtures.test.py").read_text() return _fixture_names_from_source(fixture_source) diff --git a/tests/docs/snapshots.test.py b/tests/docs/snapshots.test.py index 5b97ddb..b1c223b 100644 --- a/tests/docs/snapshots.test.py +++ b/tests/docs/snapshots.test.py @@ -72,7 +72,7 @@ def _discover_blocks(): yield rst_path, block_start, block_source, relative_stem -def _snapshot_bodies_for_block(namespace: dict, pre_exec_names: frozenset[str]) -> str: +def _snapshot_bodies_for_block(namespace, pre_exec_names: frozenset[str]) -> str: interesting_pairs = [] for identifier, value in namespace.items(): if identifier in pre_exec_names: @@ -133,8 +133,8 @@ def test_doc_code_block_produces_the_snapshotted_regex( assert_snapshot(rendered, snapshot_path) -def _prepared_exec_namespace() -> dict[str, object]: - namespace: dict[str, object] = {"edify": edify, "Pattern": Pattern, "Regex": Regex, "re": re} +def _prepared_exec_namespace(): + namespace = {"edify": edify, "Pattern": Pattern, "Regex": Regex, "re": re} for edify_export in dir(edify): if edify_export.startswith("_"): continue diff --git a/tests/errors/frames.test.py b/tests/errors/frames.test.py index b4077ba..0ae0c1b 100644 --- a/tests/errors/frames.test.py +++ b/tests/errors/frames.test.py @@ -3,6 +3,7 @@ import pytest from edify import RegexBuilder +from edify.errors.quantifier import DanglingQuantifierError from edify.errors.structure import CannotCallSubexpressionError @@ -53,8 +54,6 @@ def test_subexpression_lists_open_frames_from_the_argument_not_the_receiver(): def test_dangling_quantifier_reports_the_specific_pending_quantifier_name(): - from edify.errors.quantifier import DanglingQuantifierError - with pytest.raises(DanglingQuantifierError) as excinfo: RegexBuilder().exactly(3).to_regex_string() text = str(excinfo.value) @@ -62,8 +61,6 @@ def test_dangling_quantifier_reports_the_specific_pending_quantifier_name(): def test_dangling_quantifier_falls_back_to_generic_summary_when_name_unknown(): - from edify.errors.quantifier import DanglingQuantifierError - error = DanglingQuantifierError() text = str(error) assert "dangling quantifier" in text diff --git a/tests/errors/errors.test.py b/tests/errors/generic.test.py index 82eb8fe..82eb8fe 100644 --- a/tests/errors/errors.test.py +++ b/tests/errors/generic.test.py diff --git a/tests/fixtures/edge_cases.test.py b/tests/fixtures/fixtures.test.py index 18200b6..24b5a6f 100644 --- a/tests/fixtures/edge_cases.test.py +++ b/tests/fixtures/fixtures.test.py @@ -12,6 +12,7 @@ from pathlib import Path import pytest from edify import Pattern, RegexBuilder +from edify.compile.redos import ReDoSWarning from edify.testing import assert_snapshot _SNAPSHOT_ROOT = Path(__file__).parent.parent / "snapshots" / "fixtures" @@ -124,7 +125,5 @@ def test_edge_case_fixture_emits_the_snapshotted_regex(fixture_name, builder_fac def test_nested_unbounded_quantifier_fixture_raises_the_redos_warning(): - from edify.compile.redos import ReDoSWarning - with pytest.warns(ReDoSWarning): _nested_unbounded_quantifier_redos().to_regex() diff --git a/tests/integrations/django.test.py b/tests/integrations/django.test.py new file mode 100644 index 0000000..871ceed --- /dev/null +++ b/tests/integrations/django.test.py @@ -0,0 +1,38 @@ +"""Tests for :mod:`edify.integrations.django`.""" + +import pytest +from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator + +from edify import Pattern +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(): + validator = pattern_validator(_DIGITS_PATTERN) + assert isinstance(validator, RegexValidator) + assert validator.regex.pattern == _DIGITS_PATTERN.to_regex_string() + + +def test_pattern_validator_accepts_matching_input_without_raising(): + validator = pattern_validator(_DIGITS_PATTERN) + validator("42") + + +def test_pattern_validator_raises_django_validation_error_on_non_matching_input(): + validator = pattern_validator(_DIGITS_PATTERN) + with pytest.raises(ValidationError): + validator("abc") + + +def test_pattern_validator_carries_the_default_error_message_reproducing_the_regex(): + validator = pattern_validator(_DIGITS_PATTERN) + assert _DIGITS_PATTERN.to_regex_string() in validator.message + + +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" diff --git a/tests/integrations/fastapi.test.py b/tests/integrations/fastapi.test.py new file mode 100644 index 0000000..f2dd45a --- /dev/null +++ b/tests/integrations/fastapi.test.py @@ -0,0 +1,43 @@ +"""Tests for :mod:`edify.integrations.fastapi`.""" + +import fastapi.params + +from edify import Pattern +from edify.integrations.fastapi import pattern_path, pattern_query + +_UUID_PATTERN = ( + Pattern() + .start_of_input() + .exactly(8) + .any_of() + .range("0", "9") + .range("a", "f") + .end() + .end_of_input() +) + + +def test_pattern_query_returns_a_fastapi_query_pinned_to_the_pattern_regex(): + query_default = pattern_query(_UUID_PATTERN) + assert isinstance(query_default, fastapi.params.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" + + +def test_pattern_query_default_differs_from_an_explicit_default(): + without_default = pattern_query(_UUID_PATTERN) + with_default = pattern_query(_UUID_PATTERN, default="deadbeef") + assert without_default.default != with_default.default + + +def test_pattern_path_returns_a_fastapi_path_pinned_to_the_pattern_regex(): + path_default = pattern_path(_UUID_PATTERN) + assert isinstance(path_default, fastapi.params.Path) + + +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 new file mode 100644 index 0000000..a3c3164 --- /dev/null +++ b/tests/integrations/pydantic.test.py @@ -0,0 +1,46 @@ +"""Tests for :mod:`edify.integrations.pydantic`.""" + +from typing import Annotated + +import pytest +from pydantic import AfterValidator, BaseModel, ValidationError + +from edify import Pattern +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() + + +def test_pattern_validator_accepts_a_matching_string_and_returns_it_unchanged(): + validate = pattern_validator(_DIGITS_PATTERN) + assert validate("42") == "42" + + +def test_pattern_validator_raises_pattern_did_not_match_on_non_matching_input(): + validate = pattern_validator(_DIGITS_PATTERN) + with pytest.raises(PatternDidNotMatchError, match="does not match"): + validate("abc") + + +def test_pattern_did_not_match_error_is_a_value_error_subclass(): + assert issubclass(PatternDidNotMatchError, ValueError) + + +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_validator_composes_into_pydantic_model_field_via_annotated_after_validator(): + validator_callable = pattern_validator(_DIGITS_PATTERN) + + class Payload(BaseModel): + value: Annotated[str, AfterValidator(validator_callable)] + + assert Payload(value="42").value == "42" + with pytest.raises(ValidationError): + Payload(value="abc") diff --git a/tests/property/emitted.test.py b/tests/property/emitted.test.py index 6c66e18..2ece703 100644 --- a/tests/property/emitted.test.py +++ b/tests/property/emitted.test.py @@ -20,7 +20,8 @@ def test_concatenated_string_calls_emit_the_re_escape_concatenation(literal_segm for literal in literal_segments: builder = builder.string(literal) emitted = builder.to_regex_string() - reference = "".join(re.escape(literal) for literal in literal_segments) + escaped_segments = [re.escape(literal) for literal in literal_segments] + reference = "".join(escaped_segments) assert emitted == reference diff --git a/tests/result/match.test.py b/tests/result/match.test.py index b7f7514..9529467 100644 --- a/tests/result/match.test.py +++ b/tests/result/match.test.py @@ -70,7 +70,8 @@ def test_match_repr_contains_span_and_text(): def test_finditer_yields_wrapped_matches(): pattern = _username_pattern().to_regex() hits = list(pattern.finditer("hi @heidi and @ivan")) - assert all(isinstance(item, Match) for item in hits) + types_are_match = [isinstance(item, Match) for item in hits] + assert all(types_are_match) assert [item.captures.username for item in hits] == ["heidi", "ivan"] @@ -115,3 +116,86 @@ def test_fullmatch_returns_wrapped_match_when_full_input_matches(): def test_fullmatch_returns_none_when_input_is_only_partial(): pattern = _username_pattern().to_regex() assert pattern.fullmatch("hi @leo") is None + + +def test_match_forwarded_group_accepts_named_and_positional_selectors(): + result = _username_pattern().search("hi @meg") + assert result is not None + assert result.group() == "@meg" + assert result.group(0) == "@meg" + assert result.group(1) == "meg" + + +def test_match_forwarded_groups_returns_the_positional_group_tuple(): + result = _username_pattern().search("hi @nora") + assert result is not None + assert result.groups() == ("nora",) + + +def test_match_forwarded_groups_uses_default_for_unmatched_groups(): + optional_capture = ( + RegexBuilder() + .start_of_input() + .capture() + .string("hi") + .end() + .optional() + .capture() + .string("bye") + .end() + .end_of_input() + ) + result = optional_capture.match("hi") + assert result is not None + assert result.groups(default="missing") == ("hi", "missing") + + +def test_match_forwarded_groupdict_defaults_to_none_for_absent_named_groups(): + result = _username_pattern().search("hi @olive") + assert result is not None + assert result.groupdict() == {"username": "olive"} + + +def test_match_forwarded_groupdict_accepts_a_default_string(): + optional_named = ( + RegexBuilder() + .start_of_input() + .string("hi") + .optional() + .named_capture("nick") + .one_or_more() + .letter() + .end() + .end_of_input() + ) + result = optional_named.match("hi") + assert result is not None + assert result.groupdict(default="none") == {"nick": "none"} + + +def test_match_forwarded_start_end_span_and_expand_return_the_expected_values(): + result = _username_pattern().search("hi @pete") + assert result is not None + assert result.start() == 3 + assert result.end() == 8 + assert result.span() == (3, 8) + assert result.expand(r"\g<username>") == "pete" + + +def test_match_forwarded_re_pos_endpos_string_lastindex_lastgroup_reflect_the_compiled_pattern(): + pattern = _username_pattern().to_regex() + result = pattern.search("hi @quinn there") + assert result is not None + assert result.re is pattern.compiled + assert result.string == "hi @quinn there" + assert result.pos == 0 + assert result.endpos > 0 + assert result.lastindex == 1 + assert result.lastgroup == "username" + + +def test_match_fallback_getattr_raises_attribute_error_on_unknown_name(): + result = _username_pattern().search("hi @rita") + assert result is not None + with pytest.raises(AttributeError, match="no attribute"): + _ = result.nonexistent diff --git a/tests/serialize/errors.test.py b/tests/serialize/errors.test.py index 68adaac..1b7dd39 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,11 +52,17 @@ 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]") +def test_incompatible_schema_version_with_composite_value_stringifies_it(): + document = {"edify": [1, 2, 3], "pattern": {"kind": "root", "children": []}} + with pytest.raises(IncompatibleSchemaVersionError): + Pattern.from_dict(document) + + def test_pattern_key_not_a_root_element_produces_empty_pattern(): document = {"edify": 0, "pattern": {"kind": "digit"}} restored = Pattern.from_dict(document) 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) @@ -16,6 +16,46 @@ wheels = [ ] [[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] name = "babel" version = "2.18.0" source = { registry = "https://pypi.org/simple" } @@ -221,6 +261,40 @@ toml = [ ] [[package]] +name = "django" +version = "5.2.16" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/13/1e5e3e4c15dcecb04281b3cb2a46a4670e1cef131068e202f6040df19224/django-5.2.16-py3-none-any.whl", hash = "sha256:04f354bf9d807a86ad1a8392fe3808d362358a8eafc322848e0e43e59b24371d", size = 8311943, upload-time = "2026-07-07T13:52:11.223Z" }, +] + +[[package]] +name = "django" +version = "6.0.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/ec/1ce5334b6a2c52ce619c23a0be8d366a57a0e080ebb2d88266e5c849157c/django-6.0.7-py3-none-any.whl", hash = "sha256:a037427c2288443a8c02a1b02295a31c239663aa682bc50b1976afb7cf6a769e", size = 8373344, upload-time = "2026-07-07T13:51:20.007Z" }, +] + +[[package]] name = "docutils" version = "0.22.4" source = { registry = "https://pypi.org/simple" } @@ -234,9 +308,27 @@ name = "edify" source = { editable = "." } [package.optional-dependencies] +all = [ + { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "fastapi" }, + { name = "graphviz" }, + { name = "pydantic" }, + { name = "regex" }, +] +django = [ + { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +fastapi = [ + { name = "fastapi" }, +] graphviz = [ { name = "graphviz" }, ] +pydantic = [ + { name = "pydantic" }, +] regex = [ { name = "regex" }, ] @@ -246,9 +338,13 @@ bench = [ { name = "pytest-benchmark" }, ] dev = [ + { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "fastapi" }, { name = "graphviz" }, { name = "hypothesis" }, { name = "mypy" }, + { name = "pydantic" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -263,17 +359,28 @@ docs = [ [package.metadata] requires-dist = [ + { name = "django", marker = "extra == 'all'", specifier = ">=4.2" }, + { name = "django", marker = "extra == 'django'", specifier = ">=4.2" }, + { name = "fastapi", marker = "extra == 'all'", specifier = ">=0.100" }, + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100" }, + { name = "graphviz", marker = "extra == 'all'", specifier = ">=0.20" }, { name = "graphviz", marker = "extra == 'graphviz'", specifier = ">=0.20" }, + { name = "pydantic", marker = "extra == 'all'", specifier = ">=2.0" }, + { name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.0" }, + { name = "regex", marker = "extra == 'all'", specifier = ">=2024.11.6" }, { name = "regex", marker = "extra == 'regex'", specifier = ">=2024.11.6" }, ] -provides-extras = ["graphviz", "regex"] +provides-extras = ["all", "django", "fastapi", "graphviz", "pydantic", "regex"] [package.metadata.requires-dev] bench = [{ name = "pytest-benchmark", specifier = ">=4.0" }] dev = [ + { name = "django", specifier = ">=4.2" }, + { name = "fastapi", specifier = ">=0.100" }, { name = "graphviz", specifier = ">=0.20" }, { name = "hypothesis", specifier = ">=6.100" }, { name = "mypy", specifier = "==1.13.0" }, + { name = "pydantic", specifier = ">=2.0" }, { name = "pyright", specifier = "==1.1.389" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" }, @@ -286,6 +393,22 @@ docs = [ ] [[package]] +name = "fastapi" +version = "0.139.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, +] + +[[package]] name = "graphviz" version = "0.21" source = { registry = "https://pypi.org/simple" } @@ -493,6 +616,123 @@ wheels = [ ] [[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } @@ -873,6 +1113,28 @@ wheels = [ ] [[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] name = "tomli" version = "2.4.1" source = { registry = "https://pypi.org/simple" } @@ -936,6 +1198,27 @@ wheels = [ ] [[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] name = "urllib3" version = "2.7.0" source = { registry = "https://pypi.org/simple" } |
