From 7abb513b305720d05cc6ca380dab50d2f500d4ee Mon Sep 17 00:00:00 2001 From: Oleg Yurchik Date: Fri, 14 Aug 2026 19:58:32 +0300 Subject: [PATCH] refactor: migrate to keyword-based repository config, add nested transactions and tests --- .gitignore | 1 + AGENTS.md | 41 ++++--- README.md | 167 ++++++++++------------------ examples/basic_usage.py | 16 +-- examples/container_usage.py | 44 ++++++-- examples/dto_usage.py | 16 +-- examples/filter_usage.py | 23 ++-- examples/nested_transactions.py | 64 +++++++++++ examples/relationships.py | 32 ++++-- examples/transactions.py | 33 ++++-- metaorm/__init__.py | 22 +++- metaorm/container.py | 26 ++++- metaorm/repositories.py | 146 +++++++++++-------------- metaorm/settings.py | 2 +- pyproject.toml | 14 +-- tests/conftest.py | 10 +- tests/models.py | 42 ++++---- tests/test_container.py | 66 +++++++++++- tests/test_repositories.py | 186 +++++++++++++++++++++++++++++--- tests/test_settings.py | 12 +-- tests/test_tables.py | 3 +- uv.lock | 16 ++- 22 files changed, 658 insertions(+), 324 deletions(-) create mode 100644 examples/nested_transactions.py diff --git a/.gitignore b/.gitignore index 61c0618..ff86aa3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Byte-compiled / optimized / DLL files __pycache__/ .pytest_cache/ +.ruff_cache/ *.py[cod] *$py.class diff --git a/AGENTS.md b/AGENTS.md index d069bfe..193d296 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,12 +10,13 @@ metaorm/ repositories.py # BaseRepository tables.py # BaseTable container.py # RepositoriesContainer (session/transaction manager) - settings.py # DatabaseSettings (Pydantic model) + settings.py # RepositorySettings (Pydantic model) exceptions.py # Domain exceptions examples/ # Usage examples basic_usage.py # Simple CRUD with tables directly - dto_usage.py # DTO mapping via get_dto_type() + dto_usage.py # DTO mapping via dto= keyword transactions.py # Explicit transaction management + nested_transactions.py # Savepoints and partial rollback filter_usage.py # Query filters, pagination and sorting relationships.py # Eager loading with joinedload/selectinload container_usage.py # Multi-repository atomic transactions @@ -49,20 +50,33 @@ If `ItemType` is not specified (e.g. `BaseTable` without generic arg), `from_ite ### BaseRepository -`BaseRepository` is **not** a Generic class. Type behavior is controlled by overriding methods: +`BaseRepository` uses `__init_subclass__` to enforce keyword arguments at class-definition time. + +- `table` — required. The SQLModel table class. Must be specified on the first concrete subclass; intermediate bases that already specify it do not need to repeat it. +- `filter_` — required. A `BaseFilter` subclass. +- `dto` — optional. When provided, repository methods map table rows to that DTO type. + +`table=` must be provided on the first subclass in the hierarchy. + +The following introspection helpers are available as classmethods: + +- `get_table_type()` — returns the `table` class specified at definition time. +- `get_filter_type()` — returns the `filter_` class. +- `get_dto_type()` — returns the `dto` class, or `None` if no DTO was set. ```python -class UserRepository(BaseRepository): - def get_db_table(self) -> type[UserTable]: - return UserTable - - def get_dto_type(self) -> type[User] | None: - return User # Methods return User instances +class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter, dto=User): + pass # Methods return User instances ``` -If `get_dto_type()` returns `None` (default), repository methods return table instances directly (no DTO conversion). This is the simplest mode when you don't need a separate DTO layer. +If `dto` is omitted, repository methods return table instances directly: -If `get_filter_type()` returns a `BaseFilter` subclass, type hints on `filter_` parameters reflect that type. Note: `pydantic-filters` from GitHub is required for filter support (PyPI version is broken with pydantic v2). +```python +class ProductRepository(BaseRepository, table=ProductTable, filter_=ProductFilter): + pass # Methods return ProductTable instances +``` + +Note: `pydantic-filters` from GitHub is required for filter support (PyPI version is broken with pydantic v2). The project currently uses a fork with Python 3.14 lazy-annotations support: `git+https://github.com/OlegYurchik/pydantic-filters.git@fix/compare-to-pydantic-2.12`. #### Eager loading (options) @@ -83,7 +97,7 @@ books = [ ```python # Simple: create container internally -repo = UserRepository(settings=DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:")) +repo = UserRepository(settings=RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")) # Advanced: reuse container for shared transactions container = RepositoriesContainer(settings=settings) @@ -110,7 +124,8 @@ async with container.transaction(): - Each repository method (`get_items`, `create_item`, etc.) wraps its operation in a transaction via `self.transaction()`. - `self.transaction()` reuses an existing session from the context if one exists, otherwise creates a new one. -- Accessing `repository.session` outside a transaction raises `HaveNoSessionError`. +- `repository.session` and `container.session` both return the current `AsyncSession` or `None` if no session is active. Both `repository.transaction()` and `container.transaction()` yield the `AsyncSession` and handle nested calls by reusing the same session. +- `repository.nested_transaction()` and `container.nested_transaction()` create a SQLAlchemy savepoint (`begin_nested()`). When no outer session exists they start a new session with a savepoint. On exception the savepoint is rolled back, leaving any outer transaction unaffected. ## Development diff --git a/README.md b/README.md index 29fc036..c373241 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # MetaORM -Async repository layer over [SQLModel](https://sqlmodel.tiangolo.com). Provides a minimal, explicit pattern for database access with optional DTO mapping, automatic transaction management via `contextvars`, and built-in filter / pagination / sort support via `pydantic-filters`. +Async repository layer over [SQLModel](https://sqlmodel.tiangolo.com). Define a table, a repository with keyword arguments, and you have a complete async CRUD layer. + +- **Minimal API** — `create_item`, `get_items`, `update_items`, `delete_items`. That's it. +- **Built-in DTO mapping** — return table instances directly or map to separate Pydantic models. +- **Intuitive transactions** — every CRUD call runs in a transaction; explicit `transaction()` context manager for custom scopes. +- **Nested transactions (savepoints)** — `nested_transaction()` allows partial rollback inside a shared transaction. +- **Multi-repo atomic transactions** — `RepositoriesContainer` lets several repositories share one atomic transaction. +- **Filters, pagination, sorting** — powered by `pydantic-filters`. +- **Eager loading** — pass SQLAlchemy `joinedload` / `selectinload` via `options`. ## Install @@ -14,11 +22,8 @@ Requires Python `>=3.12`. ## Quick start -The simplest mode works with SQLModel tables directly — no DTOs, no generics, no magic: - ```python -from sqlmodel import Field -from metaorm import BaseRepository, BaseTable, DatabaseSettings +from metaorm import BaseFilter, BaseRepository, BaseTable, RepositorySettings, Field class UserTable(BaseTable, table=True): @@ -28,129 +33,68 @@ class UserTable(BaseTable, table=True): email: str = Field(unique=True) -class UserRepository(BaseRepository): - def get_db_table(self) -> type[UserTable]: - return UserTable +class UserFilter(BaseFilter): + name: str | None = None + email: str | None = None + + +class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter): + pass async def main(): repo = UserRepository( - settings=DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:"), + settings=RepositorySettings(dsn="sqlite+aiosqlite:///:memory:"), ) await repo.create_tables() - user = await repo.create_item( - UserTable(name="Alice", email="alice@example.com"), - ) + user = await repo.create_item(UserTable(name="Alice", email="alice@example.com")) print(user.id, user.name) all_users = [u async for u in repo.get_items()] print(len(all_users)) ``` -## DTO mapping +## Repository API -When you want repository methods to return separate Pydantic models instead of table instances, override `get_dto_type()` and implement `from_item` / `to_item` on the table: +Subclass `BaseRepository` with keyword arguments `table`, `filter_`, and optionally `dto`: ```python -from pydantic import BaseModel -from sqlmodel import Field -from metaorm import BaseRepository, BaseTable, DatabaseSettings +class MyRepository(BaseRepository, table=MyTable, filter_=MyFilter): + pass # returns table instances directly -class User(BaseModel): - id: int | None = None - name: str - - -class UserTable(BaseTable[User], table=True): - __tablename__ = "users" - id: int | None = Field(default=None, primary_key=True) - name: str - - @classmethod - def from_item(cls, item: User) -> "UserTable": - return cls(id=item.id, name=item.name) - - def to_item(self) -> User: - return User(id=self.id, name=self.name) - - -class UserRepository(BaseRepository): - def get_db_table(self) -> type[UserTable]: - return UserTable - - def get_dto_type(self) -> type[User]: - return User - - -async def main(): - repo = UserRepository( - settings=DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:"), - ) - await repo.create_tables() - - user = await repo.create_item(User(name="Alice")) - # user is a User DTO, not UserTable - print(user.model_dump()) +class MyRepositoryWithDto(BaseRepository, table=MyTable, filter_=MyFilter, dto=MyDto): + pass # maps rows to MyDto ``` -## Filters, pagination and sorting +Keyword arguments are checked at class-definition time. If you forget `table` or `filter_`, Python raises `TypeError` immediately. `table=` must still be provided on the first subclass in the hierarchy. -`pydantic-filters` provides `BaseFilter`, `BasePagination` and `BaseSort`. Pass them to `get_items`: +### Constructor ```python -from pydantic_filters import BaseFilter, BaseSort, OffsetPagination +# Simple — container is created internally +repo = MyRepository(settings=RepositorySettings(dsn="...")) -class BookFilter(BaseFilter): - title: str | None = None - year: int | None = None - - -class BookRepository(BaseRepository): - def get_db_table(self) -> type[BookTable]: - return BookTable - - def get_filter_type(self) -> type[BookFilter]: - return BookFilter - - -# Exact match filter -filtered = [ - item - async for item in repo.get_items(filter_=BookFilter(year=2025)) -] - -# Pagination -page = [ - item - async for item in repo.get_items( - pagination=OffsetPagination(offset=10, limit=20), - ) -] - -# Sorting -sorted_items = [ - item - async for item in repo.get_items( - sort=BaseSort(sort_by="year", sort_by_order="desc"), - ) -] +# Advanced — share a container for atomic multi-repo transactions +container = RepositoriesContainer(settings=settings) +repo = MyRepository(container=container) ``` -## Explicit transactions +### Methods -Each repository method already runs inside a transaction automatically. If you need an explicit scope (e.g. to read `repository.session`), use `repository.transaction()`: +| Method | Signature | Description | +|---|---|---| +| `create_tables` | `async () -> None` | Creates the table in the database. | +| `create_item` | `async (item) -> Any` | Inserts one row. Returns the table instance or DTO when `dto=` is set. | +| `get_items` | `async (filter_=None, pagination=None, sort=None, options=None) -> AsyncGenerator[Any]` | Streams matching rows. `options` accepts SQLAlchemy eager-loading strategies such as `joinedload`. | +| `get_items_count` | `async (filter_=None) -> int` | Returns the number of matching rows. | +| `update_items` | `async (filter_=None, options=None, **values) -> AsyncGenerator[Any]` | Updates matching rows and yields the updated instances. | +| `delete_items` | `async (filter_=None) -> None` | Deletes matching rows. | +| `transaction` | `async contextmanager () -> AsyncSession` | Explicit transaction scope. Automatically used by all CRUD methods. Reuses an existing session when nested. | +| `nested_transaction` | `async contextmanager () -> AsyncSession` | Creates a savepoint (nested transaction). Rolls back only the inner scope on error while leaving the outer transaction intact. | -```python -async with repo.transaction(): - user = await repo.create_item(UserTable(name="Alice")) - # nested transaction reuses the same session - async with repo.transaction(): - items = [item async for item in repo.get_items()] -``` - -## Atomic transactions across multiple repositories +### Multi-repository transactions Use `RepositoriesContainer` when you need a single atomic transaction spanning multiple repositories: @@ -166,22 +110,21 @@ async with container.transaction(): await order_repo.create_item(OrderTable(user_id=user.id, total=100)) ``` -`container.transaction()` stores the session in a `contextvars.ContextVar`. All repository operations within the `async with` block automatically reuse that session. Nested `container.transaction()` calls yield the same session. +`container.transaction()` stores the session in a `contextvars.ContextVar`. All repository operations within the `async with` block automatically reuse that session. Nested `transaction()` calls yield the same session. -## Eager loading +For partial rollback inside a shared transaction use `container.nested_transaction()` (or `repository.nested_transaction()`). It creates a SQLAlchemy savepoint: an error inside the block rolls back only the savepoint, leaving the outer transaction open for further operations or commit. -`get_items()` and `update_items()` accept an optional `options` parameter for SQLAlchemy eager loading strategies: +## More examples -```python -from sqlalchemy.orm import joinedload +See [`examples/`](examples/) for detailed usage patterns: -books = [ - item - async for item in book_repo.get_items( - options=[joinedload(BookTable.author)], - ) -] -``` +- [`basic_usage.py`](examples/basic_usage.py) — CRUD with tables directly +- [`dto_usage.py`](examples/dto_usage.py) — DTO mapping via `dto=` keyword +- [`transactions.py`](examples/transactions.py) — Explicit transaction management +- [`nested_transactions.py`](examples/nested_transactions.py) — Savepoints and partial rollback +- [`filter_usage.py`](examples/filter_usage.py) — Query filters, pagination and sorting +- [`relationships.py`](examples/relationships.py) — Eager loading with `joinedload` +- [`container_usage.py`](examples/container_usage.py) — Multi-repository atomic transactions ## Exceptions diff --git a/examples/basic_usage.py b/examples/basic_usage.py index e536ba9..07ef2d3 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -1,8 +1,6 @@ import asyncio -from sqlmodel import Field - -from metaorm import BaseRepository, BaseTable, DatabaseSettings +from metaorm import BaseFilter, BaseRepository, BaseTable, Field, RepositorySettings class UserTable(BaseTable, table=True): @@ -13,13 +11,17 @@ class UserTable(BaseTable, table=True): email: str = Field(unique=True) -class UserRepository(BaseRepository): - def get_db_table(self) -> type[UserTable]: - return UserTable +class UserFilter(BaseFilter): + name: str | None = None + email: str | None = None + + +class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter): + pass async def main() -> None: - settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:") + settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:") repository = UserRepository(settings=settings) await repository.create_tables() diff --git a/examples/container_usage.py b/examples/container_usage.py index afee1b2..5d27838 100644 --- a/examples/container_usage.py +++ b/examples/container_usage.py @@ -1,8 +1,13 @@ import asyncio -from sqlmodel import Field - -from metaorm import BaseRepository, BaseTable, DatabaseSettings, RepositoriesContainer +from metaorm import ( + BaseFilter, + BaseRepository, + BaseTable, + Field, + RepositoriesContainer, + RepositorySettings, +) class UserTable(BaseTable, table=True): @@ -20,18 +25,24 @@ class OrderTable(BaseTable, table=True): total: float -class UserRepository(BaseRepository): - def get_db_table(self) -> type[UserTable]: - return UserTable +class UserFilter(BaseFilter): + name: str | None = None -class OrderRepository(BaseRepository): - def get_db_table(self) -> type[OrderTable]: - return OrderTable +class OrderFilter(BaseFilter): + user_id: int | None = None + + +class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter): + pass + + +class OrderRepository(BaseRepository, table=OrderTable, filter_=OrderFilter): + pass async def main() -> None: - settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:") + settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:") container = RepositoriesContainer(settings=settings) user_repo = container.get_repository(UserRepository) @@ -46,6 +57,19 @@ async def main() -> None: await order_repo.create_item(OrderTable(user_id=user.id, total=100.00)) await order_repo.create_item(OrderTable(user_id=user.id, total=250.50)) + # Nested transaction inside outer transaction (savepoint) + async with container.transaction(): + user = await user_repo.create_item(UserTable(name="Bob")) + try: + async with container.nested_transaction(): + await order_repo.create_item( + OrderTable(user_id=user.id, total=999.99), + ) + raise ValueError("Rollback nested order") + except ValueError: + pass + # Bob stays, the order is rolled back + # Verify results users = [item async for item in user_repo.get_items()] orders = [item async for item in order_repo.get_items()] diff --git a/examples/dto_usage.py b/examples/dto_usage.py index 4d57076..2ef37a9 100644 --- a/examples/dto_usage.py +++ b/examples/dto_usage.py @@ -1,9 +1,8 @@ import asyncio from pydantic import BaseModel -from sqlmodel import Field -from metaorm import BaseRepository, BaseTable, DatabaseSettings +from metaorm import BaseFilter, BaseRepository, BaseTable, Field, RepositorySettings class User(BaseModel): @@ -27,16 +26,17 @@ class UserTable(BaseTable[User], table=True): return User(id=self.id, name=self.name, email=self.email) -class UserRepository(BaseRepository): - def get_db_table(self) -> type[UserTable]: - return UserTable +class UserFilter(BaseFilter): + name: str | None = None + email: str | None = None - def get_dto_type(self) -> type[User]: - return User + +class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter, dto=User): + pass async def main() -> None: - settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:") + settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:") repository = UserRepository(settings=settings) await repository.create_tables() diff --git a/examples/filter_usage.py b/examples/filter_usage.py index 09f434d..4ffe7a5 100644 --- a/examples/filter_usage.py +++ b/examples/filter_usage.py @@ -1,9 +1,14 @@ import asyncio -from pydantic_filters import BaseFilter, BaseSort, OffsetPagination -from sqlmodel import Field - -from metaorm import BaseRepository, BaseTable, DatabaseSettings +from metaorm import ( + BaseFilter, + BaseRepository, + BaseSort, + BaseTable, + Field, + OffsetPagination, + RepositorySettings, +) class BookTable(BaseTable, table=True): @@ -19,16 +24,12 @@ class BookFilter(BaseFilter): year: int | None = None -class BookRepository(BaseRepository): - def get_db_table(self) -> type[BookTable]: - return BookTable - - def get_filter_type(self) -> type[BookFilter]: - return BookFilter +class BookRepository(BaseRepository, table=BookTable, filter_=BookFilter): + pass async def main() -> None: - settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:") + settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:") repository = BookRepository(settings=settings) await repository.create_tables() diff --git a/examples/nested_transactions.py b/examples/nested_transactions.py new file mode 100644 index 0000000..f085898 --- /dev/null +++ b/examples/nested_transactions.py @@ -0,0 +1,64 @@ +import asyncio + +from metaorm import BaseFilter, BaseRepository, BaseTable, Field, RepositorySettings + + +class ProductTable(BaseTable, table=True): + __tablename__ = "products" + + id: int | None = Field(default=None, primary_key=True) + name: str + price: float + + +class ProductFilter(BaseFilter): + name: str | None = None + + +class ProductRepository(BaseRepository, table=ProductTable, filter_=ProductFilter): + pass + + +async def main() -> None: + settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:") + repository = ProductRepository(settings=settings) + + await repository.create_tables() + + # Standalone nested transaction: rollback only the inner scope + try: + async with repository.nested_transaction(): + await repository.create_item(ProductTable(name="Laptop", price=999.99)) + await repository.create_item(ProductTable(name="Mouse", price=29.99)) + raise ValueError("Simulated error inside nested transaction") + except ValueError: + pass + + count = await repository.get_items_count() + print(f"Items after standalone nested rollback: {count}") # 0 + + # Nested transaction inside an outer transaction + async with repository.transaction(): + await repository.create_item(ProductTable(name="Keyboard", price=79.99)) + + try: + async with repository.nested_transaction(): + await repository.create_item( + ProductTable(name="Monitor", price=299.99), + ) + raise ValueError("Nested rollback") + except ValueError: + pass + + # Monitor is rolled back, Keyboard stays in the outer transaction + items = [item async for item in repository.get_items()] + print(f"Items after partial rollback: {len(items)}") # 1 + print(items[0].name) # Keyboard + + # Verify committed results + all_items = [item async for item in repository.get_items()] + print(f"Final items: {[item.name for item in all_items]}") # ["Keyboard"] + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/relationships.py b/examples/relationships.py index 1ded751..234cf46 100644 --- a/examples/relationships.py +++ b/examples/relationships.py @@ -1,9 +1,16 @@ import asyncio from sqlalchemy.orm import joinedload -from sqlmodel import Field, Relationship -from metaorm import BaseRepository, BaseTable, DatabaseSettings, RepositoriesContainer +from metaorm import ( + BaseFilter, + BaseRepository, + BaseTable, + Field, + Relationship, + RepositoriesContainer, + RepositorySettings, +) class AuthorTable(BaseTable, table=True): @@ -23,18 +30,25 @@ class BookTable(BaseTable, table=True): author: AuthorTable = Relationship(back_populates="books") -class BookRepository(BaseRepository): - def get_db_table(self) -> type[BookTable]: - return BookTable +class BookFilter(BaseFilter): + title: str | None = None + author_id: int | None = None -class AuthorRepository(BaseRepository): - def get_db_table(self) -> type[AuthorTable]: - return AuthorTable +class AuthorFilter(BaseFilter): + name: str | None = None + + +class BookRepository(BaseRepository, table=BookTable, filter_=BookFilter): + pass + + +class AuthorRepository(BaseRepository, table=AuthorTable, filter_=AuthorFilter): + pass async def main() -> None: - settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:") + settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:") container = RepositoriesContainer(settings=settings) author_repo = AuthorRepository(container=container) diff --git a/examples/transactions.py b/examples/transactions.py index 3a8c286..7b26799 100644 --- a/examples/transactions.py +++ b/examples/transactions.py @@ -1,8 +1,6 @@ import asyncio -from sqlmodel import Field - -from metaorm import BaseRepository, BaseTable, DatabaseSettings +from metaorm import BaseFilter, BaseRepository, BaseTable, Field, RepositorySettings class ProductTable(BaseTable, table=True): @@ -13,13 +11,17 @@ class ProductTable(BaseTable, table=True): price: float -class ProductRepository(BaseRepository): - def get_db_table(self) -> type[ProductTable]: - return ProductTable +class ProductFilter(BaseFilter): + name: str | None = None + price: int | None = None + + +class ProductRepository(BaseRepository, table=ProductTable, filter_=ProductFilter): + pass async def main() -> None: - settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:") + settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:") repository = ProductRepository(settings=settings) await repository.create_tables() @@ -34,10 +36,23 @@ async def main() -> None: ) print(f"Created in transaction: {product1.name}, {product2.name}") - # Nested transaction reuses existing session + # Reusing an existing session (no new savepoint) async with repository.transaction(), repository.transaction(): items = [item async for item in repository.get_items()] - print(f"Items in nested transaction: {len(items)}") + print(f"Items in reused session: {len(items)}") + + # True nested transaction (savepoint) via repository + try: + async with repository.nested_transaction(): + await repository.create_item( + ProductTable(name="Keyboard", price=79.99), + ) + raise ValueError("Rollback nested") + except ValueError: + pass + + count = await repository.get_items_count() + print(f"Items after nested rollback: {count}") # 2 if __name__ == "__main__": diff --git a/metaorm/__init__.py b/metaorm/__init__.py index c304ca8..216fdbe 100644 --- a/metaorm/__init__.py +++ b/metaorm/__init__.py @@ -1,3 +1,12 @@ +from pydantic_filters import ( + BaseFilter, + BasePagination, + BaseSort, + OffsetPagination, + PagePagination, +) +from sqlmodel import Field, Relationship + from .container import RepositoriesContainer from .exceptions import ( AlreadyExistsError, @@ -6,10 +15,19 @@ from .exceptions import ( NotFoundError, ) from .repositories import BaseRepository -from .settings import DatabaseSettings +from .settings import RepositorySettings from .tables import BaseTable __all__ = ( + # pydantic-filters + "BaseFilter", + "BasePagination", + "BaseSort", + "OffsetPagination", + "PagePagination", + # sqlmodel + "Field", + "Relationship", # container "RepositoriesContainer", # exceptions @@ -20,7 +38,7 @@ __all__ = ( # repositories "BaseRepository", # settings - "DatabaseSettings", + "RepositorySettings", # tables "BaseTable", ) diff --git a/metaorm/container.py b/metaorm/container.py index 9568686..b016247 100644 --- a/metaorm/container.py +++ b/metaorm/container.py @@ -6,13 +6,13 @@ from typing import TypeVar from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlmodel.ext.asyncio.session import AsyncSession -from .settings import DatabaseSettings +from .settings import RepositorySettings RepositoryType = TypeVar("RepositoryType", bound="BaseRepository") # noqa: F821 class RepositoriesContainer: - def __init__(self, settings: DatabaseSettings): + def __init__(self, settings: RepositorySettings): engine_parameters = { "url": settings.dsn, "pool_recycle": settings.pool_recycle, @@ -37,7 +37,7 @@ class RepositoriesContainer: @asynccontextmanager async def transaction(self) -> AsyncGenerator[AsyncSession, None]: - existing_session = self._session_context.get() + existing_session = self._session_context.get(None) if existing_session is not None: yield existing_session return @@ -54,5 +54,25 @@ class RepositoriesContainer: finally: self._session_context.reset(token) + @asynccontextmanager + async def nested_transaction(self) -> AsyncGenerator[AsyncSession, None]: + existing_session = self._session_context.get(None) + if existing_session is not None: + async with existing_session.begin_nested(): + yield existing_session + return + + session_parameters = { + "bind": self._engine, + "expire_on_commit": False, + } + async with AsyncSession(**session_parameters) as session: + token = self._session_context.set(session) + try: + async with session.begin_nested(): + yield session + finally: + self._session_context.reset(token) + def get_repository(self, repository_class: type[RepositoryType]) -> RepositoryType: return repository_class(container=self) diff --git a/metaorm/repositories.py b/metaorm/repositories.py index 1655fa6..e9d6dc0 100644 --- a/metaorm/repositories.py +++ b/metaorm/repositories.py @@ -1,26 +1,46 @@ from collections.abc import AsyncGenerator, Sequence -from contextlib import asynccontextmanager from typing import Any from pydantic import BaseModel from pydantic_filters import BaseFilter, BasePagination, BaseSort from pydantic_filters.drivers.sqlalchemy import append_to_statement -from pydantic_filters.filter._fields import FilterFieldInfo from sqlalchemy import func from sqlalchemy.exc import IntegrityError from sqlmodel import delete, insert, select, update from sqlmodel.ext.asyncio.session import AsyncSession from .container import RepositoriesContainer -from .exceptions import AlreadyExistsError, DatabaseException, HaveNoSessionError -from .settings import DatabaseSettings +from .exceptions import AlreadyExistsError, DatabaseException +from .settings import RepositorySettings from .tables import BaseTable class BaseRepository: + def __init_subclass__( + cls, + table: type[BaseTable] | None = None, + filter_: type[BaseFilter] | None = None, + dto: type[BaseModel] | None = None, + **kwargs, + ): + super().__init_subclass__(**kwargs) + + if (table := table or getattr(cls, "_table_type", None)) is None: + raise TypeError( + f"{cls.__name__} must specify 'table' keyword argument", + ) + if (filter_ := filter_ or getattr(cls, "_filter_type", None)) is None: + raise TypeError( + f"{cls.__name__} must specify 'filter_' keyword argument", + ) + + cls._table_type = table + cls._filter_type = filter_ + cls._dto_type = dto or getattr(cls, "_dto_type", None) + def __init__( self, - settings: DatabaseSettings | None = None, + settings: RepositorySettings | None = None, container: RepositoriesContainer | None = None, ): if container is not None: @@ -30,47 +50,11 @@ class BaseRepository: else: raise TypeError("Either 'container' or 'settings' must be provided") - def get_db_table(self) -> type[BaseTable]: - raise NotImplementedError - - def get_dto_type(self) -> type[BaseModel] | None: - return None - - def get_filter_type(self) -> type[BaseFilter] | None: - return None - - @property - def session(self) -> AsyncSession: - session = self._container.session - if session is None: - raise HaveNoSessionError() - return session - - @asynccontextmanager - async def transaction(self) -> AsyncGenerator[None, None]: - existing_session = self._container.session - if existing_session is not None: - yield - return - - async with self._container.transaction(): - yield - - async def create_tables(self) -> None: - table = self.get_db_table() - async with self._container.engine.begin() as connection: - await connection.run_sync( - table.metadata.create_all, - tables=[table.__table__], - ) - async def get_items_count( self, filter_: BaseFilter | None = None, ) -> int: - if filter_ is not None: - self._ensure_filter_fields(type(filter_)) - table = self.get_db_table() + table = self.get_table_type() statement = select(func.count()).select_from(table) statement = append_to_statement( statement=statement, @@ -90,10 +74,8 @@ class BaseRepository: pagination: BasePagination | None = None, sort: BaseSort | None = None, options: Sequence[Any] | None = None, - ) -> AsyncGenerator[BaseModel]: - if filter_ is not None: - self._ensure_filter_fields(type(filter_)) - table = self.get_db_table() + ) -> AsyncGenerator[Any]: + table = self.get_table_type() statement = select(table) statement = append_to_statement( statement=statement, @@ -106,13 +88,13 @@ class BaseRepository: statement = statement.options(*options) async with self.transaction(): - result = await self.session.execute(statement) + result = await self.session.exec(statement) result = result.yield_per(100) - for db_item in result.scalars(): + for db_item in result: yield self._convert_from_table(db_item) async def create_item(self, item: BaseModel) -> BaseModel: - table = self.get_db_table() + table = self.get_table_type() values = self._convert_to_table(item).to_values() statement = insert(table).values(values).returning(table) @@ -138,9 +120,7 @@ class BaseRepository: options: Sequence[Any] | None = None, **values, ) -> AsyncGenerator[BaseModel]: - if filter_ is not None: - self._ensure_filter_fields(type(filter_)) - table = self.get_db_table() + table = self.get_table_type() statement = update(table) statement = append_to_statement( statement=statement, @@ -161,9 +141,7 @@ class BaseRepository: self, filter_: BaseFilter | None = None, ) -> None: - if filter_ is not None: - self._ensure_filter_fields(type(filter_)) - table = self.get_db_table() + table = self.get_table_type() statement = delete(table) statement = append_to_statement( statement=statement, @@ -174,41 +152,45 @@ class BaseRepository: async with self.transaction(): await self.session.exec(statement) + @property + def session(self) -> AsyncSession | None: + return self._container.session + + @property + def transaction(self): + return self._container.transaction + + @property + def nested_transaction(self): + return self._container.nested_transaction + + async def create_tables(self) -> None: + table = self.get_table_type() + async with self._container.engine.begin() as connection: + await connection.run_sync( + table.metadata.create_all, + tables=[table.__table__], + ) + def _convert_to_table(self, item: BaseModel) -> BaseModel: - table = self.get_db_table() + table = self.get_table_type() if isinstance(item, table): return item return table.from_item(item=item) def _convert_from_table(self, table: BaseTable) -> BaseModel: - dto_type = self.get_dto_type() - if dto_type is None: + if self.get_dto_type() is None: return table return table.to_item() - def _ensure_filter_fields(self, filter_class: type[BaseFilter]) -> None: - """Workaround for pydantic-filters not registering filter_fields with pydantic v2.""" - if getattr(filter_class, "filter_fields", None): - return + @classmethod + def get_table_type(cls) -> type[BaseTable] | None: + return cls._table_type - filter_fields: dict[str, FilterFieldInfo] = {} - for field_name, field_info in filter_class.model_fields.items(): - if field_info.annotation is None: - continue - annotation = field_info.annotation - # unwrap Optional[X] -> X - origin = getattr(annotation, "__origin__", None) - if origin is type | None: - args = getattr(annotation, "__args__", ()) - if args and args[0] is not type(None): - annotation = args[0] + @classmethod + def get_filter_type(cls) -> type[BaseFilter] | None: + return cls._filter_type - is_sequence = hasattr( - annotation, "__origin__" - ) and annotation.__origin__ in (list, set) - filter_fields[field_name] = FilterFieldInfo( - target=field_name, - type_="eq", - is_sequence=is_sequence, - ) - filter_class.filter_fields = filter_fields + @classmethod + def get_dto_type(cls) -> type[BaseModel] | None: + return cls._dto_type diff --git a/metaorm/settings.py b/metaorm/settings.py index 9e3fb9d..5e90026 100644 --- a/metaorm/settings.py +++ b/metaorm/settings.py @@ -1,7 +1,7 @@ from pydantic import BaseModel, Field -class DatabaseSettings(BaseModel): +class RepositorySettings(BaseModel): dsn: str = Field(default="sqlite+aiosqlite:///db.sqlite3", pattern=r"^.+://") pool_size: int = Field(default=5, ge=1) pool_recycle: int = Field(default=60, ge=1) # in seconds: 1 minute diff --git a/pyproject.toml b/pyproject.toml index 6b5a079..a5aa847 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,6 @@ -[build-system] -requires = ["setuptools>=61.0", "wheel"] -build-backend = "setuptools.build_meta" - [project] name = "metaorm" -version = "0.2.0" +version = "0.3.0" description = "Async repository layer over SQLModel" readme = "README.md" license = {text = "MIT"} @@ -32,7 +28,7 @@ classifiers = [ ] dependencies = [ "pydantic>=2.0", - "pydantic-filters @ git+https://github.com/so-saf/pydantic-filters.git", + "pydantic-filters @ git+https://github.com/OlegYurchik/pydantic-filters.git@fix/compare-to-pydantic-2.12", "sqlalchemy>=2.0", "sqlmodel>=0.0.22", ] @@ -42,6 +38,10 @@ Homepage = "https://github.com/OlegYurchik/metaorm" Repository = "https://github.com/OlegYurchik/metaorm" Issues = "https://github.com/OlegYurchik/metaorm/issues" +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + [dependency-groups] dev = [ "aiosqlite>=0.22.0", @@ -67,4 +67,4 @@ fail_under = 90 show_missing = true [tool.uv.sources] -pydantic-filters = { git = "https://github.com/so-saf/pydantic-filters" } +pydantic-filters = { git = "https://github.com/OlegYurchik/pydantic-filters", branch = "fix/compare-to-pydantic-2.12" } diff --git a/tests/conftest.py b/tests/conftest.py index 2e6d191..f54e185 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from collections.abc import AsyncGenerator import pytest import pytest_asyncio -from metaorm import DatabaseSettings, RepositoriesContainer +from metaorm import RepositoriesContainer, RepositorySettings from .models import ( AuthorRepository, @@ -14,8 +14,8 @@ from .models import ( @pytest.fixture -def database_settings() -> DatabaseSettings: - return DatabaseSettings( +def database_settings() -> RepositorySettings: + return RepositorySettings( dsn="sqlite+aiosqlite:///:memory:", pool_size=1, pool_recycle=60, @@ -25,7 +25,7 @@ def database_settings() -> DatabaseSettings: @pytest_asyncio.fixture async def repositories_container( - database_settings: DatabaseSettings, + database_settings: RepositorySettings, ) -> AsyncGenerator[RepositoriesContainer, None]: container = RepositoriesContainer(settings=database_settings) yield container @@ -43,7 +43,7 @@ async def user_repository( @pytest_asyncio.fixture async def product_repository_settings( - database_settings: DatabaseSettings, + database_settings: RepositorySettings, ) -> AsyncGenerator[ProductRepository, None]: repository = ProductRepository(settings=database_settings) await repository.create_tables() diff --git a/tests/models.py b/tests/models.py index 2d5db7b..76fe1d5 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,8 +1,6 @@ from pydantic import BaseModel -from pydantic_filters import BaseFilter -from sqlmodel import Field, Relationship -from metaorm import BaseRepository, BaseTable +from metaorm import BaseFilter, BaseRepository, BaseTable, Field, Relationship class User(BaseModel): @@ -25,12 +23,13 @@ class UserTable(BaseTable[User], table=True): return User(id=self.id, name=self.name, email=self.email) -class UserRepository(BaseRepository): - def get_db_table(self) -> type[UserTable]: - return UserTable +class UserFilter(BaseFilter): + name: str | None = None + email: str | None = None - def get_dto_type(self) -> type[User]: - return User + +class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter, dto=User): + pass class ProductTable(BaseTable, table=True): @@ -45,12 +44,8 @@ class ProductFilter(BaseFilter): price: int | None = None -class ProductRepository(BaseRepository): - def get_db_table(self) -> type[ProductTable]: - return ProductTable - - def get_filter_type(self) -> type[ProductFilter]: - return ProductFilter +class ProductRepository(BaseRepository, table=ProductTable, filter_=ProductFilter): + pass class AuthorTable(BaseTable, table=True): @@ -68,11 +63,18 @@ class BookTable(BaseTable, table=True): author: AuthorTable = Relationship(back_populates="books") -class AuthorRepository(BaseRepository): - def get_db_table(self) -> type[AuthorTable]: - return AuthorTable +class AuthorFilter(BaseFilter): + name: str | None = None -class BookRepository(BaseRepository): - def get_db_table(self) -> type[BookTable]: - return BookTable +class BookFilter(BaseFilter): + title: str | None = None + author_id: int | None = None + + +class AuthorRepository(BaseRepository, table=AuthorTable, filter_=AuthorFilter): + pass + + +class BookRepository(BaseRepository, table=BookTable, filter_=BookFilter): + pass diff --git a/tests/test_container.py b/tests/test_container.py index 9b234eb..307b45a 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -1,7 +1,8 @@ +import pytest from sqlalchemy.ext.asyncio import AsyncEngine from metaorm import RepositoriesContainer -from tests.models import UserRepository +from tests.models import User, UserRepository class TestRepositoriesContainer: @@ -39,6 +40,69 @@ class TestRepositoriesContainer: ): assert inner_session is outer_session + async def test_nested_transaction_creates_session( + self, + repositories_container: RepositoriesContainer, + ) -> None: + assert repositories_container.session is None + + async with repositories_container.nested_transaction() as session: + assert session is not None + assert repositories_container.session is session + + assert repositories_container.session is None + + async def test_nested_transaction_reuses_outer_session( + self, + repositories_container: RepositoriesContainer, + ) -> None: + async with ( + repositories_container.transaction() as outer_session, + repositories_container.nested_transaction() as inner_session, + ): + assert inner_session is outer_session + + async def test_nested_transaction_rollbacks_on_exception( + self, + repositories_container: RepositoriesContainer, + ) -> None: + repository = repositories_container.get_repository(UserRepository) + await repository.create_tables() + + with pytest.raises(ValueError): + async with repositories_container.nested_transaction(): + await repository.create_item( + User(name="Alice", email="alice@example.com"), + ) + raise ValueError("boom") + + count = await repository.get_items_count() + assert count == 0 + + async def test_nested_transaction_in_outer_transaction_rollbacks_only_inner( + self, + repositories_container: RepositoriesContainer, + ) -> None: + repository = repositories_container.get_repository(UserRepository) + await repository.create_tables() + + async with repositories_container.transaction(): + await repository.create_item( + User(name="Bob", email="bob@example.com"), + ) + with pytest.raises(ValueError): + async with repositories_container.nested_transaction(): + await repository.create_item( + User(name="Alice", email="alice@example.com"), + ) + raise ValueError("boom") + + count = await repository.get_items_count() + assert count == 1 + + items = [item async for item in repository.get_items()] + assert items[0].name == "Bob" + async def test_get_repository_returns_repository_instance( self, repositories_container: RepositoriesContainer, diff --git a/tests/test_repositories.py b/tests/test_repositories.py index 0b12a80..a06c987 100644 --- a/tests/test_repositories.py +++ b/tests/test_repositories.py @@ -1,8 +1,13 @@ import pytest -from pydantic_filters import BaseSort, OffsetPagination from sqlalchemy.orm import joinedload -from metaorm import AlreadyExistsError, DatabaseSettings, HaveNoSessionError +from metaorm import ( + AlreadyExistsError, + BaseRepository, + BaseSort, + OffsetPagination, + RepositorySettings, +) from tests.models import ( AuthorRepository, AuthorTable, @@ -12,18 +17,13 @@ from tests.models import ( ProductRepository, ProductTable, User, + UserFilter, UserRepository, + UserTable, ) class TestBaseRepository: - async def test_session_raises_error_without_transaction( - self, - user_repository: UserRepository, - ) -> None: - with pytest.raises(HaveNoSessionError): - _ = user_repository.session - async def test_create_item(self, user_repository: UserRepository) -> None: user = User(name="Alice", email="alice@example.com") @@ -93,12 +93,29 @@ class TestBaseRepository: with pytest.raises(AlreadyExistsError): await user_repository.create_item(user) - async def test_transaction_reuses_existing_session( + async def test_transaction_scope_allows_crud( self, user_repository: UserRepository, ) -> None: async with user_repository.transaction(): - _ = user_repository.session + count = await user_repository.get_items_count() + assert count == 0 + + async def test_session_is_none_without_transaction( + self, + user_repository: UserRepository, + ) -> None: + assert user_repository.session is None + + async def test_session_returns_session_inside_transaction( + self, + user_repository: UserRepository, + ) -> None: + from sqlmodel.ext.asyncio.session import AsyncSession + + async with user_repository.transaction() as session: + assert isinstance(user_repository.session, AsyncSession) + assert user_repository.session is session async def test_get_items_with_pagination( self, @@ -147,11 +164,83 @@ class TestBaseRepository: assert created.name == "Widget" async def test_get_filter_type(self) -> None: - repository = ProductRepository(settings=DatabaseSettings()) + product_repository = ProductRepository(settings=RepositorySettings()) + user_repository = UserRepository(settings=RepositorySettings()) - filter_type = repository.get_filter_type() + assert product_repository.get_filter_type() is ProductFilter + assert user_repository.get_filter_type() is UserFilter - assert filter_type is ProductFilter + async def test_get_dto_type(self) -> None: + product_repository = ProductRepository(settings=RepositorySettings()) + user_repository = UserRepository(settings=RepositorySettings()) + + assert product_repository.get_dto_type() is None + assert user_repository.get_dto_type() is User + + async def test_get_items_count_with_filter( + self, + product_repository_settings: ProductRepository, + ) -> None: + await product_repository_settings.create_item( + ProductTable(name="Alpha", price=10.0), + ) + await product_repository_settings.create_item( + ProductTable(name="Beta", price=20.0), + ) + + count = await product_repository_settings.get_items_count( + filter_=ProductFilter(name="Alpha"), + ) + + assert count == 1 + + async def test_delete_items_with_filter( + self, + product_repository_settings: ProductRepository, + ) -> None: + await product_repository_settings.create_item( + ProductTable(name="Alpha", price=10.0), + ) + await product_repository_settings.create_item( + ProductTable(name="Beta", price=20.0), + ) + + await product_repository_settings.delete_items( + filter_=ProductFilter(name="Alpha"), + ) + + count = await product_repository_settings.get_items_count() + assert count == 1 + + remaining = [item async for item in product_repository_settings.get_items()] + assert remaining[0].name == "Beta" + + async def test_update_items_with_filter( + self, + product_repository_settings: ProductRepository, + ) -> None: + await product_repository_settings.create_item( + ProductTable(name="Alpha", price=10.0), + ) + await product_repository_settings.create_item( + ProductTable(name="Beta", price=20.0), + ) + + updated = [ + item + async for item in product_repository_settings.update_items( + filter_=ProductFilter(name="Alpha"), + name="Gamma", + ) + ] + + assert len(updated) == 1 + assert updated[0].name == "Gamma" + + all_items = [item async for item in product_repository_settings.get_items()] + assert len(all_items) == 2 + names = {item.name for item in all_items} + assert names == {"Gamma", "Beta"} async def test_get_items_with_filter( self, @@ -217,3 +306,72 @@ class TestBaseRepository: assert len(updated) == 1 assert updated[0].title == "Updated" + + async def test_init_raises_without_container_or_settings(self) -> None: + with pytest.raises(TypeError): + BaseRepository() + + async def test_repository_without_table_raises(self) -> None: + with pytest.raises(TypeError): + class BadRepository(BaseRepository, filter_=UserFilter): + pass + + async def test_nested_transaction_property_allows_crud( + self, + user_repository: UserRepository, + ) -> None: + async with user_repository.nested_transaction(): + count = await user_repository.get_items_count() + assert count == 0 + + async def test_nested_transaction_rollbacks_inner_scope( + self, + user_repository: UserRepository, + ) -> None: + with pytest.raises(ValueError): + async with user_repository.nested_transaction(): + await user_repository.create_item( + User(name="Alice", email="alice@example.com"), + ) + raise ValueError("boom") + + count = await user_repository.get_items_count() + assert count == 0 + + async def test_nested_transaction_in_outer_transaction_rollbacks_only_inner( + self, + user_repository: UserRepository, + ) -> None: + async with user_repository.transaction(): + await user_repository.create_item( + User(name="Bob", email="bob@example.com"), + ) + with pytest.raises(ValueError): + async with user_repository.nested_transaction(): + await user_repository.create_item( + User(name="Alice", email="alice@example.com"), + ) + raise ValueError("boom") + + count = await user_repository.get_items_count() + assert count == 1 + + items = [item async for item in user_repository.get_items()] + assert items[0].name == "Bob" + + async def test_params_via_intermediate_base_class(self) -> None: + class IntermediateRepository( + BaseRepository, + table=UserTable, + filter_=UserFilter, + dto=User, + ): + pass + + class ConcreteRepository(IntermediateRepository): + pass + + repository = ConcreteRepository(settings=RepositorySettings()) + + assert repository.get_filter_type() is UserFilter + assert repository.get_dto_type() is User diff --git a/tests/test_settings.py b/tests/test_settings.py index 041a040..549566e 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,12 +1,12 @@ import pytest from pydantic import ValidationError -from metaorm import DatabaseSettings +from metaorm import RepositorySettings -class TestDatabaseSettings: +class TestRepositorySettings: def test_default_values(self) -> None: - settings = DatabaseSettings() + settings = RepositorySettings() assert settings.dsn == "sqlite+aiosqlite:///db.sqlite3" assert settings.pool_size == 5 @@ -14,7 +14,7 @@ class TestDatabaseSettings: assert settings.pool_timeout == 60 def test_custom_values(self) -> None: - settings = DatabaseSettings( + settings = RepositorySettings( dsn="postgresql+asyncpg://user:pass@localhost/db", pool_size=10, pool_recycle=120, @@ -28,7 +28,7 @@ class TestDatabaseSettings: def test_dsn_must_match_pattern(self) -> None: with pytest.raises(ValidationError): - DatabaseSettings(dsn="invalid_dsn") + RepositorySettings(dsn="invalid_dsn") @pytest.mark.parametrize( "field_name,invalid_value", @@ -44,4 +44,4 @@ class TestDatabaseSettings: invalid_value: int, ) -> None: with pytest.raises(ValidationError): - DatabaseSettings(**{field_name: invalid_value}) + RepositorySettings(**{field_name: invalid_value}) diff --git a/tests/test_tables.py b/tests/test_tables.py index 534fb06..9e1f94a 100644 --- a/tests/test_tables.py +++ b/tests/test_tables.py @@ -1,7 +1,6 @@ import pytest -from sqlmodel import Field -from metaorm import BaseTable +from metaorm import BaseTable, Field from tests.models import User diff --git a/uv.lock b/uv.lock index 71b3a14..1747e7a 100644 --- a/uv.lock +++ b/uv.lock @@ -137,7 +137,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" }, { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" }, { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, @@ -145,7 +147,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" }, { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" }, { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, @@ -153,7 +157,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, @@ -161,14 +167,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" }, { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" }, { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, @@ -176,7 +186,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" }, { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" }, { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, @@ -215,7 +227,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "pydantic", specifier = ">=2.0" }, - { name = "pydantic-filters", git = "https://github.com/so-saf/pydantic-filters" }, + { name = "pydantic-filters", git = "https://github.com/OlegYurchik/pydantic-filters?branch=fix%2Fcompare-to-pydantic-2.12" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "sqlmodel", specifier = ">=0.0.22" }, ] @@ -340,7 +352,7 @@ wheels = [ [[package]] name = "pydantic-filters" version = "0.0.0" -source = { git = "https://github.com/so-saf/pydantic-filters#d385c382c1ab3adc264855bf99e4e378f297eb66" } +source = { git = "https://github.com/OlegYurchik/pydantic-filters?branch=fix%2Fcompare-to-pydantic-2.12#2ca8b822d59feaf5f19f36b570974d314ba5e330" } dependencies = [ { name = "pydantic" }, ]