From 51df7ac0252c39bd83740514e75ab0632762b749 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Mon, 19 Oct 2026 14:58:33 +0200 Subject: [PATCH] MPT-26929 add mongo support --- .github/workflows/pr-build-merge.yaml | 10 + README.md | 81 ++++- docker-compose.yaml | 19 ++ pyproject.toml | 4 +- src/requela/builders/__init__.py | 3 + src/requela/builders/base.py | 23 +- src/requela/builders/django.py | 20 +- src/requela/builders/mongo.py | 393 ++++++++++++++++++++++++ src/requela/builders/sqlalchemy.py | 31 +- src/requela/rules.py | 50 +-- tests/django/rules.py | 7 + tests/django/test_rules.py | 25 +- tests/integration/mongo/__init__.py | 0 tests/integration/mongo/conftest.py | 158 ++++++++++ tests/integration/mongo/test_queries.py | 200 ++++++++++++ tests/mongo/__init__.py | 0 tests/mongo/models.py | 85 +++++ tests/mongo/rules.py | 94 ++++++ tests/mongo/test_comparison.py | 187 +++++++++++ tests/mongo/test_logical.py | 59 ++++ tests/mongo/test_ordering.py | 25 ++ tests/mongo/test_relationship.py | 87 ++++++ tests/mongo/test_rules.py | 137 +++++++++ tests/settings.py | 6 +- tests/sqlalchemy/rules.py | 14 + tests/sqlalchemy/test_rules.py | 44 +++ uv.lock | 232 +++++++++++++- 27 files changed, 1948 insertions(+), 46 deletions(-) create mode 100644 src/requela/builders/mongo.py create mode 100644 tests/integration/mongo/__init__.py create mode 100644 tests/integration/mongo/conftest.py create mode 100644 tests/integration/mongo/test_queries.py create mode 100644 tests/mongo/__init__.py create mode 100644 tests/mongo/models.py create mode 100644 tests/mongo/rules.py create mode 100644 tests/mongo/test_comparison.py create mode 100644 tests/mongo/test_logical.py create mode 100644 tests/mongo/test_ordering.py create mode 100644 tests/mongo/test_relationship.py create mode 100644 tests/mongo/test_rules.py diff --git a/.github/workflows/pr-build-merge.yaml b/.github/workflows/pr-build-merge.yaml index 0fd10e2..d41ca58 100644 --- a/.github/workflows/pr-build-merge.yaml +++ b/.github/workflows/pr-build-merge.yaml @@ -29,6 +29,15 @@ jobs: --health-interval 2s --health-timeout 5s --health-retries 15 + mongo: + image: mongo:7 + ports: + - 27017:27017 + options: >- + --health-cmd "mongosh --quiet --eval 'db.adminCommand(\"ping\")'" + --health-interval 2s + --health-timeout 5s + --health-retries 15 steps: - uses: actions/checkout@v4 @@ -58,6 +67,7 @@ jobs: run: uv run pytest env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/requela + MONGO_URL: mongodb://localhost:27017 - name: Compute added/removed lines for notification if: ${{ github.event_name == 'pull_request' }} diff --git a/README.md b/README.md index 23a9ad7..2406690 100644 --- a/README.md +++ b/README.md @@ -123,10 +123,89 @@ class UserRules(ModelRQLRules): query = rules.build_query("and(eq(name,John),gt(age,30),eq(events.born.at,2024-01-01))&order_by(name,-age)") ``` +### MongoDB + +MongoDB collections are described with pydantic models or dataclasses. Install PyMongo (4.13 or +later) alongside requela: + +```bash +pip install requela pymongo +``` + +Set the builder explicitly with `__builder__`, as there is no ORM model to infer it from. An +abstract base class shares it with every rules class, including the nested ones: + +```python +from datetime import datetime + +from bson import ObjectId +from pydantic import BaseModel, ConfigDict, Field + +from requela import FieldRule, ModelRQLRules, RelationshipRule +from requela.builders.mongo import MongoQueryBuilder + + +class Tag(BaseModel): + name: str + value: str + + +class Event(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + id: ObjectId = Field(alias="_id") + time: datetime + level: str + tags: list[Tag] = [] + + +class MongoRules(ModelRQLRules): + __abstract__ = True + __builder__ = MongoQueryBuilder + + +class TagRules(MongoRules): + __model__ = Tag + + name = FieldRule() + value = FieldRule() + + +class EventRules(MongoRules): + __model__ = Event + + id = FieldRule() + time = FieldRule() + level = FieldRule() + tags = RelationshipRule(rules=TagRules()) + + +query = EventRules().build_query("and(eq(level,ERROR),any(tags,eq(tags.name,env)))&order_by(-time)") +# query.filter == {"$and": [{"level": "ERROR"}, {"tags": {"$elemMatch": {"name": "env"}}}]} +# query.sort == [("time", -1)] + +documents = collection.find(query.filter).sort(query.sort).to_list() # pymongo.MongoClient +documents = await collection.find(query.filter).sort(query.sort).to_list() # pymongo.AsyncMongoClient +``` + +The builder never runs queries, so its output works with both the blocking and the asyncio +PyMongo clients. Fields are filtered on their stored keys: pydantic aliases (`id` stored as +`_id`) or, for dataclasses, `field(metadata={"mongo_key": "_id"})`. + +Things to know: + +* Fields inside a list of nested models can only be filtered through `any()`, which matches + one single element (`$elemMatch`). +* `null()` also matches missing fields, and `ne`, `out` and `not` also match documents where + the field is null or missing. +* Quote ObjectIds that start with a digit: `eq(id,'65f0c0ffee0ddba11ad0beef')`. +* When the stored format differs from the model type, convert the value with + `value_converter`, e.g. a datetime stored as a Unix timestamp: + `time = FieldRule(value_converter=lambda value: int(value.timestamp()))`. + ## License ReQueLa is licensed under the [Apache 2.0 License](https://www.apache.org/licenses/LICENSE-2.0) and is copyright SoftwareOne AG. - diff --git a/docker-compose.yaml b/docker-compose.yaml index 3c50381..418cc7d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,14 +16,30 @@ services: timeout: 5s retries: 15 + mongo: + image: mongo:${MONGO_VERSION:-7} + ports: + - "${MONGO_PORT:-27017}:27017" + # Throwaway test database: keep the data in memory + tmpfs: + - /data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] + interval: 2s + timeout: 5s + retries: 15 + tests: build: . command: pytest -v environment: DATABASE_URL: postgresql://postgres:postgres@postgres:5432/requela + MONGO_URL: mongodb://mongo:27017 depends_on: postgres: condition: service_healthy + mongo: + condition: service_healthy bash: @@ -34,6 +50,9 @@ services: tty: true environment: DATABASE_URL: postgresql://postgres:postgres@postgres:5432/requela + MONGO_URL: mongodb://mongo:27017 depends_on: postgres: condition: service_healthy + mongo: + condition: service_healthy diff --git a/pyproject.toml b/pyproject.toml index 91d15b8..28b7b63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ authors = [ license = {file = "LICENSE.txt"} requires-python = ">=3.12,<4" -keywords = ["rql", "sqlalchemy", "query", "filter", "orm"] +keywords = ["rql", "sqlalchemy", "django", "mongodb", "query", "filter", "orm"] classifiers = [ "Development Status :: 4 - Beta", @@ -48,6 +48,8 @@ dev = [ "django>=5.1.6", "pre-commit>=4.1.0", "pytest-randomly>=3.16.0", + "pymongo>=4.13", + "pydantic>=2", ] diff --git a/src/requela/builders/__init__.py b/src/requela/builders/__init__.py index dcc06fc..f16ebfd 100644 --- a/src/requela/builders/__init__.py +++ b/src/requela/builders/__init__.py @@ -9,6 +9,7 @@ def get_builder_for_model( resolve_alias_callback: Callable | None = None, validate_operator_and_field_callback: Callable | None = None, validate_ordering_callback: Callable | None = None, + get_field_rule_callback: Callable | None = None, ): """Returns appropriate builder based on model type""" @@ -21,6 +22,7 @@ def get_builder_for_model( resolve_alias_callback=resolve_alias_callback, validate_operator_and_field_callback=validate_operator_and_field_callback, validate_ordering_callback=validate_ordering_callback, + get_field_rule_callback=get_field_rule_callback, ) # Django model elif hasattr(model, "_meta"): # pragma: no branch @@ -31,6 +33,7 @@ def get_builder_for_model( resolve_alias_callback=resolve_alias_callback, validate_operator_and_field_callback=validate_operator_and_field_callback, validate_ordering_callback=validate_ordering_callback, + get_field_rule_callback=get_field_rule_callback, ) else: # pragma: no cover raise ValueError(f"Unsupported model type: {type(model)}") diff --git a/src/requela/builders/base.py b/src/requela/builders/base.py index b00ab7a..4ff38b6 100644 --- a/src/requela/builders/base.py +++ b/src/requela/builders/base.py @@ -2,12 +2,19 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Sequence from datetime import date, datetime +from enum import Enum from functools import partial from typing import Any from lark.exceptions import VisitError -from requela.dataclasses import FilterExpression, Operator, OperatorFunctions, OrderByExpression +from requela.dataclasses import ( + DEFAULT_OPERATORS, + FilterExpression, + Operator, + OperatorFunctions, + OrderByExpression, +) from requela.parser import parse from requela.transformer import RQLTransformer @@ -21,11 +28,13 @@ def __init__( resolve_alias_callback: Callable | None = None, validate_operator_and_field_callback: Callable | None = None, validate_ordering_callback: Callable | None = None, + get_field_rule_callback: Callable | None = None, ): self.model_class = model_class self.resolve_alias_callback = resolve_alias_callback self.validate_operator_and_field_callback = validate_operator_and_field_callback self.validate_ordering_callback = validate_ordering_callback + self.get_field_rule_callback = get_field_rule_callback self.transformer = RQLTransformer( OperatorFunctions( and_op=self.apply_and, @@ -129,6 +138,18 @@ def validate_operator_and_field(self, field: str, operator: Operator) -> None: if self.validate_operator_and_field_callback: return self.validate_operator_and_field_callback(field, operator) + def get_default_operators(self, field_type: type) -> set[Operator] | None: + if issubclass(field_type, Enum): + return DEFAULT_OPERATORS.get(Enum) + return DEFAULT_OPERATORS.get(field_type) + + def convert_value(self, prop: str, value: Any) -> Any: + """Applies the field rule's `value_converter`, if any, to a value already cast.""" + if value is None or not self.get_field_rule_callback: + return value + converter = getattr(self.get_field_rule_callback(prop), "value_converter", None) + return converter(value) if converter else value + def resolve_alias(self, alias: str) -> str: if self.resolve_alias_callback: return self.resolve_alias_callback(alias) diff --git a/src/requela/builders/django.py b/src/requela/builders/django.py index 1982e8b..d84e665 100644 --- a/src/requela/builders/django.py +++ b/src/requela/builders/django.py @@ -18,12 +18,14 @@ def __init__( resolve_alias_callback: Callable | None = None, validate_operator_and_field_callback: Callable | None = None, validate_ordering_callback: Callable | None = None, + get_field_rule_callback: Callable | None = None, ): super().__init__( model_class, resolve_alias_callback=resolve_alias_callback, validate_operator_and_field_callback=validate_operator_and_field_callback, validate_ordering_callback=validate_ordering_callback, + get_field_rule_callback=get_field_rule_callback, ) self.prefetches: list[str] = [] @@ -70,30 +72,32 @@ def apply_not(self, condition: Q) -> Q: def apply_eq(self, prop: str, value: str | bool | date | datetime | int | float | None) -> Q: if value is None: return Q(**{f"{self.resolve_property(prop)}__isnull": True}) - return Q(**{self.resolve_property(prop): value}) + return Q(**{self.resolve_property(prop): self.convert_value(prop, value)}) def apply_ne(self, prop: str, value: str | bool | date | datetime | int | float | None) -> Q: if value is None: return Q(**{f"{self.resolve_property(prop)}__isnull": False}) - return ~Q(**{self.resolve_property(prop): value}) + return ~Q(**{self.resolve_property(prop): self.convert_value(prop, value)}) def apply_gt(self, prop: str, value: date | datetime | int | float) -> Q: - return Q(**{f"{self.resolve_property(prop)}__gt": value}) + return Q(**{f"{self.resolve_property(prop)}__gt": self.convert_value(prop, value)}) def apply_lt(self, prop: str, value: date | datetime | int | float) -> Q: - return Q(**{f"{self.resolve_property(prop)}__lt": value}) + return Q(**{f"{self.resolve_property(prop)}__lt": self.convert_value(prop, value)}) def apply_gte(self, prop: str, value: date | datetime | int | float) -> Q: - return Q(**{f"{self.resolve_property(prop)}__gte": value}) + return Q(**{f"{self.resolve_property(prop)}__gte": self.convert_value(prop, value)}) def apply_lte(self, prop: str, value: date | datetime | int | float) -> Q: - return Q(**{f"{self.resolve_property(prop)}__lte": value}) + return Q(**{f"{self.resolve_property(prop)}__lte": self.convert_value(prop, value)}) def apply_in(self, prop: str, value: Sequence[str] | Sequence[float] | Sequence[int]) -> Q: - return Q(**{f"{self.resolve_property(prop)}__in": value}) + values = [self.convert_value(prop, item) for item in value] + return Q(**{f"{self.resolve_property(prop)}__in": values}) def apply_out(self, prop: str, value: Sequence[str] | Sequence[float] | Sequence[int]) -> Q: - return ~Q(**{f"{self.resolve_property(prop)}__in": value}) + values = [self.convert_value(prop, item) for item in value] + return ~Q(**{f"{self.resolve_property(prop)}__in": values}) def apply_like(self, prop: str, value: str) -> Q: return self.apply_pattern(prop, value, case_insensitive=False) diff --git a/src/requela/builders/mongo.py b/src/requela/builders/mongo.py new file mode 100644 index 0000000..0d27944 --- /dev/null +++ b/src/requela/builders/mongo.py @@ -0,0 +1,393 @@ +import dataclasses +import re +import types +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from datetime import UTC, date, datetime +from decimal import Decimal +from enum import Enum +from functools import cache +from typing import Annotated, Any, Union, get_args, get_origin, get_type_hints + +try: + from bson import Decimal128, ObjectId + from bson.errors import InvalidId +except ImportError as e: # pragma: no cover + raise ImportError("MongoDB support requires pymongo>=4.13: pip install pymongo") from e + +from requela.builders.base import QueryBuilder +from requela.dataclasses import FilterExpression, Operator, OrderByExpression + +LOGICAL_OPERATORS = ("$and", "$or", "$nor") + +OBJECT_ID_OPERATORS = { + Operator.EQ, + Operator.NE, + Operator.GT, + Operator.LT, + Operator.GTE, + Operator.LTE, + Operator.IN, + Operator.OUT, +} + +SCALAR_TYPES = (str, int, float, bool, Decimal, datetime, date) + + +@dataclass +class MongoQuery: + """A MongoDB `find` filter and sort, built from an RQL expression.""" + + filter: dict[str, Any] = field(default_factory=dict) + sort: list[tuple[str, int]] = field(default_factory=list) + + +@dataclass(frozen=True) +class ModelField: + """A model attribute: its stored key, its (unwrapped) type and whether it is a list.""" + + name: str + key: str + type: Any + is_list: bool + + @property + def is_model(self) -> bool: + return is_model(self.type) + + +def is_model(annotation: Any) -> bool: + """True for pydantic models (detected by `model_fields`) and dataclasses.""" + return isinstance(annotation, type) and ( + hasattr(annotation, "model_fields") or dataclasses.is_dataclass(annotation) + ) + + +def is_supported_scalar(annotation: Any) -> bool: + return isinstance(annotation, type) and ( + annotation in SCALAR_TYPES or issubclass(annotation, Enum | ObjectId) + ) + + +def unwrap(annotation: Any) -> tuple[Any, bool]: + """Strips `Annotated`, `X | None` and one list level; returns (type, is_list). + + Types that can't be reduced to a single type (e.g. `int | str`) come back unchanged and + are rejected later as unsupported. + """ + origin = get_origin(annotation) + if origin is Annotated: + return unwrap(get_args(annotation)[0]) + if origin in (Union, types.UnionType): + args = [arg for arg in get_args(annotation) if arg is not type(None)] + return unwrap(args[0]) if len(args) == 1 else (annotation, False) + if origin in (list, Sequence): + item_type, item_is_list = unwrap(get_args(annotation)[0]) + return (annotation, False) if item_is_list else (item_type, True) + return annotation, False + + +@cache +def get_model_fields(model: type) -> dict[str, ModelField]: + """The fields of a pydantic model or dataclass, by attribute name.""" + hints = get_type_hints(model, include_extras=True) + if hasattr(model, "model_fields"): + keys = { + name: info.serialization_alias or info.alias or name + for name, info in model.model_fields.items() + } + else: + keys = {f.name: f.metadata.get("mongo_key", f.name) for f in dataclasses.fields(model)} + + model_fields = {} + for name, key in keys.items(): + field_type, is_list = unwrap(hints[name]) + model_fields[name] = ModelField(name=name, key=key, type=field_type, is_list=is_list) + return model_fields + + +class MongoQueryBuilder(QueryBuilder): + """Builds a `MongoQuery` for a pydantic model or dataclass. It never runs the query.""" + + def __init__( + self, + model_class: Any, + resolve_alias_callback: Callable | None = None, + validate_operator_and_field_callback: Callable | None = None, + validate_ordering_callback: Callable | None = None, + get_field_rule_callback: Callable | None = None, + ): + super().__init__( + model_class, + resolve_alias_callback=resolve_alias_callback, + validate_operator_and_field_callback=validate_operator_and_field_callback, + validate_ordering_callback=validate_ordering_callback, + get_field_rule_callback=get_field_rule_callback, + ) + # Stored key paths mapped to the field names used in the RQL expression, for errors + self.field_names: dict[str, str] = {} + + def get_initial_query(self) -> MongoQuery: + return MongoQuery() + + def get_field_type(self, field: str) -> type: + model_field = self.resolve_path(field)[-1] + if model_field.is_list or not is_supported_scalar(model_field.type): + type_name = getattr(model_field.type, "__name__", repr(model_field.type)) + if model_field.is_list: + type_name = f"list[{type_name}]" + raise TypeError(f"Unsupported type for field '{field}': {type_name}.") + return model_field.type + + def get_default_operators(self, field_type: type) -> set[Operator] | None: + if issubclass(field_type, ObjectId): + return OBJECT_ID_OPERATORS + return super().get_default_operators(field_type) + + def resolve_path(self, path: str) -> list[ModelField]: + """The model fields along a dotted attribute path, from the root model.""" + model_fields = [] + model: Any = self.model_class + for part in path.split("."): + fields = get_model_fields(model) if is_model(model) else {} + if part not in fields: + raise AttributeError( + f"Field '{path}' not found in model '{self.model_class.__name__}'." + ) + model_fields.append(fields[part]) + model = fields[part].type + return model_fields + + def resolve_key(self, prop: str) -> tuple[str, ModelField]: + """The stored key path of a field in the RQL expression, and the field it ends at.""" + model_fields = self.resolve_path(self.resolve_alias(prop)) + key = ".".join(model_field.key for model_field in model_fields) + self.field_names[key] = prop + return key, model_fields[-1] + + def apply_and(self, *conditions: dict[str, Any]) -> dict[str, Any]: + return {"$and": list(conditions)} + + def apply_or(self, *conditions: dict[str, Any]) -> dict[str, Any]: + return {"$or": list(conditions)} + + def apply_not(self, condition: dict[str, Any]) -> dict[str, Any]: + # $not only negates a single field's operator expression; $nor negates any condition + return {"$nor": [condition]} + + def apply_eq(self, prop: str, value: Any) -> dict[str, Any]: + key, value = self.prepare(prop, value, "eq") + return {key: value} + + def apply_ne(self, prop: str, value: Any) -> dict[str, Any]: + key, value = self.prepare(prop, value, "ne") + return {key: {"$ne": value}} + + def apply_gt(self, prop: str, value: Any) -> dict[str, Any]: + key, value = self.prepare(prop, value, "gt") + return {key: {"$gt": value}} + + def apply_lt(self, prop: str, value: Any) -> dict[str, Any]: + key, value = self.prepare(prop, value, "lt") + return {key: {"$lt": value}} + + def apply_gte(self, prop: str, value: Any) -> dict[str, Any]: + key, value = self.prepare(prop, value, "gte") + return {key: {"$gte": value}} + + def apply_lte(self, prop: str, value: Any) -> dict[str, Any]: + key, value = self.prepare(prop, value, "lte") + return {key: {"$lte": value}} + + def apply_in(self, prop: str, value: Sequence[Any]) -> dict[str, Any]: + key, values = self.prepare_items(prop, value) + return {key: {"$in": values}} + + def apply_out(self, prop: str, value: Sequence[Any]) -> dict[str, Any]: + key, values = self.prepare_items(prop, value) + return {key: {"$nin": values}} + + def apply_like(self, prop: str, value: str) -> dict[str, Any]: + key, _ = self.resolve_key(prop) + return {key: {"$regex": self.to_regex(str(value))}} + + def apply_ilike(self, prop: str, value: str) -> dict[str, Any]: + key, _ = self.resolve_key(prop) + return {key: {"$regex": self.to_regex(str(value)), "$options": "i"}} + + def to_regex(self, pattern: str) -> str: + # `*` matches anything; every other character, regex ones included, is literal. + # `\z` anchors at the very end (`$` would also match before a trailing newline). + return "^" + ".*".join(re.escape(part) for part in pattern.split("*")) + r"\z" + + def prepare(self, prop: str, value: Any, operator: str) -> tuple[str, Any]: + key, model_field = self.resolve_key(prop) + if model_field.is_model: + if value is not None or operator not in ("eq", "ne"): + raise ValueError( + f"`{operator}` can be applied to relationship only to test for null." + ) + return key, None + if value is None: + return key, None + return key, self.convert_value(prop, self.cast_value(model_field.type, value)) + + def prepare_items(self, prop: str, values: Sequence[Any]) -> tuple[str, list[Any]]: + key, model_field = self.resolve_key(prop) + return key, [ + self.convert_value(prop, self.cast_value(model_field.type, value)) for value in values + ] + + def cast_value(self, field_type: type, value: Any) -> Any: + try: + return self.cast(field_type, value) + except (ValueError, TypeError, InvalidId) as e: + raise ValueError(f"Cannot cast value {value!r} to {field_type.__name__}: {e}") from e + + def cast(self, field_type: type, value: Any) -> Any: + if issubclass(field_type, Enum): + return field_type(value).value + if issubclass(field_type, ObjectId): + return value if isinstance(value, ObjectId) else ObjectId(str(value)) + if field_type is bool: + if isinstance(value, bool): + return value + raise TypeError("expected true or false") + if field_type is int: + return self.cast_int(value) + if field_type is float: + if isinstance(value, bool): + raise TypeError("expected a number") + return float(value) + if field_type is Decimal: + if isinstance(value, bool): + raise TypeError("expected a number") + return Decimal128(Decimal(str(value))) + if field_type is datetime: + return self.cast_datetime(value) + if field_type is date: + if isinstance(value, datetime): + raise TypeError("expected a date without time") + if isinstance(value, str): + value = date.fromisoformat(value) + # MongoDB has no date-only type: dates are stored as midnight UTC + return datetime(value.year, value.month, value.day, tzinfo=UTC) + return str(value) + + def cast_int(self, value: Any) -> int: + if isinstance(value, bool): + raise TypeError("expected an integer") + if isinstance(value, int): + return value + number = float(value) + if not number.is_integer(): + raise ValueError("expected an integer") + return int(number) + + def cast_datetime(self, value: Any) -> datetime: + if isinstance(value, str): + value = datetime.fromisoformat(value) + elif not isinstance(value, datetime): + # A date compares as midnight UTC, not as the whole day + value = datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # PyMongo stores and returns naive datetimes as UTC + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + def apply_any(self, relationship_name: str, condition: dict[str, Any]) -> dict[str, Any]: + model_fields = self.resolve_path(self.resolve_alias(relationship_name)) + relationship = model_fields[-1] + if not (relationship.is_model and relationship.is_list): + raise ValueError( + f"any() requires a list of nested models: '{relationship_name}' is not one." + ) + key = ".".join(model_field.key for model_field in model_fields) + self.field_names[key] = relationship_name + return { + key: { + "$elemMatch": self.relative_condition( + condition, key, relationship_name, relationship.type + ) + } + } + + def relative_condition( + self, condition: dict[str, Any], key: str, relationship_name: str, model: type + ) -> dict[str, Any]: + """Rewrites the keys of a condition on `key.*` relative to `key`, for `$elemMatch`.""" + relative = {} + for condition_key, value in condition.items(): + if condition_key in LOGICAL_OPERATORS: + relative[condition_key] = [ + self.relative_condition(item, key, relationship_name, model) for item in value + ] + elif condition_key.startswith(f"{key}."): + relative_key = condition_key.removeprefix(f"{key}.") + self.check_no_array_traversal(model, relative_key, condition_key) + relative[relative_key] = value + else: + field_name = self.field_names.get(condition_key, condition_key) + raise ValueError( + f"Field '{field_name}' is not part of relationship '{relationship_name}'." + ) + return relative + + def check_condition(self, condition: dict[str, Any]) -> None: + """Rejects fields reached through an array outside `any()`.""" + for key, value in condition.items(): + if key in LOGICAL_OPERATORS: + for item in value: + self.check_condition(item) + else: + self.check_no_array_traversal(self.model_class, key, key) + + def check_no_array_traversal(self, model: type, key: str, full_key: str) -> None: + # A key may end at an array ($elemMatch, null tests) but must not go through one + parts = key.split(".") + for part in parts[:-1]: + model_field = next(f for f in get_model_fields(model).values() if f.key == part) + if model_field.is_list: + field_name = self.field_names.get(full_key, full_key) + raise ValueError( + f"Field '{field_name}' goes through the list '{model_field.name}': " + "use any() to filter on it." + ) + model = model_field.type + + def apply_filter(self, query: MongoQuery, filter_expression: FilterExpression) -> MongoQuery: + condition = filter_expression.condition + self.check_condition(condition) + query.filter = {"$and": [query.filter, condition]} if query.filter else condition + return query + + def apply_joins(self, query: MongoQuery) -> MongoQuery: + return query + + def apply_order_by( + self, query: MongoQuery, order_by_expression: OrderByExpression + ) -> MongoQuery: + for order_field in order_by_expression.fields: + model_fields = self.resolve_path(self.resolve_alias(order_field.field_path)) + if any(model_field.is_list for model_field in model_fields): + raise ValueError( + f"Order by '{order_field.field_path}' is not possible: it is a list " + "or inside a list." + ) + if model_fields[-1].is_model: + raise ValueError( + f"Order by '{order_field.field_path}' is not possible: it is a nested model." + ) + key = ".".join(model_field.key for model_field in model_fields) + query.sort.append((key, -1 if order_field.direction == "-" else 1)) + return query + + def build_query(self, rql_query: str, initial_query: MongoQuery | None = None) -> MongoQuery: + self.field_names = {} + # Never modify the caller's query + query = ( + MongoQuery(filter=dict(initial_query.filter), sort=list(initial_query.sort)) + if initial_query is not None + else None + ) + return super().build_query(rql_query=rql_query, initial_query=query) diff --git a/src/requela/builders/sqlalchemy.py b/src/requela/builders/sqlalchemy.py index 8a44157..65b8138 100644 --- a/src/requela/builders/sqlalchemy.py +++ b/src/requela/builders/sqlalchemy.py @@ -30,12 +30,14 @@ def __init__( resolve_alias_callback: Callable | None = None, validate_operator_and_field_callback: Callable | None = None, validate_ordering_callback: Callable | None = None, + get_field_rule_callback: Callable | None = None, ): super().__init__( model_class, resolve_alias_callback=resolve_alias_callback, validate_operator_and_field_callback=validate_operator_and_field_callback, validate_ordering_callback=validate_ordering_callback, + get_field_rule_callback=get_field_rule_callback, ) self.joins: list[JoinExpression] = [] # Aliases of to-many relationships that are not joined, mapped to the property path @@ -75,8 +77,11 @@ def apply_eq( return self.apply_eq_to_relationship(model_field.property) if value is True or value is False or value is None: - return model_field.is_(value) - return model_field == self.cast_value(model_field, value) + value = self.convert_value(prop, value) + if value is True or value is False or value is None: + return model_field.is_(value) + return model_field == value + return model_field == self.convert_value(prop, self.cast_value(model_field, value)) def apply_eq_to_relationship( self, relationship_property: RelationshipProperty @@ -95,8 +100,11 @@ def apply_ne( raise ValueError("`ne` can be applied to relationship only to test for null.") return self.apply_ne_to_relationship(model_field.property) if value is True or value is False or value is None: - return model_field.isnot(value) - return model_field != self.cast_value(model_field, value) + value = self.convert_value(prop, value) + if value is True or value is False or value is None: + return model_field.isnot(value) + return model_field != value + return model_field != self.convert_value(prop, self.cast_value(model_field, value)) def apply_ne_to_relationship( self, relationship_property: RelationshipProperty @@ -107,33 +115,33 @@ def apply_ne_to_relationship( return or_(*conditions) def apply_gt(self, prop: str, value: date | datetime | int | float) -> ColumnExpressionArgument: - return self.resolve_property(prop) > self.cast_value(self.resolve_property(prop), value) + return self.resolve_property(prop) > self.cast_and_convert(prop, value) def apply_lt(self, prop: str, value: date | datetime | int | float) -> ColumnExpressionArgument: - return self.resolve_property(prop) < self.cast_value(self.resolve_property(prop), value) + return self.resolve_property(prop) < self.cast_and_convert(prop, value) def apply_gte( self, prop: str, value: date | datetime | int | float ) -> ColumnExpressionArgument: - return self.resolve_property(prop) >= self.cast_value(self.resolve_property(prop), value) + return self.resolve_property(prop) >= self.cast_and_convert(prop, value) def apply_lte( self, prop: str, value: date | datetime | int | float ) -> ColumnExpressionArgument: - return self.resolve_property(prop) <= self.cast_value(self.resolve_property(prop), value) + return self.resolve_property(prop) <= self.cast_and_convert(prop, value) def apply_in( self, prop: str, value: Sequence[str] | Sequence[float] | Sequence[int] ) -> ColumnExpressionArgument: return self.resolve_property(prop).in_( - [self.cast_value(self.resolve_property(prop), item) for item in value] + [self.cast_and_convert(prop, item) for item in value] ) def apply_out( self, prop: str, value: Sequence[str] | Sequence[float] | Sequence[int] ) -> ColumnExpressionArgument: return self.resolve_property(prop).not_in( - [self.cast_value(self.resolve_property(prop), item) for item in value] + [self.cast_and_convert(prop, item) for item in value] ) def apply_like(self, prop: str, value: str) -> ColumnExpressionArgument: @@ -189,6 +197,9 @@ def apply_any( ) return exists_clause + def cast_and_convert(self, prop: str, value: Any) -> Any: + return self.convert_value(prop, self.cast_value(self.resolve_property(prop), value)) + def cast_value(self, column: ColumnElement, value: Any) -> Any: # pragma: no cover if isinstance(value, column.type.python_type): return value diff --git a/src/requela/rules.py b/src/requela/rules.py index c1b8220..d4369a4 100644 --- a/src/requela/rules.py +++ b/src/requela/rules.py @@ -1,11 +1,11 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from enum import Enum, IntEnum, StrEnum from typing import Any, ClassVar from requela.builders import QueryBuilder, get_builder_for_model -from requela.dataclasses import DEFAULT_OPERATORS, Operator +from requela.dataclasses import Operator from requela.exceptions import RequelaError DOCS_HEADER = [ @@ -20,6 +20,8 @@ class FieldRule: alias: str | None = None allow_ordering: bool = True source: str | None = None + # Converts a value, already cast to the field's type, to the stored format + value_converter: Callable[[Any], Any] | None = None @dataclass @@ -30,12 +32,19 @@ class RelationshipRule: class ModelRQLRules: __model__: ClassVar[Any] + # Builder class to use; when not set, it is inferred from the model (SQLAlchemy, Django) + __builder__: ClassVar[type[QueryBuilder] | None] = None _fields: ClassVar[dict[str, FieldRule]] _relations: ClassVar[dict[str, RelationshipRule]] def __init_subclass__(cls): super().__init_subclass__() + # Abstract rules classes only share settings (e.g. `__builder__`) with subclasses. + # Read from the class itself so that subclasses of an abstract class are concrete. + if cls.__dict__.get("__abstract__", False): + return + if not hasattr(cls, "__model__"): raise TypeError(f"{cls.__name__} must define __model__") @@ -54,6 +63,8 @@ def __init_subclass__(cls): cls._relations = relations def __init__(self): + if self.__class__.__dict__.get("__abstract__", False): + raise TypeError(f"{self.__class__.__name__} is abstract and cannot be instantiated") self.builder = self._get_builder() self._validate() @@ -121,14 +132,22 @@ def _validate_ordering(cls, field: str): if not field_def.allow_ordering: # type: ignore raise ValueError(f"Order by '{field}' is not allowed.") + @classmethod + def _get_field_rule(cls, alias: str) -> FieldRule | RelationshipRule: + _, field_def = cls._get_field_by_alias(alias) + return field_def + @classmethod def _get_builder(cls) -> QueryBuilder: - return get_builder_for_model( - cls.__model__, - resolve_alias_callback=cls._resolve_alias, - validate_operator_and_field_callback=cls._validate_operator_and_field, - validate_ordering_callback=cls._validate_ordering, - ) + callbacks = { + "resolve_alias_callback": cls._resolve_alias, + "validate_operator_and_field_callback": cls._validate_operator_and_field, + "validate_ordering_callback": cls._validate_ordering, + "get_field_rule_callback": cls._get_field_rule, + } + if cls.__builder__ is not None: + return cls.__builder__(cls.__model__, **callbacks) + return get_builder_for_model(cls.__model__, **callbacks) def _validate(self) -> None: errors = [] @@ -141,10 +160,7 @@ def _validate(self) -> None: # If no operators specified, infer from field type if field_def.allowed_operators is None: - if issubclass(field_type, Enum | StrEnum | IntEnum): - default_operators = DEFAULT_OPERATORS.get(Enum) - else: - default_operators = DEFAULT_OPERATORS.get(field_type) + default_operators = self.builder.get_default_operators(field_type) if default_operators is None: # pragma: no cover errors.append( @@ -168,6 +184,8 @@ def _validate(self) -> None: errors.append( f"Field '{field_name}' not found in model '{self.__model__.__name__}'." ) + except TypeError as e: + errors.append(str(e)) if errors: raise ExceptionGroup( @@ -179,14 +197,10 @@ def _get_field_type(self, field_name: str) -> type: """Gets the type of a field - to be implemented by specific ORM builders""" return self.builder.get_field_type(field_name) - @classmethod - def _validate_operators(cls, field_type: type, operators: set[Operator]) -> set[Operator]: + def _validate_operators(self, field_type: type, operators: set[Operator]) -> set[Operator]: """Validates operators against field type""" - if issubclass(field_type, Enum | StrEnum | IntEnum): - valid_operators = DEFAULT_OPERATORS.get(Enum) - else: - valid_operators = DEFAULT_OPERATORS.get(field_type) + valid_operators = self.builder.get_default_operators(field_type) if not valid_operators: # pragma: no cover return operators diff --git a/tests/django/rules.py b/tests/django/rules.py index cae8833..dfd15ed 100644 --- a/tests/django/rules.py +++ b/tests/django/rules.py @@ -43,3 +43,10 @@ class AccountWithAliasedUsersRules(ModelRQLRules): name = FieldRule() users = RelationshipRule(alias="members", rules=UserNameRules()) + + +class UserWithConvertersRules(ModelRQLRules): + __model__ = User + + name = FieldRule(value_converter=str.upper) + age = FieldRule(value_converter=lambda value: value * 12) diff --git a/tests/django/test_rules.py b/tests/django/test_rules.py index 9e8d8cd..17c9f60 100644 --- a/tests/django/test_rules.py +++ b/tests/django/test_rules.py @@ -1,13 +1,13 @@ from datetime import date, datetime import pytest -from django.db.models import Exists, OuterRef +from django.db.models import Exists, OuterRef, QuerySet from requela.dataclasses import Operator from requela.exceptions import RequelaError from requela.rules import FieldRule, ModelRQLRules from tests.django.models import Account, User -from tests.django.rules import AccountWithAliasedUsersRules, UserRules +from tests.django.rules import AccountWithAliasedUsersRules, UserRules, UserWithConvertersRules from tests.django.utils import assert_statements_equal @@ -122,3 +122,24 @@ def test_any_on_aliased_relationship_filters_on_the_relationship() -> None: Exists(User.objects.filter(account=OuterRef("pk")).filter(name="John")) ) assert_statements_equal(stmt, expected) + + +@pytest.mark.parametrize( + ("query_string", "expected"), + [ + ("eq(name,john)", User.objects.filter(name="JOHN")), + ("ne(name,john)", User.objects.exclude(name="JOHN")), + ("gt(age,2)", User.objects.filter(age__gt=24)), + ("gte(age,2)", User.objects.filter(age__gte=24)), + ("lt(age,2)", User.objects.filter(age__lt=24)), + ("lte(age,2)", User.objects.filter(age__lte=24)), + ("in(name,(john,jane))", User.objects.filter(name__in=["JOHN", "JANE"])), + ("out(name,(john,jane))", User.objects.exclude(name__in=["JOHN", "JANE"])), + ("eq(name,null())", User.objects.filter(name__isnull=True)), + ("like(name,jo*)", User.objects.filter(name__startswith="jo")), + ], +) +def test_value_converter_converts_filter_values(query_string: str, expected: QuerySet) -> None: + """`value_converter` converts compared values, but not `null()` or `like` patterns.""" + stmt = UserWithConvertersRules().build_query(query_string) + assert_statements_equal(stmt, expected) diff --git a/tests/integration/mongo/__init__.py b/tests/integration/mongo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/mongo/conftest.py b/tests/integration/mongo/conftest.py new file mode 100644 index 0000000..c4d5a38 --- /dev/null +++ b/tests/integration/mongo/conftest.py @@ -0,0 +1,158 @@ +from collections.abc import Awaitable, Callable, Iterator +from datetime import UTC, datetime +from decimal import Decimal +from typing import Any + +import pytest +from bson import Decimal128, ObjectId +from pymongo import AsyncMongoClient, MongoClient +from pymongo.database import Database + +from requela.builders.mongo import MongoQuery +from tests.mongo.models import Level, Source, Tag +from tests.settings import MONGO_URL + +DATABASE_NAME = "requela_test" + +FindDocuments = Callable[[str, MongoQuery], Awaitable[list[dict[str, Any]]]] + + +@pytest.fixture(scope="session") +def mongo_client() -> Iterator[MongoClient]: + """A blocking client connected to the MongoDB test server.""" + client: MongoClient = MongoClient(MONGO_URL, serverSelectionTimeoutMS=5000) + yield client + client.close() + + +@pytest.fixture +def database(mongo_client: MongoClient) -> Iterator[Database]: + """An empty test database, dropped at the end of the test.""" + mongo_client.drop_database(DATABASE_NAME) + yield mongo_client[DATABASE_NAME] + mongo_client.drop_database(DATABASE_NAME) + + +@pytest.fixture(params=["sync", "async"]) +def find_documents(request: pytest.FixtureRequest, database: Database) -> FindDocuments: + """Run a `MongoQuery` with the blocking or the asyncio PyMongo client.""" + + async def _find(collection_name: str, query: MongoQuery) -> list[dict[str, Any]]: + if request.param == "sync": + cursor = database[collection_name].find(query.filter) + if query.sort: + cursor = cursor.sort(query.sort) + return list(cursor) + + client: AsyncMongoClient = AsyncMongoClient(MONGO_URL) + try: + async_cursor = client[DATABASE_NAME][collection_name].find(query.filter) + if query.sort: + async_cursor = async_cursor.sort(query.sort) + return await async_cursor.to_list() + finally: + await client.close() + + return _find + + +@pytest.fixture +def event_factory(database: Database) -> Callable[..., ObjectId]: + """Insert an event document with overridable defaults; `missing` omits fields.""" + + def _build( + *, + description: str, + level: Level = Level.INFO, + time: datetime = datetime(2024, 1, 1, tzinfo=UTC), + ack: bool = False, + count: int = 0, + note: str | None = None, + tags: list[Tag] | None = None, + source: Source | None = None, + missing: tuple[str, ...] = (), + ) -> ObjectId: + document = { + "_id": ObjectId(), + "organization_id": "ORG-1", + "time": time, + "level": level.value, + "evt_class": "quota", + "description": description, + "ack": ack, + "count": count, + "note": note, + "tags": [tag.model_dump() for tag in tags or []], + "source": source.model_dump() if source else None, + } + for key in missing: + del document[key] + database["events"].insert_one(document) + return document["_id"] + + return _build + + +@pytest.fixture +def sample_events(event_factory: Callable[..., ObjectId]) -> None: + """Four events covering null and missing fields, nested lists and nested models.""" + event_factory( + description="disk full", + level=Level.ERROR, + time=datetime(2024, 1, 3, tzinfo=UTC), + count=5, + note="check", + tags=[ + Tag(name="env", value="prod"), + Tag(name="team", value="ops", aliases=[{"name": "operations"}]), + ], + source=Source(name="aws", region="eu"), + ) + event_factory( + description="quota exceeded", + level=Level.WARNING, + time=datetime(2024, 1, 2, tzinfo=UTC), + ack=True, + count=2, + note=None, + tags=[Tag(name="env", value="dev"), Tag(name="team", value="ops")], + source=Source(name="gcp"), + ) + event_factory( + description="Quota reset", + time=datetime(2024, 1, 1, tzinfo=UTC), + missing=("note",), + ) + event_factory( + description="a.b literal", + time=datetime(2024, 1, 4, tzinfo=UTC), + ack=True, + count=1, + note="x", + tags=[Tag(name="env", value="prod"), Tag(name="team", value="dev")], + missing=("source",), + ) + + +@pytest.fixture +def keeper_event_factory(database: Database) -> Callable[[datetime], ObjectId]: + """Insert a keeper event, which stores `time` as an int Unix timestamp.""" + + def _build(time: datetime) -> ObjectId: + result = database["keeper_events"].insert_one({"time": int(time.timestamp())}) + return result.inserted_id + + return _build + + +@pytest.fixture +def item_factory(database: Database) -> Callable[..., ObjectId]: + """Insert an item document, as stored for the `Item` dataclass.""" + + def _build(*, name: str, price: Decimal, created: datetime) -> ObjectId: + result = database["items"].insert_one( + {"name": name, "price": Decimal128(price), "created": created} + ) + return result.inserted_id + + return _build diff --git a/tests/integration/mongo/test_queries.py b/tests/integration/mongo/test_queries.py new file mode 100644 index 0000000..7894989 --- /dev/null +++ b/tests/integration/mongo/test_queries.py @@ -0,0 +1,200 @@ +from collections.abc import Callable +from datetime import UTC, datetime +from decimal import Decimal + +import pytest +from bson import ObjectId + +from tests.integration.mongo.conftest import FindDocuments +from tests.mongo.models import KeeperEvent +from tests.mongo.rules import EventRules, ItemRules, KeeperEventRules + +ALL_EVENTS = {"disk full", "quota exceeded", "Quota reset", "a.b literal"} + + +@pytest.mark.usefixtures("sample_events") +@pytest.mark.parametrize( + ("query_string", "expected"), + [ + ("eq(level,ERROR)", {"disk full"}), + ("ne(level,INFO)", {"disk full", "quota exceeded"}), + ("gt(count,1)", {"disk full", "quota exceeded"}), + ("lte(count,1)", {"Quota reset", "a.b literal"}), + ("in(level,(INFO,WARNING))", {"quota exceeded", "Quota reset", "a.b literal"}), + ("out(level,(INFO,WARNING))", {"disk full"}), + ("eq(ack,true)", {"quota exceeded", "a.b literal"}), + ("gt(time,2024-01-02)", {"disk full", "a.b literal"}), + ("lt(time,2024-01-02T00:00:00+01:00)", {"Quota reset"}), + ("eq(type,quota)", ALL_EVENTS), + ("and(eq(level,INFO),eq(ack,true))", {"a.b literal"}), + ("or(eq(level,ERROR),eq(level,WARNING))", {"disk full", "quota exceeded"}), + ("not(eq(level,INFO))", {"disk full", "quota exceeded"}), + ("eq(origin.name,aws)", {"disk full"}), + ], +) +async def test_operators_select_the_matching_documents( + find_documents: FindDocuments, query_string: str, expected: set[str] +) -> None: + """Each RQL operator selects the expected documents on a real MongoDB.""" + query = EventRules().build_query(query_string) + + documents = await find_documents("events", query) + + assert {document["description"] for document in documents} == expected + + +@pytest.mark.usefixtures("sample_events") +@pytest.mark.parametrize( + ("query_string", "expected"), + [ + ("like(description,quota*)", {"quota exceeded"}), + ("ilike(description,quota*)", {"quota exceeded", "Quota reset"}), + ("like(description,*full)", {"disk full"}), + ("like(description,*s*f*)", {"disk full"}), + ("like(description,a.b*)", {"a.b literal"}), + ("like(description,a?b*)", set()), + ("like(description,disk)", set()), + ], +) +async def test_like_matches_wildcards_and_literal_characters( + find_documents: FindDocuments, query_string: str, expected: set[str] +) -> None: + """`*` matches any characters; every other character, regex ones included, is literal.""" + query = EventRules().build_query(query_string) + + documents = await find_documents("events", query) + + assert {document["description"] for document in documents} == expected + + +@pytest.mark.usefixtures("sample_events") +@pytest.mark.parametrize( + ("query_string", "expected"), + [ + ("eq(note,null())", {"quota exceeded", "Quota reset"}), + ("ne(note,null())", {"disk full", "a.b literal"}), + ("ne(note,check)", {"quota exceeded", "Quota reset", "a.b literal"}), + ("out(note,(check,x))", {"quota exceeded", "Quota reset"}), + ("not(eq(note,check))", {"quota exceeded", "Quota reset", "a.b literal"}), + ("eq(origin,null())", {"Quota reset", "a.b literal"}), + ], +) +async def test_null_also_matches_missing_fields( + find_documents: FindDocuments, query_string: str, expected: set[str] +) -> None: + """`null()` matches null and missing fields; `ne`, `out` and `not` include them.""" + query = EventRules().build_query(query_string) + + documents = await find_documents("events", query) + + assert {document["description"] for document in documents} == expected + + +@pytest.mark.usefixtures("sample_events") +@pytest.mark.parametrize( + ("query_string", "expected"), + [ + ("any(tags,eq(tags.key,env))", {"disk full", "quota exceeded", "a.b literal"}), + ("any(tags,and(eq(tags.key,team),eq(tags.value,dev)))", {"a.b literal"}), + ( + "and(any(tags,eq(tags.key,team)),any(tags,eq(tags.value,dev)))", + {"quota exceeded", "a.b literal"}, + ), + ("not(any(tags,eq(tags.key,env)))", {"Quota reset"}), + ( + "or(eq(level,INFO),any(tags,eq(tags.value,dev)))", + {"quota exceeded", "Quota reset", "a.b literal"}, + ), + ( + "any(tags,and(any(tags.aliases,eq(tags.aliases.name,operations))))", + {"disk full"}, + ), + ], +) +async def test_any_matches_elements_of_nested_lists( + find_documents: FindDocuments, query_string: str, expected: set[str] +) -> None: + """`any()` needs one element to match all its conditions; separate `any()` are independent.""" + query = EventRules().build_query(query_string) + + documents = await find_documents("events", query) + + assert {document["description"] for document in documents} == expected + + +@pytest.mark.usefixtures("sample_events") +@pytest.mark.parametrize( + ("query_string", "expected"), + [ + ("order_by(-time)", ["a.b literal", "disk full", "quota exceeded", "Quota reset"]), + ( + "order_by(count,description)", + ["Quota reset", "a.b literal", "quota exceeded", "disk full"], + ), + ( + "order_by(note,description)", + ["Quota reset", "quota exceeded", "disk full", "a.b literal"], + ), + ], +) +async def test_order_by_sorts_documents( + find_documents: FindDocuments, query_string: str, expected: list[str] +) -> None: + """`order_by` sorts the documents; null and missing values come first.""" + query = EventRules().build_query(query_string) + + documents = await find_documents("events", query) + + assert [document["description"] for document in documents] == expected + + +async def test_object_id_filter_selects_the_document( + find_documents: FindDocuments, event_factory: Callable[..., ObjectId] +) -> None: + """An `id` given as a hex string selects the document with that `ObjectId`.""" + event_id = event_factory(description="target") + event_factory(description="other") + query = EventRules().build_query(f"eq(id,'{event_id}')") + + documents = await find_documents("events", query) + + assert [document["_id"] for document in documents] == [event_id] + + +async def test_value_converter_filters_on_the_stored_format( + find_documents: FindDocuments, keeper_event_factory: Callable[[datetime], ObjectId] +) -> None: + """A datetime filter on a timestamp stored as int selects through `value_converter`.""" + keeper_event_factory(datetime(2024, 1, 1, tzinfo=UTC)) + later_id = keeper_event_factory(datetime(2024, 1, 3, tzinfo=UTC)) + query = KeeperEventRules().build_query("gt(time,2024-01-02)") + + documents = await find_documents("keeper_events", query) + + assert [document["_id"] for document in documents] == [later_id] + + +async def test_model_reads_a_timestamp_stored_as_int( + find_documents: FindDocuments, keeper_event_factory: Callable[[datetime], ObjectId] +) -> None: + """The pydantic model parses the int timestamp stored by keeper into a datetime.""" + keeper_event_factory(datetime(2024, 1, 3, tzinfo=UTC)) + + documents = await find_documents( + "keeper_events", KeeperEventRules().build_query("order_by(id)") + ) + + assert KeeperEvent.model_validate(documents[0]).time == datetime(2024, 1, 3, tzinfo=UTC) + + +async def test_decimal_and_date_filters_select_dataclass_documents( + find_documents: FindDocuments, item_factory: Callable[..., ObjectId] +) -> None: + """`Decimal` and `date` values compare with the `Decimal128` and datetime stored.""" + item_factory(name="cheap", price=Decimal("9.99"), created=datetime(2024, 1, 1)) + item_factory(name="dear", price=Decimal("10.50"), created=datetime(2024, 1, 2)) + query = ItemRules().build_query("and(gte(price,'10.00'),eq(created,2024-01-02))") + + documents = await find_documents("items", query) + + assert [document["name"] for document in documents] == ["dear"] diff --git a/tests/mongo/__init__.py b/tests/mongo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mongo/models.py b/tests/mongo/models.py new file mode 100644 index 0000000..540b87c --- /dev/null +++ b/tests/mongo/models.py @@ -0,0 +1,85 @@ +from dataclasses import dataclass, field +from datetime import date, datetime +from decimal import Decimal +from enum import IntEnum, StrEnum +from typing import Annotated, Any +from uuid import UUID + +from bson import ObjectId +from pydantic import BaseModel, ConfigDict, Field + + +class Level(StrEnum): + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + + +class Severity(IntEnum): + LOW = 1 + HIGH = 2 + + +class Alias(BaseModel): + name: str + + +class Tag(BaseModel): + name: str + value: str + aliases: list[Alias] = [] + + +class Source(BaseModel): + name: str + region: str | None = None + + +class Event(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) + + id: ObjectId = Field(alias="_id") + organization_id: str + time: datetime + level: Level + severity: Severity = Severity.LOW + evt_class: str + description: str + ack: bool = False + count: Annotated[int, Field(ge=0)] = 0 + cost: float = 0.0 + note: str | None = None + day: date | None = None + tags: list[Tag] = [] + source: Source | None = None + labels: list[str] = [] + payload: dict[str, Any] = {} + extra: Any = None + code: int | str = 0 + reference: UUID | None = None + + +class KeeperEvent(BaseModel): + """keeper stores `time` as an int Unix timestamp; users filter with datetimes.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) + + id: ObjectId = Field(alias="_id") + time: datetime + + +class CamelEvent(BaseModel): + """Stored keys differ from attribute names through aliases.""" + + model_config = ConfigDict(populate_by_name=True) + + event_class: str = Field(alias="eventClass") + created_by: str = Field(alias="createdBy", serialization_alias="author") + + +@dataclass +class Item: + id: ObjectId = field(metadata={"mongo_key": "_id"}) + name: str + price: Decimal + created: date diff --git a/tests/mongo/rules.py b/tests/mongo/rules.py new file mode 100644 index 0000000..a0ff512 --- /dev/null +++ b/tests/mongo/rules.py @@ -0,0 +1,94 @@ +from datetime import datetime + +from requela.builders.mongo import MongoQueryBuilder +from requela.dataclasses import Operator +from requela.rules import FieldRule, ModelRQLRules, RelationshipRule +from tests.mongo.models import Alias, Event, Item, KeeperEvent, Source, Tag + + +def to_timestamp(value: datetime) -> int: + return int(value.timestamp()) + + +class MongoRules(ModelRQLRules): + __abstract__ = True + __builder__ = MongoQueryBuilder + + +class AliasRules(MongoRules): + __model__ = Alias + + name = FieldRule() + + +class TagRules(MongoRules): + __model__ = Tag + + name = FieldRule(alias="key") + value = FieldRule() + aliases = RelationshipRule(rules=AliasRules()) + + +class SourceRules(MongoRules): + __model__ = Source + + name = FieldRule() + region = FieldRule() + + +class EventRules(MongoRules): + __model__ = Event + + id = FieldRule() + time = FieldRule() + level = FieldRule() + severity = FieldRule() + type = FieldRule(source="evt_class") + description = FieldRule(allowed_operators={Operator.EQ, Operator.LIKE, Operator.ILIKE}) + ack = FieldRule(allow_ordering=False) + count = FieldRule() + note = FieldRule() + day = FieldRule() + tags = RelationshipRule(rules=TagRules()) + source = RelationshipRule(alias="origin", rules=SourceRules()) + + +class KeeperEventRules(MongoRules): + __model__ = KeeperEvent + + id = FieldRule() + time = FieldRule(value_converter=to_timestamp) + + +class ItemRules(MongoRules): + __model__ = Item + + id = FieldRule() + name = FieldRule() + price = FieldRule() + created = FieldRule() + + +class EventWithUnsupportedFieldsRules(MongoRules): + """Rules on fields whose type can't be filtered: they fail when instantiated.""" + + __model__ = Event + + labels = FieldRule() + payload = FieldRule() + extra = FieldRule() + code = FieldRule() + reference = FieldRule() + payload_key = FieldRule(source="payload.key") + + +def reject(value: object) -> object: + raise ValueError(f"Rejected value {value!r}") + + +class EventWithConvertersRules(MongoRules): + __model__ = Event + + count = FieldRule(value_converter=str) + description = FieldRule(value_converter=str.upper) + note = FieldRule(value_converter=reject) diff --git a/tests/mongo/test_comparison.py b/tests/mongo/test_comparison.py new file mode 100644 index 0000000..fc2c5ab --- /dev/null +++ b/tests/mongo/test_comparison.py @@ -0,0 +1,187 @@ +from datetime import UTC, datetime, timedelta, timezone +from decimal import Decimal +from typing import Any + +import pytest +from bson import Decimal128, ObjectId + +from requela.builders.mongo import MongoQueryBuilder +from tests.mongo.models import Event, Item + +OBJECT_ID = "65f0c0ffee0ddba11ad0beef" + + +@pytest.mark.parametrize( + ("query_string", "expected"), + [ + ("eq(evt_class,quota)", {"evt_class": "quota"}), + ("eq(evt_class,'quoted value')", {"evt_class": "quoted value"}), + ("eq(evt_class,123)", {"evt_class": "123"}), + ("eq(evt_class,empty())", {"evt_class": ""}), + ("eq(note,null())", {"note": None}), + ("eq(count,3)", {"count": 3}), + ("eq(count,3.0)", {"count": 3}), + ("eq(cost,3)", {"cost": 3.0}), + ("eq(ack,true)", {"ack": True}), + ("eq(level,ERROR)", {"level": "ERROR"}), + ("eq(severity,2)", {"severity": 2}), + (f"eq(id,'{OBJECT_ID}')", {"_id": ObjectId(OBJECT_ID)}), + ("eq(source,null())", {"source": None}), + ("eq(source.name,aws)", {"source.name": "aws"}), + ], +) +def test_eq_builds_an_equality_filter(query_string: str, expected: dict[str, Any]) -> None: + """`eq` matches the value cast to the field type, on the stored key.""" + assert MongoQueryBuilder(Event).build_query(query_string).filter == expected + + +@pytest.mark.parametrize( + ("query_string", "expected"), + [ + ("ne(evt_class,quota)", {"evt_class": {"$ne": "quota"}}), + ("ne(note,null())", {"note": {"$ne": None}}), + ("ne(source,null())", {"source": {"$ne": None}}), + ], +) +def test_ne_builds_a_not_equal_filter(query_string: str, expected: dict[str, Any]) -> None: + """`ne` uses `$ne`, which also matches documents where the field is null or missing.""" + assert MongoQueryBuilder(Event).build_query(query_string).filter == expected + + +@pytest.mark.parametrize( + ("operator", "mongo_operator"), + [("gt", "$gt"), ("gte", "$gte"), ("lt", "$lt"), ("lte", "$lte")], +) +def test_range_operators_map_to_mongo_operators(operator: str, mongo_operator: str) -> None: + """`gt`, `gte`, `lt` and `lte` map to the MongoDB operator of the same name.""" + query = MongoQueryBuilder(Event).build_query(f"{operator}(count,5)") + assert query.filter == {"count": {mongo_operator: 5}} + + +def test_in_casts_each_item() -> None: + """`in` uses `$in` with every item cast to the field type.""" + query = MongoQueryBuilder(Event).build_query("in(count,(1,2.0,3))") + assert query.filter == {"count": {"$in": [1, 2, 3]}} + + +def test_out_casts_each_item() -> None: + """`out` uses `$nin` with every item cast to the field type.""" + query = MongoQueryBuilder(Event).build_query("out(level,(INFO,ERROR))") + assert query.filter == {"level": {"$nin": ["INFO", "ERROR"]}} + + +@pytest.mark.parametrize( + ("pattern", "regex"), + [ + ("quota", "^quota\\z"), + ("quota*", "^quota.*\\z"), + ("*quota", "^.*quota\\z"), + ("*quota*", "^.*quota.*\\z"), + ("a*b", "^a.*b\\z"), + ("a.b*", "^a\\.b.*\\z"), + ("(x)+[y]?", "^\\(x\\)\\+\\[y\\]\\?\\z"), + ], +) +def test_like_builds_an_anchored_regex(pattern: str, regex: str) -> None: + """`like` turns `*` into `.*` and matches every other character literally.""" + query = MongoQueryBuilder(Event).build_query(f"like(description,'{pattern}')") + assert query.filter == {"description": {"$regex": regex}} + + +def test_ilike_builds_a_case_insensitive_regex() -> None: + """`ilike` builds the same regex as `like`, with the case-insensitive option.""" + query = MongoQueryBuilder(Event).build_query("ilike(description,*Quota*)") + assert query.filter == {"description": {"$regex": "^.*Quota.*\\z", "$options": "i"}} + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("2024-01-01", datetime(2024, 1, 1, tzinfo=UTC)), + ("2024-01-01T10:30:00+00:00", datetime(2024, 1, 1, 10, 30, tzinfo=UTC)), + ("2024-01-01T12:30:00+02:00", datetime(2024, 1, 1, 10, 30, tzinfo=UTC)), + ("'2024-01-01T10:30:00'", datetime(2024, 1, 1, 10, 30, tzinfo=UTC)), + ], +) +def test_datetime_values_are_converted_to_utc(value: str, expected: datetime) -> None: + """Dates become midnight UTC, naive datetimes are taken as UTC, others converted.""" + query = MongoQueryBuilder(Event).build_query(f"gte(time,{value})") + assert query.filter == {"time": {"$gte": expected}} + assert query.filter["time"]["$gte"].utcoffset() == timedelta(0) + + +@pytest.mark.parametrize("value", ["2024-01-02", "'2024-01-02'"]) +def test_date_values_are_stored_as_midnight_utc(value: str) -> None: + """A `date` field compares as a datetime at midnight UTC.""" + query = MongoQueryBuilder(Event).build_query(f"eq(day,{value})") + assert query.filter == {"day": datetime(2024, 1, 2, tzinfo=UTC)} + + +def test_decimal_values_become_decimal128() -> None: + """A `Decimal` field compares as `Decimal128`, the type PyMongo stores.""" + query = MongoQueryBuilder(Item).build_query("eq(price,'10.50')") + assert query.filter == {"price": Decimal128(Decimal("10.50"))} + + +def test_dataclass_fields_use_the_mongo_key_metadata() -> None: + """A dataclass field with `mongo_key` metadata is filtered on that key.""" + query = MongoQueryBuilder(Item).build_query(f"eq(id,'{OBJECT_ID}')") + assert query.filter == {"_id": ObjectId(OBJECT_ID)} + + +@pytest.mark.parametrize( + ("query_string", "message"), + [ + ("eq(count,3.5)", "Cannot cast value 3.5 to int: expected an integer"), + ("eq(count,abc)", "Cannot cast value 'abc' to int"), + ("eq(count,true)", "Cannot cast value True to int: expected an integer"), + ("eq(cost,true)", "Cannot cast value True to float: expected a number"), + ("eq(ack,yes)", "Cannot cast value 'yes' to bool: expected true or false"), + ("eq(level,FATAL)", "Cannot cast value 'FATAL' to Level"), + ("eq(id,nothex)", "Cannot cast value 'nothex' to ObjectId"), + ("eq(time,yesterday)", "Cannot cast value 'yesterday' to datetime"), + ( + "eq(day,2024-01-02T10:00:00+00:00)", + "Cannot cast value .* to date: expected a date without time", + ), + ], +) +def test_values_that_cannot_be_cast_are_rejected(query_string: str, message: str) -> None: + """A value that doesn't fit the field type raises `ValueError` instead of matching nothing.""" + with pytest.raises(ValueError, match=message): + MongoQueryBuilder(Event).build_query(query_string) + + +def test_decimal_rejects_booleans() -> None: + """A boolean is not accepted as a `Decimal` value.""" + with pytest.raises(ValueError, match="Cannot cast value True to Decimal: expected a number"): + MongoQueryBuilder(Item).build_query("eq(price,true)") + + +@pytest.mark.parametrize( + "query_string", + ["eq(source,aws)", "gt(source,null())", "ne(source,aws)"], +) +def test_nested_model_can_only_be_tested_for_null(query_string: str) -> None: + """A nested model field accepts only `eq`/`ne` with `null()`, as SQLAlchemy relationships.""" + with pytest.raises(ValueError, match="can be applied to relationship only to test for null"): + MongoQueryBuilder(Event).build_query(query_string) + + +def test_unknown_field_is_rejected() -> None: + """A field that is not on the model raises `AttributeError`.""" + with pytest.raises(AttributeError, match="Field 'missing' not found in model 'Event'"): + MongoQueryBuilder(Event).build_query("eq(missing,1)") + + +def test_path_into_a_non_model_field_is_rejected() -> None: + """A dotted path into a field that is not a nested model is reported as unknown.""" + with pytest.raises(AttributeError, match="Field 'payload.key' not found in model 'Event'"): + MongoQueryBuilder(Event).build_query("eq(payload.key,1)") + + +def test_aware_datetime_values_keep_their_instant() -> None: + """An aware datetime value keeps the same instant once converted to UTC.""" + offset = timezone(timedelta(hours=-5)) + query = MongoQueryBuilder(Event).build_query("lt(time,2024-01-01T19:00:00-05:00)") + assert query.filter["time"]["$lt"] == datetime(2024, 1, 1, 19, tzinfo=offset) diff --git a/tests/mongo/test_logical.py b/tests/mongo/test_logical.py new file mode 100644 index 0000000..22883b6 --- /dev/null +++ b/tests/mongo/test_logical.py @@ -0,0 +1,59 @@ +from requela.builders.mongo import MongoQuery, MongoQueryBuilder +from tests.mongo.models import Event + + +def test_and_combines_conditions_with_and() -> None: + """`and` puts its conditions in an `$and` list.""" + query = MongoQueryBuilder(Event).build_query("and(eq(level,ERROR),gt(count,1))") + assert query.filter == {"$and": [{"level": "ERROR"}, {"count": {"$gt": 1}}]} + + +def test_or_combines_conditions_with_or() -> None: + """`or` puts its conditions in an `$or` list.""" + query = MongoQueryBuilder(Event).build_query("or(eq(level,ERROR),gt(count,1))") + assert query.filter == {"$or": [{"level": "ERROR"}, {"count": {"$gt": 1}}]} + + +def test_not_negates_any_condition_with_nor() -> None: + """`not` uses `$nor`, which, unlike `$not`, negates any condition.""" + query = MongoQueryBuilder(Event).build_query("not(and(eq(level,ERROR),eq(ack,true)))") + assert query.filter == {"$nor": [{"$and": [{"level": "ERROR"}, {"ack": True}]}]} + + +def test_nested_logical_operators_keep_their_structure() -> None: + """Logical operators nest as written, without flattening.""" + query = MongoQueryBuilder(Event).build_query( + "or(and(eq(level,ERROR),not(eq(ack,true))),eq(count,0))" + ) + assert query.filter == { + "$or": [ + {"$and": [{"level": "ERROR"}, {"$nor": [{"ack": True}]}]}, + {"count": 0}, + ] + } + + +def test_top_level_expressions_are_combined_with_and() -> None: + """Expressions joined with `&` are all applied.""" + query = MongoQueryBuilder(Event).build_query("eq(level,ERROR)>(count,1)") + assert query.filter == {"$and": [{"level": "ERROR"}, {"count": {"$gt": 1}}]} + + +def test_initial_query_filter_is_combined_with_the_rql_filter() -> None: + """The RQL conditions are added with `$and` to the filter of the initial query.""" + initial = MongoQuery(filter={"organization_id": "ORG-1"}) + query = MongoQueryBuilder(Event).build_query("eq(level,ERROR)", initial_query=initial) + assert query.filter == {"$and": [{"organization_id": "ORG-1"}, {"level": "ERROR"}]} + + +def test_initial_query_is_not_modified() -> None: + """Building a query never changes the initial query passed by the caller.""" + initial = MongoQuery(filter={"organization_id": "ORG-1"}, sort=[("time", -1)]) + MongoQueryBuilder(Event).build_query("eq(level,ERROR)&order_by(count)", initial_query=initial) + assert initial == MongoQuery(filter={"organization_id": "ORG-1"}, sort=[("time", -1)]) + + +def test_query_without_filters_matches_everything() -> None: + """An RQL expression with only `order_by` gives an empty filter.""" + query = MongoQueryBuilder(Event).build_query("order_by(time)") + assert query == MongoQuery(filter={}, sort=[("time", 1)]) diff --git a/tests/mongo/test_ordering.py b/tests/mongo/test_ordering.py new file mode 100644 index 0000000..5421a6e --- /dev/null +++ b/tests/mongo/test_ordering.py @@ -0,0 +1,25 @@ +import pytest + +from requela.builders.mongo import MongoQueryBuilder +from tests.mongo.models import Event + + +def test_order_by_builds_sort_on_stored_keys() -> None: + """`order_by` sorts ascending by default and descending with `-`, on stored keys.""" + query = MongoQueryBuilder(Event).build_query("order_by(-time,id,+source.name)") + assert query.sort == [("time", -1), ("_id", 1), ("source.name", 1)] + + +@pytest.mark.parametrize( + ("field", "reason"), + [ + ("tags", "it is a list or inside a list"), + ("tags.name", "it is a list or inside a list"), + ("labels", "it is a list or inside a list"), + ("source", "it is a nested model"), + ], +) +def test_order_by_rejects_lists_and_nested_models(field: str, reason: str) -> None: + """Ordering on a list, a field inside a list or a nested model is rejected.""" + with pytest.raises(ValueError, match=f"Order by '{field}' is not possible: {reason}"): + MongoQueryBuilder(Event).build_query(f"order_by({field})") diff --git a/tests/mongo/test_relationship.py b/tests/mongo/test_relationship.py new file mode 100644 index 0000000..46c9555 --- /dev/null +++ b/tests/mongo/test_relationship.py @@ -0,0 +1,87 @@ +import pytest + +from requela.builders.mongo import MongoQueryBuilder +from tests.mongo.models import Event + + +def test_any_matches_one_element_with_elem_match() -> None: + """`any()` uses `$elemMatch` with field names relative to the list.""" + query = MongoQueryBuilder(Event).build_query("any(tags,eq(tags.name,env))") + assert query.filter == {"tags": {"$elemMatch": {"name": "env"}}} + + +def test_any_applies_all_conditions_to_the_same_element() -> None: + """All the conditions inside `any()` end up in one `$elemMatch`.""" + query = MongoQueryBuilder(Event).build_query( + "any(tags,and(eq(tags.name,env),or(eq(tags.value,prod),not(eq(tags.value,dev)))))" + ) + assert query.filter == { + "tags": { + "$elemMatch": { + "$and": [ + {"name": "env"}, + {"$or": [{"value": "prod"}, {"$nor": [{"value": "dev"}]}]}, + ] + } + } + } + + +def test_any_combines_with_logical_operators() -> None: + """`any()` can be used inside `or` and `not` like any other condition.""" + query = MongoQueryBuilder(Event).build_query( + "or(eq(level,ERROR),not(any(tags,eq(tags.name,env))))" + ) + assert query.filter == { + "$or": [{"level": "ERROR"}, {"$nor": [{"tags": {"$elemMatch": {"name": "env"}}}]}] + } + + +def test_nested_any_matches_lists_inside_lists() -> None: + """`any()` inside `any()` matches a list nested in the elements of another list.""" + query = MongoQueryBuilder(Event).build_query( + "any(tags,and(eq(tags.name,env),any(tags.aliases,eq(tags.aliases.name,environment))))" + ) + assert query.filter == { + "tags": { + "$elemMatch": { + "$and": [ + {"name": "env"}, + {"aliases": {"$elemMatch": {"name": "environment"}}}, + ] + } + } + } + + +@pytest.mark.parametrize( + ("query_string", "field_name", "list_name"), + [ + ("eq(tags.name,env)", "tags.name", "tags"), + ("or(eq(level,ERROR),eq(tags.name,env))", "tags.name", "tags"), + ("any(tags.aliases,eq(tags.aliases.name,x))", "tags.aliases", "tags"), + ("any(tags,eq(tags.aliases.name,x))", "tags.aliases.name", "aliases"), + ], +) +def test_field_inside_a_list_requires_any( + query_string: str, field_name: str, list_name: str +) -> None: + """A field reached through a list outside `any()` on that list is rejected.""" + with pytest.raises( + ValueError, + match=f"Field '{field_name}' goes through the list '{list_name}': use any\\(\\)", + ): + MongoQueryBuilder(Event).build_query(query_string) + + +def test_any_rejects_field_outside_the_relationship() -> None: + """A field inside `any()` that is not reached through the list is rejected.""" + with pytest.raises(ValueError, match="Field 'level' is not part of relationship 'tags'"): + MongoQueryBuilder(Event).build_query("any(tags,eq(level,ERROR))") + + +@pytest.mark.parametrize("relationship", ["source", "level", "labels"]) +def test_any_requires_a_list_of_nested_models(relationship: str) -> None: + """`any()` on a field that is not a list of nested models is rejected.""" + with pytest.raises(ValueError, match="any\\(\\) requires a list of nested models: "): + MongoQueryBuilder(Event).build_query(f"any({relationship},eq({relationship},null()))") diff --git a/tests/mongo/test_rules.py b/tests/mongo/test_rules.py new file mode 100644 index 0000000..8dc0b59 --- /dev/null +++ b/tests/mongo/test_rules.py @@ -0,0 +1,137 @@ +from datetime import UTC, datetime + +import pytest +from bson import ObjectId + +from requela.builders.mongo import MongoQueryBuilder +from requela.exceptions import RequelaError +from requela.rules import FieldRule +from tests.mongo.models import CamelEvent +from tests.mongo.rules import ( + EventRules, + EventWithConvertersRules, + EventWithUnsupportedFieldsRules, + ItemRules, + KeeperEventRules, + MongoRules, +) + +OBJECT_ID = "65f0c0ffee0ddba11ad0beef" + + +def test_rules_use_the_builder_set_on_the_abstract_base() -> None: + """A rules class inherits `__builder__` from its abstract base.""" + assert isinstance(EventRules().builder, MongoQueryBuilder) + + +def test_abstract_rules_class_cannot_be_instantiated() -> None: + """An abstract rules class only shares settings and raises `TypeError` if instantiated.""" + with pytest.raises(TypeError, match="MongoRules is abstract and cannot be instantiated"): + MongoRules() + + +def test_subclass_of_abstract_rules_must_define_model() -> None: + """A subclass of an abstract rules class is concrete again and must define `__model__`.""" + with pytest.raises(TypeError, match="NoModelRules must define __model__"): + type("NoModelRules", (MongoRules,), {"name": FieldRule()}) + + +def test_dataclass_model_is_validated() -> None: + """Rules on a dataclass model infer the operators of each field from its type.""" + rules = ItemRules() + assert rules.build_query(f"in(id,('{OBJECT_ID}'))").filter == { + "_id": {"$in": [ObjectId(OBJECT_ID)]} + } + + +def test_unsupported_field_types_fail_when_rules_are_created() -> None: + """Rules on fields that can't be filtered fail when created, with one error per field.""" + with pytest.raises(ExceptionGroup) as exc_info: + EventWithUnsupportedFieldsRules() + + assert sorted(str(error) for error in exc_info.value.exceptions) == [ + "Field 'payload_key' not found in model 'Event'.", + "Unsupported type for field 'code': int | str.", + "Unsupported type for field 'extra': Any.", + "Unsupported type for field 'labels': list[str].", + "Unsupported type for field 'payload': dict.", + "Unsupported type for field 'reference': UUID.", + ] + + +def test_rules_resolve_aliases_and_sources_to_stored_keys() -> None: + """Field and relationship aliases and `source` are resolved to the stored keys.""" + query = EventRules().build_query( + "and(eq(type,quota),eq(origin.name,aws),any(tags,eq(tags.key,env)))" + ) + assert query.filter == { + "$and": [ + {"evt_class": "quota"}, + {"source.name": "aws"}, + {"tags": {"$elemMatch": {"name": "env"}}}, + ] + } + + +def test_pydantic_aliases_map_to_stored_keys() -> None: + """The serialization alias, then the alias, of a pydantic field is its stored key.""" + query = MongoQueryBuilder(CamelEvent).build_query("and(eq(event_class,a),eq(created_by,b))") + assert query.filter == {"$and": [{"eventClass": "a"}, {"author": "b"}]} + + +@pytest.mark.parametrize( + ("query_string", "message"), + [ + ("ne(description,x)", "Operator 'ne' is not allowed for field 'description'."), + ("order_by(ack)", "Order by 'ack' is not allowed."), + ("eq(evt_class,x)", "Relation with alias 'evt_class' not found"), + ("eq(tags.missing,x)", "Relation with alias 'missing' not found"), + ("eq(count,1.5)", "Cannot cast value 1.5 to int: expected an integer"), + ], +) +def test_invalid_queries_raise_requela_error(query_string: str, message: str) -> None: + """Rejected operators, orderings, unknown fields and bad values raise `RequelaError`.""" + with pytest.raises(RequelaError, match=message): + EventRules().build_query(query_string) + + +def test_value_converter_converts_values_after_casting() -> None: + """`value_converter` receives the value cast to the field type and its result is used.""" + query = KeeperEventRules().build_query("gte(time,2024-01-01)") + assert query.filter == {"time": {"$gte": int(datetime(2024, 1, 1, tzinfo=UTC).timestamp())}} + + +def test_value_converter_converts_each_item_of_in() -> None: + """`value_converter` is applied to every item of `in` and `out`.""" + query = EventWithConvertersRules().build_query("in(count,(1,2))") + assert query.filter == {"count": {"$in": ["1", "2"]}} + + +def test_value_converter_is_not_applied_to_null() -> None: + """`null()` is never passed to `value_converter`.""" + query = EventWithConvertersRules().build_query("eq(note,null())") + assert query.filter == {"note": None} + + +def test_value_converter_is_not_applied_to_like_patterns() -> None: + """`like`/`ilike` patterns are wildcard strings and are never converted.""" + query = EventWithConvertersRules().build_query("and(eq(description,abc),like(description,ab*))") + assert query.filter == { + "$and": [{"description": "ABC"}, {"description": {"$regex": "^ab.*\\z"}}] + } + + +def test_value_converter_errors_raise_requela_error() -> None: + """A `ValueError` from `value_converter` is raised as `RequelaError`.""" + with pytest.raises(RequelaError, match="Rejected value 'x'"): + EventWithConvertersRules().build_query("eq(note,x)") + + +def test_documentation_lists_object_id_fields_and_relationships() -> None: + """`get_documentation()` lists `ObjectId` fields and nested relationships with aliases.""" + documentation = EventRules().get_documentation().splitlines() + assert "|id|eq, gt, gte, in, lt, lte, ne, out|yes|" in documentation + assert "|origin|eq, ne|no|" in documentation + assert "|origin.name|eq, ilike, in, like, ne, out|yes|" in documentation + assert "|tags.aliases.name|eq, ilike, in, like, ne, out|yes|" in documentation + assert "|tags.key|eq, ilike, in, like, ne, out|yes|" in documentation diff --git a/tests/settings.py b/tests/settings.py index a0feced..e20e0de 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,7 +1,9 @@ import os -# PostgreSQL is mandatory for the test suite. The default matches the `postgres` service -# in docker-compose.yaml started with `docker compose up -d postgres`. +# PostgreSQL and MongoDB are mandatory for the test suite. The defaults match the +# `postgres` and `mongo` services in docker-compose.yaml (`docker compose up -d --wait`). DATABASE_URL = os.environ.get( "DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/requela" ) + +MONGO_URL = os.environ.get("MONGO_URL", "mongodb://localhost:27017") diff --git a/tests/sqlalchemy/rules.py b/tests/sqlalchemy/rules.py index d169e26..2abc0ee 100644 --- a/tests/sqlalchemy/rules.py +++ b/tests/sqlalchemy/rules.py @@ -92,3 +92,17 @@ class AccountWithDuplicatedRelationAliasRules(ModelRQLRules): tenant = RelationshipRule(alias="owners", rules=TenantRules()) users = RelationshipRule(alias="owners", rules=UserNameRules()) + + +class UserWithConvertersRules(ModelRQLRules): + __model__ = User + + name = FieldRule(value_converter=str.upper) + age = FieldRule(value_converter=lambda value: value * 12) + is_active = FieldRule(value_converter=lambda value: not value) + + +class UserWithFlagStoredAsIntRules(ModelRQLRules): + __model__ = User + + is_active = FieldRule(value_converter=int) diff --git a/tests/sqlalchemy/test_rules.py b/tests/sqlalchemy/test_rules.py index c6c93bb..373469a 100644 --- a/tests/sqlalchemy/test_rules.py +++ b/tests/sqlalchemy/test_rules.py @@ -1,4 +1,5 @@ from datetime import date, datetime +from typing import Any import pytest from sqlalchemy import exists, select @@ -15,6 +16,8 @@ AccountWithDuplicatedRelationAliasRules, AccountWithPrefixedRelationsRules, UserRules, + UserWithConvertersRules, + UserWithFlagStoredAsIntRules, ) from tests.sqlalchemy.utils import assert_statements_equal @@ -288,3 +291,44 @@ def test_relationship_alias_shared_by_two_relationships_is_rejected() -> None: account_filter = AccountWithDuplicatedRelationAliasRules() with pytest.raises(RequelaError, match="Multiple relations found for alias 'owners.name'"): account_filter.build_query("eq(owners.name,John)") + + +@pytest.mark.parametrize( + ("query_string", "expected"), + [ + ("eq(name,john)", User.name == "JOHN"), + ("ne(name,john)", User.name != "JOHN"), + ("gt(age,2)", User.age > 24), + ("lte(age,2)", User.age <= 24), + ("in(name,(john,jane))", User.name.in_(["JOHN", "JANE"])), + ("out(name,(john,jane))", User.name.not_in(["JOHN", "JANE"])), + ("eq(is_active,true)", User.is_active.is_(False)), + ("ne(is_active,true)", User.is_active.isnot(False)), + ("eq(name,null())", User.name.is_(None)), + ], +) +def test_value_converter_converts_filter_values(query_string: str, expected: Any) -> None: + """`value_converter` converts every compared value except `null()`.""" + stmt = UserWithConvertersRules().build_query(query_string) + assert_statements_equal(stmt, select(User).filter(expected)) + + +def test_value_converter_is_not_applied_to_like_patterns() -> None: + """`like` patterns are wildcard strings and are never converted.""" + stmt = UserWithConvertersRules().build_query("like(name,jo*)") + assert_statements_equal(stmt, select(User).filter(User.name.like("jo%"))) + + +@pytest.mark.parametrize( + ("query_string", "expected"), + [ + ("eq(is_active,true)", User.is_active == 1), + ("ne(is_active,true)", User.is_active != 1), + ], +) +def test_value_converter_result_that_is_not_boolean_is_compared( + query_string: str, expected: Any +) -> None: + """A boolean converted to a non-boolean value is compared with `=`/`!=`, not `IS`.""" + stmt = UserWithFlagStoredAsIntRules().build_query(query_string) + assert_statements_equal(stmt, select(User).filter(expected)) diff --git a/uv.lock b/uv.lock index 4e84d26..0eb7a5a 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.12, <4" +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "asgiref" version = "3.8.1" @@ -133,6 +142,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/0f/7e042df3d462d39ae01b27a09ee76653692442bc3701fbfa6cb38e12889d/Django-5.1.7-py3-none-any.whl", hash = "sha256:1323617cb624add820cb9611cdcc788312d250824f92ca6048fda8625514af2b", size = 8276912, upload-time = "2025-03-06T12:52:12.784Z" }, ] +[[package]] +name = "dnspython" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz", hash = "sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1", size = 423560, upload-time = "2026-10-09T00:07:24.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl", hash = "sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9", size = 354822, upload-time = "2026-10-09T00:07:22.622Z" }, +] + [[package]] name = "executing" version = "2.2.0" @@ -436,6 +454,127 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "pydantic" +version = "2.14.1" +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/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, + { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, + { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, +] + [[package]] name = "pygments" version = "2.19.1" @@ -445,6 +584,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pymongo" +version = "4.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/8b/a9d214044153cb7d9141229d3e1b171cdf4f460fa07cade9354c4ce2f84d/pymongo-4.19.0.tar.gz", hash = "sha256:3c510dd3c5d9b392d3b33bb5d2a594758acfe8f026fca654253f947ce0af9d40", size = 2689381, upload-time = "2026-10-14T19:48:19.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/a3/47f2c964779c395314b1dc5506df9d00d4ba26c1aa6f35674e81a4a418d3/pymongo-4.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d28d6ff5cec9fd405657de12128e3faafb9c4a0b0194527e3d761dd9d083d7a7", size = 827051, upload-time = "2026-10-14T19:46:20.446Z" }, + { url = "https://files.pythonhosted.org/packages/4f/58/d4ee8dac050365c0de8ca3ad02aafb9128176d63b9145ace2865c7850d2e/pymongo-4.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcf04e36e192791fb07f53e3a508c4752e6e0bba7aeda5cee10a84b3ccd0ca44", size = 827371, upload-time = "2026-10-14T19:46:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/83e24645c49cb66631e3802b574deba362e2712c92228f0853e44c10b098/pymongo-4.19.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:117e64c5ba2755d147bea31c86f3b4cd59ec8fb0f44cbae2f49e1502ff226789", size = 1047887, upload-time = "2026-10-14T19:46:23.669Z" }, + { url = "https://files.pythonhosted.org/packages/36/02/f9336de0777074c37f164901bb28c9b6cd26e366e054f1b9d0e0938be380/pymongo-4.19.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f072289060739430d2ded949a196939c3e3ff8ba4469b40e4833b5f1d8b0943", size = 1058974, upload-time = "2026-10-14T19:46:25.416Z" }, + { url = "https://files.pythonhosted.org/packages/37/b9/01c3e07d93ec955ca72ef20f8ecacf77b4e75ad2b453acadd356c924e05c/pymongo-4.19.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff9679803b691aa5ff6efe4de2d715e65e1784641e334d701b7b80a0776c35f8", size = 1082922, upload-time = "2026-10-14T19:46:27.605Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/989bb02c9fb545304d88b77727c62fd215c46df43a8847d07960aad00227/pymongo-4.19.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:03ae5228d97eb465e42cd3058888be6892146296a600e8038b6dd3a4c4ac20fe", size = 1076649, upload-time = "2026-10-14T19:46:29.49Z" }, + { url = "https://files.pythonhosted.org/packages/44/1b/e8364fadbc05bff19e67dda4f151e63fb13c58252cd5e1e1750c22cc1b8f/pymongo-4.19.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5af9e52dfd18224474d5f54817ef2cbf06e313d100772a4a72aea8394037941", size = 1058052, upload-time = "2026-10-14T19:46:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f0/b562a891e73ae371f26fd9aa949c69f9596e720431a25396b8f9416a5194/pymongo-4.19.0-cp312-cp312-win32.whl", hash = "sha256:43debbb3e14be3db2764a77f14da2ac220b8ff192b485145855574127e2feee2", size = 822933, upload-time = "2026-10-14T19:46:32.885Z" }, + { url = "https://files.pythonhosted.org/packages/ac/1d/dda443f738b63e34f045ba0249e03e0010e0406c093eb9af9c2468d56300/pymongo-4.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:4fd6db124a081b627fb86e1f1d681a58f42c6ae2ec876c6e2015f1d516931ea9", size = 829709, upload-time = "2026-10-14T19:46:34.605Z" }, + { url = "https://files.pythonhosted.org/packages/25/53/0392704674a921e9798eddc726045a01a554748dc7e80ec00d6577c76099/pymongo-4.19.0-cp312-cp312-win_arm64.whl", hash = "sha256:6073c762dbd4d0d17acbdd3aac4004750eec842fa40aa10965451367963f40d6", size = 825566, upload-time = "2026-10-14T19:46:36.382Z" }, + { url = "https://files.pythonhosted.org/packages/ef/17/67576f517eeb18ce214e483164b0e8e124c3baee07aa114d3a5c5e72d2cb/pymongo-4.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:701c4a102c8794a1f656ff9c06ec9269276fb5f62c268359ee68d46163655b68", size = 826947, upload-time = "2026-10-14T19:46:38.094Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5a/15074c71298adfe468f7aa02080b2bdfc17bf9752d4855893df96a2b6718/pymongo-4.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae2eb0a729de0b009de52b76003e4f1f19fd28cda88ec7a81c51faf90dd1587b", size = 827243, upload-time = "2026-10-14T19:46:39.827Z" }, + { url = "https://files.pythonhosted.org/packages/50/45/bf0d840668f8932d6342c026a6ac9070d60c79a18453ab1fea5632688336/pymongo-4.19.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e8e44c4229cfe7e36fc5772b2c4c2d273b141bf9a212829ad5b0cc402efcd629", size = 1048053, upload-time = "2026-10-14T19:46:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/95/46/661e222349c1a9c64d83f859404076fc4e1063e395643f3526e013b5a74c/pymongo-4.19.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7204210e9a613aef743b9c7a2e1f07406c21090b61b9338e3d96bb8b2b14b36", size = 1058956, upload-time = "2026-10-14T19:46:43.505Z" }, + { url = "https://files.pythonhosted.org/packages/b6/11/d3e355464b01786a11700e70266d649c29ab281e98c7e32ca4b7ffb2d83c/pymongo-4.19.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab0167d3c99a33a119befa93f1771ef0436832275ed6fd95c68b2535dae3f2e7", size = 1082727, upload-time = "2026-10-14T19:46:45.142Z" }, + { url = "https://files.pythonhosted.org/packages/a3/eb/40f52875c43952533f0faa683a607600842df55e58a66d88dab22955f5f2/pymongo-4.19.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df57b703b0b07c35860da7b214735b7750b2f2a5288f296dc08eeaf10cf8c46a", size = 1076511, upload-time = "2026-10-14T19:46:47.067Z" }, + { url = "https://files.pythonhosted.org/packages/0c/98/ad65d39cab6cf071d09823aa525a0ff531cb9a4868130b9dfc44bb84828b/pymongo-4.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d199721ab77c83a7da83fcd219d3b819c559d8133e66c0d9bec9408001649f7", size = 1058253, upload-time = "2026-10-14T19:46:49.138Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/9ea41c62a2ca75326424eda2e798aa4269d2cfe221c662df5181274728dc/pymongo-4.19.0-cp313-cp313-win32.whl", hash = "sha256:54877c8e89add9ed115316722ead430d422b95d475b4eb57663bc6e017587853", size = 822970, upload-time = "2026-10-14T19:46:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/73/04/4622fcc48338b1f59318e4488327248dc3e8eeb1c2886c477d319632d803/pymongo-4.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:2f5719dfbb5527a55dfaf6a68164df118efc13fffd00bc2ee9231488c1e8e03a", size = 829736, upload-time = "2026-10-14T19:46:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/3a15fda4d2bbc91bfb186d72e40528b8bb52ad6fcf336221dc41dbbeafc0/pymongo-4.19.0-cp313-cp313-win_arm64.whl", hash = "sha256:9bf359a18df79981ea775b90c4c1fa044480b8896c0ff45932e568b0aed6a9eb", size = 825616, upload-time = "2026-10-14T19:46:55.076Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e7/6e62d60303a1e5cc816cefaa4d57d74df8ee65753ee9fe154b5fad851de3/pymongo-4.19.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:08c354566ab8b5dce6d805f35d61b5575455d3ea1835d7b90151d53e8c32e669", size = 826849, upload-time = "2026-10-14T19:46:56.892Z" }, + { url = "https://files.pythonhosted.org/packages/e7/68/b2f67b99f22c5543a8be397c0ed8dee526c23717b4491405ae513138d88c/pymongo-4.19.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06b9ee12c4ceb7fb6ff8a7ab0465814c1cb5e5c6c2c452cb18eab7435b38a5b2", size = 827363, upload-time = "2026-10-14T19:46:58.842Z" }, + { url = "https://files.pythonhosted.org/packages/02/bb/35e17473d000bc0517190aabe1429853aa142499370dbd6d7ae3743e8833/pymongo-4.19.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec25ab536e42e48fde356c6fc86e66f548e5af0cc584365e2ec34d3683be5a63", size = 1049786, upload-time = "2026-10-14T19:47:00.537Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/83cc2961d977c1ba36662f24ae55c9f5dbee2845ca615146fec0f4eda053/pymongo-4.19.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e65783e95b37c3387ed1105fe01e2be6b1b394c22331c5e8cc2fed2c3a30a06", size = 1059425, upload-time = "2026-10-14T19:47:02.511Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/baa32ef582f9edf3112b00f6e271cf5f83c481edcf999e2f462898990e87/pymongo-4.19.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3264b209b6319cae120306e266ed5fa9c7bc071b73ba5e13cbad23a6cbd73d2", size = 1082787, upload-time = "2026-10-14T19:47:04.38Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/949a24776ceba31e9b731f7048dce4fbb913047afd16580a61723143afb9/pymongo-4.19.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:212dbc97f8e813a24639aaaef38503d84f7652d00b88b391f87762ba4c1f1709", size = 1073579, upload-time = "2026-10-14T19:47:06.247Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b0/a577ab8eff3772cf7036118b4e407a8cbb53add7bbe322f011871eb6db44/pymongo-4.19.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2faa34469b052635c81dcec6b07fc5757d4aba0ec60f94c6658c7fa6f887bc46", size = 1057865, upload-time = "2026-10-14T19:47:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/95/cf/81b1d8a35ac3e5d5dcd8fc466f9acdd8f67a5035da130afb0d76e2efd6ac/pymongo-4.19.0-cp314-cp314-win32.whl", hash = "sha256:eee3fc70ea4253c8c7a6bd7917be468c5ef0a2860898766dd55497a563ddda94", size = 823938, upload-time = "2026-10-14T19:47:10.086Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c5/1aa13304c714ad81ab70feb6bd99f6514baafe8e6c84d243ffabae678379/pymongo-4.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac673404456b23c568cea326ab996a6b35a6009e41d42bcb774db025d0918b7d", size = 831074, upload-time = "2026-10-14T19:47:12.088Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a8/5de505ba380af3d10737a2d0ddd2c6752ff6e9a0fe484c992483efe74889/pymongo-4.19.0-cp314-cp314-win_arm64.whl", hash = "sha256:2bb0e7c422c14ff2b31ec8be3e6ecaad326c17fca17071bcfcd13482584a8e0f", size = 826686, upload-time = "2026-10-14T19:47:13.959Z" }, + { url = "https://files.pythonhosted.org/packages/9a/fc/eddcc314b76ab9f3ab1417ecc088f88336cc2bca5be1356c8aa3d183dda8/pymongo-4.19.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b01cc054878931ea81fc0a57c4c10489db723b8d7275fb10070f7228149012f1", size = 829839, upload-time = "2026-10-14T19:47:15.761Z" }, + { url = "https://files.pythonhosted.org/packages/87/51/caa4ac1f33d4b8a4de2469a0624ffc7f7fae7441f7d71d41c2be306734a4/pymongo-4.19.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:823f8b2fb59e4e635e296d5e92efa883e3d01a8faa477d515fc9dfe515368026", size = 830216, upload-time = "2026-10-14T19:47:17.789Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e7/b3eb14aa900cfe7b6f7c0dd2349b5d0a488c17a9db76a8bfdf8bd30afd9d/pymongo-4.19.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1435721737b46be9bab5aa2374cfe57de934dc4ac421d5473308aa94c9fa39c3", size = 1113851, upload-time = "2026-10-14T19:47:19.743Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/ec2c2bdde80e23693703f01805a1e37509e088127177f2d5758ca05c9a79/pymongo-4.19.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dee18feff3203fa128798c6673c7795ef8a46d0b32c0e6b920c7b3f46129447", size = 1133130, upload-time = "2026-10-14T19:47:21.617Z" }, + { url = "https://files.pythonhosted.org/packages/40/df/4f1bada8fa02babd094a5c4ed8f4ea1dc76cfc1366b26238a2ad1fc55b51/pymongo-4.19.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8d866560dfbe44bc5e1110e96af4b8d92ffe6368c345dac1c36c8060188ebba6", size = 1152659, upload-time = "2026-10-14T19:47:23.572Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/a97d315c4c4e362d1f2e216d306122ae0f713ab457f73730684f3606a349/pymongo-4.19.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:47f04522f786dca82c776d5c3ed3ff9d08d6bf4cd0074c42296da5fac4d816ad", size = 1144177, upload-time = "2026-10-14T19:47:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/8d/59/2a6c68bdee03f326194361149c68ec6720a22460d11a2a43a0742a7d7fce/pymongo-4.19.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac55cf643eaa6146822f5f05f07be4dedbed906f525bb2ee098a865c4892788a", size = 1125673, upload-time = "2026-10-14T19:47:27.582Z" }, + { url = "https://files.pythonhosted.org/packages/20/c1/b108dda370e09db7a4dccfb2bb003e769a8dd98513135e4429040cb88b83/pymongo-4.19.0-cp314-cp314t-win32.whl", hash = "sha256:3bcebec2536a9aec1d490ad6fa9fc7ffc3329059fb1f99154efa5d594abdc98c", size = 826636, upload-time = "2026-10-14T19:47:29.463Z" }, + { url = "https://files.pythonhosted.org/packages/b9/55/a0da8479007f149838c094f6f863fc05c973abf6802654881a4dfc68858e/pymongo-4.19.0-cp314-cp314t-win_amd64.whl", hash = "sha256:24668c6990bef96e1558328ba0802279cc1f752a3bcc7b283c2f39099a01e28c", size = 835337, upload-time = "2026-10-14T19:47:31.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/d0/9837244d18d8280277e7b2e9366ee2b9d35338052362888a4704d77ad633/pymongo-4.19.0-cp314-cp314t-win_arm64.whl", hash = "sha256:542b0f4e47fe68e753c85503f8352d4baa81ac73593601c8ede0fa22ba5c0431", size = 827513, upload-time = "2026-10-14T19:47:33.367Z" }, + { url = "https://files.pythonhosted.org/packages/97/6c/af80cf714a91b41441e9ad0aeac1af2000d902dfef7bac31388ba05bbfe7/pymongo-4.19.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:cc81d7ceeb7766254bce7ad7644dddb44241fb57555cd7c71de305b6903493b8", size = 826913, upload-time = "2026-10-14T19:47:35.317Z" }, + { url = "https://files.pythonhosted.org/packages/95/14/2ed9ee6c83fd05a36d310100562b599ea987d2339c57955b1afba80d07ec/pymongo-4.19.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b602baef46ec5cd876fdf45dfdf864a58f5a507129393b93b8248249008f9a70", size = 827513, upload-time = "2026-10-14T19:47:37.463Z" }, + { url = "https://files.pythonhosted.org/packages/78/78/cd65885104e7b37f8cb7dd7e33d0b2c2415270afc2644ed643b52f526214/pymongo-4.19.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:179bc536b73fc76ae3d227114123ffc804f002fb45ddd996a81b233e806a0d2d", size = 1052003, upload-time = "2026-10-14T19:47:39.539Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ed/99fc74ed08dded2351818bf374303ddc400bd2e8b5ab297dac352aa0df56/pymongo-4.19.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4bd5e3ecd44d94b4eeef51f7e20a513206f2fceeab9534e9299c31133cc2e42", size = 1061762, upload-time = "2026-10-14T19:47:41.601Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8b/ded0ef32a2c4032cbec796f29b7b6067e76ac27714fbcfe06ce9a969b415/pymongo-4.19.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a38cfd2d81daef820a099c28065c6dc2ec9254ae80fefcf7981ea27e5381159", size = 1084314, upload-time = "2026-10-14T19:47:43.874Z" }, + { url = "https://files.pythonhosted.org/packages/52/64/82099393a7178c80fe1b16cc5dca94f388dec3df7a3f059a7b831bbf10dd/pymongo-4.19.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:567e509e1e01c956bfd5e60805b7d582aae45eeba34e9690d0da6f09560afb4f", size = 1075560, upload-time = "2026-10-14T19:47:45.904Z" }, + { url = "https://files.pythonhosted.org/packages/09/d2/1eab760f5dc3d09fbc8fec7ad2474def3c8d2efbeb8550bff12fed61f863/pymongo-4.19.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c3a47a6b325ac605352e9825ef658e6cca4f612e3a09838a564859f7d5435ea", size = 1060541, upload-time = "2026-10-14T19:47:47.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7d/426c1b661e8b4bd78671ea063ee66005faff0dfe6731ebdce0fb0000c339/pymongo-4.19.0-cp315-cp315-win32.whl", hash = "sha256:5d684e289cdb687f1508b15a44d3c0268f974c92ba129f658c1ef1fd196854e7", size = 823985, upload-time = "2026-10-14T19:47:50.253Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e9/f2ece0253d82d34fad0a316ffec848ac4e85357cae849cd5ea29def72ae4/pymongo-4.19.0-cp315-cp315-win_amd64.whl", hash = "sha256:546350d196b01b7feff7f8e6d140b6d4ab47486d5ae70dab858605cdfc2ffe1d", size = 831173, upload-time = "2026-10-14T19:47:52.418Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e0/be46ba1676cd04f831a9d4f6f8dbe0d3f816034788b8e3157762139f7aa8/pymongo-4.19.0-cp315-cp315-win_arm64.whl", hash = "sha256:d29ea47eebbeec81b67809fbb3440ffc53628d28f5b9f21624eed0038d9fddaa", size = 826603, upload-time = "2026-10-14T19:47:54.538Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/3e04d21eab4844372ef141d5cc4f5e03d4fb9ebda057dd5e5ef1db562433/pymongo-4.19.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:b7e8b5b546e31ac63255650b0bf764383885a6c657b3269e83b9e1e5de3ed129", size = 829820, upload-time = "2026-10-14T19:47:56.428Z" }, + { url = "https://files.pythonhosted.org/packages/44/9c/dbad3291c3614a884285d10e2cc123567386d682bf8a08caf5e0a630bf3e/pymongo-4.19.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f21109534f5555cf77689ad323a21fbc07e8a397b34f157938a347725d83b7b5", size = 830313, upload-time = "2026-10-14T19:47:58.457Z" }, + { url = "https://files.pythonhosted.org/packages/68/2d/17e783859c89e749fe63803a08ab5e85ca0ee8416f0cbe84d5fe6efa2981/pymongo-4.19.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3af5ab5a9e490580d3f40660665f0f4d579a324e25acee6372e1508e4b7c7b7a", size = 1117096, upload-time = "2026-10-14T19:48:00.917Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/0b23e363eb5856ecfdf3b7edbdfea7f964da664eb78e507bb8375820c7e5/pymongo-4.19.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb9d9bff4f666405cd9d7a17b6127294394847dce60ca38d8ba45f4879ada6c9", size = 1134854, upload-time = "2026-10-14T19:48:03.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/b8/60758f35a90729d77fdfd36eeff5ddf191d9f198528074884d816865d942/pymongo-4.19.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be75840640e98ea4b5f150bceda8a55f1085e395732e21da028195da30ae79b5", size = 1154121, upload-time = "2026-10-14T19:48:05.638Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/e2b56cf154bf1dff7deca641de160215f9163253609a8beb780dc35f007b/pymongo-4.19.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa39c6ddaf987a48ef073ff7fc225b84282079a46fbabaea9c5fcb6f89476e44", size = 1143786, upload-time = "2026-10-14T19:48:07.734Z" }, + { url = "https://files.pythonhosted.org/packages/d1/88/39b61ede07785568d47229a01e7e82fc3903f5cac55ad377e0e64a0d324a/pymongo-4.19.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b92aa4cc4b0bf67a18e3c73062ef70e00ca6921c742aa4d0f4770a493193c661", size = 1126770, upload-time = "2026-10-14T19:48:09.891Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/391d41d24384545b2a6ed09694b2444f765a6e20932c75ed4eb507c9ef36/pymongo-4.19.0-cp315-cp315t-win32.whl", hash = "sha256:eececca812e8f5b3c12ad33dc90201ac20f5f193da446f7719f4321a0841387b", size = 826638, upload-time = "2026-10-14T19:48:11.962Z" }, + { url = "https://files.pythonhosted.org/packages/d1/48/96b923a2d29456896c7f11f8e6104339818112f5a8621f42ba51f131a510/pymongo-4.19.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f17b100fdc16b65c12997ec4fcc78eecc0a6395254c7ec92a4596e855ff1f33a", size = 835209, upload-time = "2026-10-14T19:48:14.063Z" }, + { url = "https://files.pythonhosted.org/packages/46/6b/2ede9f64d96393e8111d250620f5340d64e62f4617322a43800516027ce9/pymongo-4.19.0-cp315-cp315t-win_arm64.whl", hash = "sha256:bfcb5f8912edd9714a52564ad41c0dcd72e5408d1d3d67b41f6145df4a516318", size = 827452, upload-time = "2026-10-14T19:48:17.534Z" }, +] + [[package]] name = "pytest" version = "8.3.5" @@ -539,6 +749,8 @@ dev = [ { name = "mypy" }, { name = "pre-commit" }, { name = "psycopg2-binary" }, + { name = "pydantic" }, + { name = "pymongo" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -558,6 +770,8 @@ dev = [ { name = "mypy", specifier = ">=1.15.0" }, { name = "pre-commit", specifier = ">=4.1.0" }, { name = "psycopg2-binary", specifier = ">=2.9.9" }, + { name = "pydantic", specifier = ">=2" }, + { name = "pymongo", specifier = ">=4.13" }, { name = "pytest", specifier = ">=8.3.3,<9.0" }, { name = "pytest-asyncio", specifier = ">=0.24.0,<1.0" }, { name = "pytest-cov", specifier = ">=6.0.0,<7.0" }, @@ -659,11 +873,23 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.12.2" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] [[package]]