refactor: migrate to keyword-based repository config, add nested transactions and tests
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
|
|||||||
41
AGENTS.md
41
AGENTS.md
@@ -10,12 +10,13 @@ metaorm/
|
|||||||
repositories.py # BaseRepository
|
repositories.py # BaseRepository
|
||||||
tables.py # BaseTable
|
tables.py # BaseTable
|
||||||
container.py # RepositoriesContainer (session/transaction manager)
|
container.py # RepositoriesContainer (session/transaction manager)
|
||||||
settings.py # DatabaseSettings (Pydantic model)
|
settings.py # RepositorySettings (Pydantic model)
|
||||||
exceptions.py # Domain exceptions
|
exceptions.py # Domain exceptions
|
||||||
examples/ # Usage examples
|
examples/ # Usage examples
|
||||||
basic_usage.py # Simple CRUD with tables directly
|
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
|
transactions.py # Explicit transaction management
|
||||||
|
nested_transactions.py # Savepoints and partial rollback
|
||||||
filter_usage.py # Query filters, pagination and sorting
|
filter_usage.py # Query filters, pagination and sorting
|
||||||
relationships.py # Eager loading with joinedload/selectinload
|
relationships.py # Eager loading with joinedload/selectinload
|
||||||
container_usage.py # Multi-repository atomic transactions
|
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
|
||||||
|
|
||||||
`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
|
```python
|
||||||
class UserRepository(BaseRepository):
|
class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter, dto=User):
|
||||||
def get_db_table(self) -> type[UserTable]:
|
pass # Methods return User instances
|
||||||
return UserTable
|
|
||||||
|
|
||||||
def get_dto_type(self) -> type[User] | None:
|
|
||||||
return User # 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)
|
#### Eager loading (options)
|
||||||
|
|
||||||
@@ -83,7 +97,7 @@ books = [
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# Simple: create container internally
|
# 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
|
# Advanced: reuse container for shared transactions
|
||||||
container = RepositoriesContainer(settings=settings)
|
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()`.
|
- 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.
|
- `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
|
## Development
|
||||||
|
|
||||||
|
|||||||
167
README.md
167
README.md
@@ -1,6 +1,14 @@
|
|||||||
# MetaORM
|
# 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
|
## Install
|
||||||
|
|
||||||
@@ -14,11 +22,8 @@ Requires Python `>=3.12`.
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
The simplest mode works with SQLModel tables directly — no DTOs, no generics, no magic:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from sqlmodel import Field
|
from metaorm import BaseFilter, BaseRepository, BaseTable, RepositorySettings, Field
|
||||||
from metaorm import BaseRepository, BaseTable, DatabaseSettings
|
|
||||||
|
|
||||||
|
|
||||||
class UserTable(BaseTable, table=True):
|
class UserTable(BaseTable, table=True):
|
||||||
@@ -28,129 +33,68 @@ class UserTable(BaseTable, table=True):
|
|||||||
email: str = Field(unique=True)
|
email: str = Field(unique=True)
|
||||||
|
|
||||||
|
|
||||||
class UserRepository(BaseRepository):
|
class UserFilter(BaseFilter):
|
||||||
def get_db_table(self) -> type[UserTable]:
|
name: str | None = None
|
||||||
return UserTable
|
email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
repo = UserRepository(
|
repo = UserRepository(
|
||||||
settings=DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:"),
|
settings=RepositorySettings(dsn="sqlite+aiosqlite:///:memory:"),
|
||||||
)
|
)
|
||||||
await repo.create_tables()
|
await repo.create_tables()
|
||||||
|
|
||||||
user = await repo.create_item(
|
user = await repo.create_item(UserTable(name="Alice", email="alice@example.com"))
|
||||||
UserTable(name="Alice", email="alice@example.com"),
|
|
||||||
)
|
|
||||||
print(user.id, user.name)
|
print(user.id, user.name)
|
||||||
|
|
||||||
all_users = [u async for u in repo.get_items()]
|
all_users = [u async for u in repo.get_items()]
|
||||||
print(len(all_users))
|
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
|
```python
|
||||||
from pydantic import BaseModel
|
class MyRepository(BaseRepository, table=MyTable, filter_=MyFilter):
|
||||||
from sqlmodel import Field
|
pass # returns table instances directly
|
||||||
from metaorm import BaseRepository, BaseTable, DatabaseSettings
|
|
||||||
|
|
||||||
|
|
||||||
class User(BaseModel):
|
class MyRepositoryWithDto(BaseRepository, table=MyTable, filter_=MyFilter, dto=MyDto):
|
||||||
id: int | None = None
|
pass # maps rows to MyDto
|
||||||
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())
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
```python
|
||||||
from pydantic_filters import BaseFilter, BaseSort, OffsetPagination
|
# Simple — container is created internally
|
||||||
|
repo = MyRepository(settings=RepositorySettings(dsn="..."))
|
||||||
|
|
||||||
class BookFilter(BaseFilter):
|
# Advanced — share a container for atomic multi-repo transactions
|
||||||
title: str | None = None
|
container = RepositoriesContainer(settings=settings)
|
||||||
year: int | None = None
|
repo = MyRepository(container=container)
|
||||||
|
|
||||||
|
|
||||||
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"),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
### Multi-repository transactions
|
||||||
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
|
|
||||||
|
|
||||||
Use `RepositoriesContainer` when you need a single atomic transaction spanning multiple repositories:
|
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))
|
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
|
See [`examples/`](examples/) for detailed usage patterns:
|
||||||
from sqlalchemy.orm import joinedload
|
|
||||||
|
|
||||||
books = [
|
- [`basic_usage.py`](examples/basic_usage.py) — CRUD with tables directly
|
||||||
item
|
- [`dto_usage.py`](examples/dto_usage.py) — DTO mapping via `dto=` keyword
|
||||||
async for item in book_repo.get_items(
|
- [`transactions.py`](examples/transactions.py) — Explicit transaction management
|
||||||
options=[joinedload(BookTable.author)],
|
- [`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
|
## Exceptions
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from sqlmodel import Field
|
from metaorm import BaseFilter, BaseRepository, BaseTable, Field, RepositorySettings
|
||||||
|
|
||||||
from metaorm import BaseRepository, BaseTable, DatabaseSettings
|
|
||||||
|
|
||||||
|
|
||||||
class UserTable(BaseTable, table=True):
|
class UserTable(BaseTable, table=True):
|
||||||
@@ -13,13 +11,17 @@ class UserTable(BaseTable, table=True):
|
|||||||
email: str = Field(unique=True)
|
email: str = Field(unique=True)
|
||||||
|
|
||||||
|
|
||||||
class UserRepository(BaseRepository):
|
class UserFilter(BaseFilter):
|
||||||
def get_db_table(self) -> type[UserTable]:
|
name: str | None = None
|
||||||
return UserTable
|
email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:")
|
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
|
||||||
repository = UserRepository(settings=settings)
|
repository = UserRepository(settings=settings)
|
||||||
|
|
||||||
await repository.create_tables()
|
await repository.create_tables()
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from sqlmodel import Field
|
from metaorm import (
|
||||||
|
BaseFilter,
|
||||||
from metaorm import BaseRepository, BaseTable, DatabaseSettings, RepositoriesContainer
|
BaseRepository,
|
||||||
|
BaseTable,
|
||||||
|
Field,
|
||||||
|
RepositoriesContainer,
|
||||||
|
RepositorySettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserTable(BaseTable, table=True):
|
class UserTable(BaseTable, table=True):
|
||||||
@@ -20,18 +25,24 @@ class OrderTable(BaseTable, table=True):
|
|||||||
total: float
|
total: float
|
||||||
|
|
||||||
|
|
||||||
class UserRepository(BaseRepository):
|
class UserFilter(BaseFilter):
|
||||||
def get_db_table(self) -> type[UserTable]:
|
name: str | None = None
|
||||||
return UserTable
|
|
||||||
|
|
||||||
|
|
||||||
class OrderRepository(BaseRepository):
|
class OrderFilter(BaseFilter):
|
||||||
def get_db_table(self) -> type[OrderTable]:
|
user_id: int | None = None
|
||||||
return OrderTable
|
|
||||||
|
|
||||||
|
class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRepository(BaseRepository, table=OrderTable, filter_=OrderFilter):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:")
|
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
|
||||||
container = RepositoriesContainer(settings=settings)
|
container = RepositoriesContainer(settings=settings)
|
||||||
|
|
||||||
user_repo = container.get_repository(UserRepository)
|
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=100.00))
|
||||||
await order_repo.create_item(OrderTable(user_id=user.id, total=250.50))
|
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
|
# Verify results
|
||||||
users = [item async for item in user_repo.get_items()]
|
users = [item async for item in user_repo.get_items()]
|
||||||
orders = [item async for item in order_repo.get_items()]
|
orders = [item async for item in order_repo.get_items()]
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from pydantic import BaseModel
|
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):
|
class User(BaseModel):
|
||||||
@@ -27,16 +26,17 @@ class UserTable(BaseTable[User], table=True):
|
|||||||
return User(id=self.id, name=self.name, email=self.email)
|
return User(id=self.id, name=self.name, email=self.email)
|
||||||
|
|
||||||
|
|
||||||
class UserRepository(BaseRepository):
|
class UserFilter(BaseFilter):
|
||||||
def get_db_table(self) -> type[UserTable]:
|
name: str | None = None
|
||||||
return UserTable
|
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:
|
async def main() -> None:
|
||||||
settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:")
|
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
|
||||||
repository = UserRepository(settings=settings)
|
repository = UserRepository(settings=settings)
|
||||||
|
|
||||||
await repository.create_tables()
|
await repository.create_tables()
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from pydantic_filters import BaseFilter, BaseSort, OffsetPagination
|
from metaorm import (
|
||||||
from sqlmodel import Field
|
BaseFilter,
|
||||||
|
BaseRepository,
|
||||||
from metaorm import BaseRepository, BaseTable, DatabaseSettings
|
BaseSort,
|
||||||
|
BaseTable,
|
||||||
|
Field,
|
||||||
|
OffsetPagination,
|
||||||
|
RepositorySettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookTable(BaseTable, table=True):
|
class BookTable(BaseTable, table=True):
|
||||||
@@ -19,16 +24,12 @@ class BookFilter(BaseFilter):
|
|||||||
year: int | None = None
|
year: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class BookRepository(BaseRepository):
|
class BookRepository(BaseRepository, table=BookTable, filter_=BookFilter):
|
||||||
def get_db_table(self) -> type[BookTable]:
|
pass
|
||||||
return BookTable
|
|
||||||
|
|
||||||
def get_filter_type(self) -> type[BookFilter]:
|
|
||||||
return BookFilter
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:")
|
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
|
||||||
repository = BookRepository(settings=settings)
|
repository = BookRepository(settings=settings)
|
||||||
|
|
||||||
await repository.create_tables()
|
await repository.create_tables()
|
||||||
|
|||||||
64
examples/nested_transactions.py
Normal file
64
examples/nested_transactions.py
Normal file
@@ -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())
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from sqlalchemy.orm import joinedload
|
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):
|
class AuthorTable(BaseTable, table=True):
|
||||||
@@ -23,18 +30,25 @@ class BookTable(BaseTable, table=True):
|
|||||||
author: AuthorTable = Relationship(back_populates="books")
|
author: AuthorTable = Relationship(back_populates="books")
|
||||||
|
|
||||||
|
|
||||||
class BookRepository(BaseRepository):
|
class BookFilter(BaseFilter):
|
||||||
def get_db_table(self) -> type[BookTable]:
|
title: str | None = None
|
||||||
return BookTable
|
author_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class AuthorRepository(BaseRepository):
|
class AuthorFilter(BaseFilter):
|
||||||
def get_db_table(self) -> type[AuthorTable]:
|
name: str | None = None
|
||||||
return AuthorTable
|
|
||||||
|
|
||||||
|
class BookRepository(BaseRepository, table=BookTable, filter_=BookFilter):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorRepository(BaseRepository, table=AuthorTable, filter_=AuthorFilter):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:")
|
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
|
||||||
container = RepositoriesContainer(settings=settings)
|
container = RepositoriesContainer(settings=settings)
|
||||||
|
|
||||||
author_repo = AuthorRepository(container=container)
|
author_repo = AuthorRepository(container=container)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from sqlmodel import Field
|
from metaorm import BaseFilter, BaseRepository, BaseTable, Field, RepositorySettings
|
||||||
|
|
||||||
from metaorm import BaseRepository, BaseTable, DatabaseSettings
|
|
||||||
|
|
||||||
|
|
||||||
class ProductTable(BaseTable, table=True):
|
class ProductTable(BaseTable, table=True):
|
||||||
@@ -13,13 +11,17 @@ class ProductTable(BaseTable, table=True):
|
|||||||
price: float
|
price: float
|
||||||
|
|
||||||
|
|
||||||
class ProductRepository(BaseRepository):
|
class ProductFilter(BaseFilter):
|
||||||
def get_db_table(self) -> type[ProductTable]:
|
name: str | None = None
|
||||||
return ProductTable
|
price: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductRepository(BaseRepository, table=ProductTable, filter_=ProductFilter):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:")
|
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
|
||||||
repository = ProductRepository(settings=settings)
|
repository = ProductRepository(settings=settings)
|
||||||
|
|
||||||
await repository.create_tables()
|
await repository.create_tables()
|
||||||
@@ -34,10 +36,23 @@ async def main() -> None:
|
|||||||
)
|
)
|
||||||
print(f"Created in transaction: {product1.name}, {product2.name}")
|
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():
|
async with repository.transaction(), repository.transaction():
|
||||||
items = [item async for item in repository.get_items()]
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
from pydantic_filters import (
|
||||||
|
BaseFilter,
|
||||||
|
BasePagination,
|
||||||
|
BaseSort,
|
||||||
|
OffsetPagination,
|
||||||
|
PagePagination,
|
||||||
|
)
|
||||||
|
from sqlmodel import Field, Relationship
|
||||||
|
|
||||||
from .container import RepositoriesContainer
|
from .container import RepositoriesContainer
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
AlreadyExistsError,
|
AlreadyExistsError,
|
||||||
@@ -6,10 +15,19 @@ from .exceptions import (
|
|||||||
NotFoundError,
|
NotFoundError,
|
||||||
)
|
)
|
||||||
from .repositories import BaseRepository
|
from .repositories import BaseRepository
|
||||||
from .settings import DatabaseSettings
|
from .settings import RepositorySettings
|
||||||
from .tables import BaseTable
|
from .tables import BaseTable
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
# pydantic-filters
|
||||||
|
"BaseFilter",
|
||||||
|
"BasePagination",
|
||||||
|
"BaseSort",
|
||||||
|
"OffsetPagination",
|
||||||
|
"PagePagination",
|
||||||
|
# sqlmodel
|
||||||
|
"Field",
|
||||||
|
"Relationship",
|
||||||
# container
|
# container
|
||||||
"RepositoriesContainer",
|
"RepositoriesContainer",
|
||||||
# exceptions
|
# exceptions
|
||||||
@@ -20,7 +38,7 @@ __all__ = (
|
|||||||
# repositories
|
# repositories
|
||||||
"BaseRepository",
|
"BaseRepository",
|
||||||
# settings
|
# settings
|
||||||
"DatabaseSettings",
|
"RepositorySettings",
|
||||||
# tables
|
# tables
|
||||||
"BaseTable",
|
"BaseTable",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ from typing import TypeVar
|
|||||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from .settings import DatabaseSettings
|
from .settings import RepositorySettings
|
||||||
|
|
||||||
RepositoryType = TypeVar("RepositoryType", bound="BaseRepository") # noqa: F821
|
RepositoryType = TypeVar("RepositoryType", bound="BaseRepository") # noqa: F821
|
||||||
|
|
||||||
|
|
||||||
class RepositoriesContainer:
|
class RepositoriesContainer:
|
||||||
def __init__(self, settings: DatabaseSettings):
|
def __init__(self, settings: RepositorySettings):
|
||||||
engine_parameters = {
|
engine_parameters = {
|
||||||
"url": settings.dsn,
|
"url": settings.dsn,
|
||||||
"pool_recycle": settings.pool_recycle,
|
"pool_recycle": settings.pool_recycle,
|
||||||
@@ -37,7 +37,7 @@ class RepositoriesContainer:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def transaction(self) -> AsyncGenerator[AsyncSession, None]:
|
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:
|
if existing_session is not None:
|
||||||
yield existing_session
|
yield existing_session
|
||||||
return
|
return
|
||||||
@@ -54,5 +54,25 @@ class RepositoriesContainer:
|
|||||||
finally:
|
finally:
|
||||||
self._session_context.reset(token)
|
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:
|
def get_repository(self, repository_class: type[RepositoryType]) -> RepositoryType:
|
||||||
return repository_class(container=self)
|
return repository_class(container=self)
|
||||||
|
|||||||
@@ -1,26 +1,46 @@
|
|||||||
from collections.abc import AsyncGenerator, Sequence
|
from collections.abc import AsyncGenerator, Sequence
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic_filters import BaseFilter, BasePagination, BaseSort
|
from pydantic_filters import BaseFilter, BasePagination, BaseSort
|
||||||
from pydantic_filters.drivers.sqlalchemy import append_to_statement
|
from pydantic_filters.drivers.sqlalchemy import append_to_statement
|
||||||
from pydantic_filters.filter._fields import FilterFieldInfo
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlmodel import delete, insert, select, update
|
from sqlmodel import delete, insert, select, update
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from .container import RepositoriesContainer
|
from .container import RepositoriesContainer
|
||||||
from .exceptions import AlreadyExistsError, DatabaseException, HaveNoSessionError
|
from .exceptions import AlreadyExistsError, DatabaseException
|
||||||
from .settings import DatabaseSettings
|
from .settings import RepositorySettings
|
||||||
from .tables import BaseTable
|
from .tables import BaseTable
|
||||||
|
|
||||||
|
|
||||||
class BaseRepository:
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
settings: DatabaseSettings | None = None,
|
settings: RepositorySettings | None = None,
|
||||||
container: RepositoriesContainer | None = None,
|
container: RepositoriesContainer | None = None,
|
||||||
):
|
):
|
||||||
if container is not None:
|
if container is not None:
|
||||||
@@ -30,47 +50,11 @@ class BaseRepository:
|
|||||||
else:
|
else:
|
||||||
raise TypeError("Either 'container' or 'settings' must be provided")
|
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(
|
async def get_items_count(
|
||||||
self,
|
self,
|
||||||
filter_: BaseFilter | None = None,
|
filter_: BaseFilter | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
if filter_ is not None:
|
table = self.get_table_type()
|
||||||
self._ensure_filter_fields(type(filter_))
|
|
||||||
table = self.get_db_table()
|
|
||||||
statement = select(func.count()).select_from(table)
|
statement = select(func.count()).select_from(table)
|
||||||
statement = append_to_statement(
|
statement = append_to_statement(
|
||||||
statement=statement,
|
statement=statement,
|
||||||
@@ -90,10 +74,8 @@ class BaseRepository:
|
|||||||
pagination: BasePagination | None = None,
|
pagination: BasePagination | None = None,
|
||||||
sort: BaseSort | None = None,
|
sort: BaseSort | None = None,
|
||||||
options: Sequence[Any] | None = None,
|
options: Sequence[Any] | None = None,
|
||||||
) -> AsyncGenerator[BaseModel]:
|
) -> AsyncGenerator[Any]:
|
||||||
if filter_ is not None:
|
table = self.get_table_type()
|
||||||
self._ensure_filter_fields(type(filter_))
|
|
||||||
table = self.get_db_table()
|
|
||||||
statement = select(table)
|
statement = select(table)
|
||||||
statement = append_to_statement(
|
statement = append_to_statement(
|
||||||
statement=statement,
|
statement=statement,
|
||||||
@@ -106,13 +88,13 @@ class BaseRepository:
|
|||||||
statement = statement.options(*options)
|
statement = statement.options(*options)
|
||||||
|
|
||||||
async with self.transaction():
|
async with self.transaction():
|
||||||
result = await self.session.execute(statement)
|
result = await self.session.exec(statement)
|
||||||
result = result.yield_per(100)
|
result = result.yield_per(100)
|
||||||
for db_item in result.scalars():
|
for db_item in result:
|
||||||
yield self._convert_from_table(db_item)
|
yield self._convert_from_table(db_item)
|
||||||
|
|
||||||
async def create_item(self, item: BaseModel) -> BaseModel:
|
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()
|
values = self._convert_to_table(item).to_values()
|
||||||
statement = insert(table).values(values).returning(table)
|
statement = insert(table).values(values).returning(table)
|
||||||
|
|
||||||
@@ -138,9 +120,7 @@ class BaseRepository:
|
|||||||
options: Sequence[Any] | None = None,
|
options: Sequence[Any] | None = None,
|
||||||
**values,
|
**values,
|
||||||
) -> AsyncGenerator[BaseModel]:
|
) -> AsyncGenerator[BaseModel]:
|
||||||
if filter_ is not None:
|
table = self.get_table_type()
|
||||||
self._ensure_filter_fields(type(filter_))
|
|
||||||
table = self.get_db_table()
|
|
||||||
statement = update(table)
|
statement = update(table)
|
||||||
statement = append_to_statement(
|
statement = append_to_statement(
|
||||||
statement=statement,
|
statement=statement,
|
||||||
@@ -161,9 +141,7 @@ class BaseRepository:
|
|||||||
self,
|
self,
|
||||||
filter_: BaseFilter | None = None,
|
filter_: BaseFilter | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if filter_ is not None:
|
table = self.get_table_type()
|
||||||
self._ensure_filter_fields(type(filter_))
|
|
||||||
table = self.get_db_table()
|
|
||||||
statement = delete(table)
|
statement = delete(table)
|
||||||
statement = append_to_statement(
|
statement = append_to_statement(
|
||||||
statement=statement,
|
statement=statement,
|
||||||
@@ -174,41 +152,45 @@ class BaseRepository:
|
|||||||
async with self.transaction():
|
async with self.transaction():
|
||||||
await self.session.exec(statement)
|
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:
|
def _convert_to_table(self, item: BaseModel) -> BaseModel:
|
||||||
table = self.get_db_table()
|
table = self.get_table_type()
|
||||||
if isinstance(item, table):
|
if isinstance(item, table):
|
||||||
return item
|
return item
|
||||||
return table.from_item(item=item)
|
return table.from_item(item=item)
|
||||||
|
|
||||||
def _convert_from_table(self, table: BaseTable) -> BaseModel:
|
def _convert_from_table(self, table: BaseTable) -> BaseModel:
|
||||||
dto_type = self.get_dto_type()
|
if self.get_dto_type() is None:
|
||||||
if dto_type is None:
|
|
||||||
return table
|
return table
|
||||||
return table.to_item()
|
return table.to_item()
|
||||||
|
|
||||||
def _ensure_filter_fields(self, filter_class: type[BaseFilter]) -> None:
|
@classmethod
|
||||||
"""Workaround for pydantic-filters not registering filter_fields with pydantic v2."""
|
def get_table_type(cls) -> type[BaseTable] | None:
|
||||||
if getattr(filter_class, "filter_fields", None):
|
return cls._table_type
|
||||||
return
|
|
||||||
|
|
||||||
filter_fields: dict[str, FilterFieldInfo] = {}
|
@classmethod
|
||||||
for field_name, field_info in filter_class.model_fields.items():
|
def get_filter_type(cls) -> type[BaseFilter] | None:
|
||||||
if field_info.annotation is None:
|
return cls._filter_type
|
||||||
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]
|
|
||||||
|
|
||||||
is_sequence = hasattr(
|
@classmethod
|
||||||
annotation, "__origin__"
|
def get_dto_type(cls) -> type[BaseModel] | None:
|
||||||
) and annotation.__origin__ in (list, set)
|
return cls._dto_type
|
||||||
filter_fields[field_name] = FilterFieldInfo(
|
|
||||||
target=field_name,
|
|
||||||
type_="eq",
|
|
||||||
is_sequence=is_sequence,
|
|
||||||
)
|
|
||||||
filter_class.filter_fields = filter_fields
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class DatabaseSettings(BaseModel):
|
class RepositorySettings(BaseModel):
|
||||||
dsn: str = Field(default="sqlite+aiosqlite:///db.sqlite3", pattern=r"^.+://")
|
dsn: str = Field(default="sqlite+aiosqlite:///db.sqlite3", pattern=r"^.+://")
|
||||||
pool_size: int = Field(default=5, ge=1)
|
pool_size: int = Field(default=5, ge=1)
|
||||||
pool_recycle: int = Field(default=60, ge=1) # in seconds: 1 minute
|
pool_recycle: int = Field(default=60, ge=1) # in seconds: 1 minute
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools>=61.0", "wheel"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "metaorm"
|
name = "metaorm"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
description = "Async repository layer over SQLModel"
|
description = "Async repository layer over SQLModel"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
@@ -32,7 +28,7 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pydantic>=2.0",
|
"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",
|
"sqlalchemy>=2.0",
|
||||||
"sqlmodel>=0.0.22",
|
"sqlmodel>=0.0.22",
|
||||||
]
|
]
|
||||||
@@ -42,6 +38,10 @@ Homepage = "https://github.com/OlegYurchik/metaorm"
|
|||||||
Repository = "https://github.com/OlegYurchik/metaorm"
|
Repository = "https://github.com/OlegYurchik/metaorm"
|
||||||
Issues = "https://github.com/OlegYurchik/metaorm/issues"
|
Issues = "https://github.com/OlegYurchik/metaorm/issues"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"aiosqlite>=0.22.0",
|
"aiosqlite>=0.22.0",
|
||||||
@@ -67,4 +67,4 @@ fail_under = 90
|
|||||||
show_missing = true
|
show_missing = true
|
||||||
|
|
||||||
[tool.uv.sources]
|
[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" }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from collections.abc import AsyncGenerator
|
|||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
from metaorm import DatabaseSettings, RepositoriesContainer
|
from metaorm import RepositoriesContainer, RepositorySettings
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
AuthorRepository,
|
AuthorRepository,
|
||||||
@@ -14,8 +14,8 @@ from .models import (
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def database_settings() -> DatabaseSettings:
|
def database_settings() -> RepositorySettings:
|
||||||
return DatabaseSettings(
|
return RepositorySettings(
|
||||||
dsn="sqlite+aiosqlite:///:memory:",
|
dsn="sqlite+aiosqlite:///:memory:",
|
||||||
pool_size=1,
|
pool_size=1,
|
||||||
pool_recycle=60,
|
pool_recycle=60,
|
||||||
@@ -25,7 +25,7 @@ def database_settings() -> DatabaseSettings:
|
|||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def repositories_container(
|
async def repositories_container(
|
||||||
database_settings: DatabaseSettings,
|
database_settings: RepositorySettings,
|
||||||
) -> AsyncGenerator[RepositoriesContainer, None]:
|
) -> AsyncGenerator[RepositoriesContainer, None]:
|
||||||
container = RepositoriesContainer(settings=database_settings)
|
container = RepositoriesContainer(settings=database_settings)
|
||||||
yield container
|
yield container
|
||||||
@@ -43,7 +43,7 @@ async def user_repository(
|
|||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def product_repository_settings(
|
async def product_repository_settings(
|
||||||
database_settings: DatabaseSettings,
|
database_settings: RepositorySettings,
|
||||||
) -> AsyncGenerator[ProductRepository, None]:
|
) -> AsyncGenerator[ProductRepository, None]:
|
||||||
repository = ProductRepository(settings=database_settings)
|
repository = ProductRepository(settings=database_settings)
|
||||||
await repository.create_tables()
|
await repository.create_tables()
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
from pydantic import BaseModel
|
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):
|
class User(BaseModel):
|
||||||
@@ -25,12 +23,13 @@ class UserTable(BaseTable[User], table=True):
|
|||||||
return User(id=self.id, name=self.name, email=self.email)
|
return User(id=self.id, name=self.name, email=self.email)
|
||||||
|
|
||||||
|
|
||||||
class UserRepository(BaseRepository):
|
class UserFilter(BaseFilter):
|
||||||
def get_db_table(self) -> type[UserTable]:
|
name: str | None = None
|
||||||
return UserTable
|
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):
|
class ProductTable(BaseTable, table=True):
|
||||||
@@ -45,12 +44,8 @@ class ProductFilter(BaseFilter):
|
|||||||
price: int | None = None
|
price: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProductRepository(BaseRepository):
|
class ProductRepository(BaseRepository, table=ProductTable, filter_=ProductFilter):
|
||||||
def get_db_table(self) -> type[ProductTable]:
|
pass
|
||||||
return ProductTable
|
|
||||||
|
|
||||||
def get_filter_type(self) -> type[ProductFilter]:
|
|
||||||
return ProductFilter
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorTable(BaseTable, table=True):
|
class AuthorTable(BaseTable, table=True):
|
||||||
@@ -68,11 +63,18 @@ class BookTable(BaseTable, table=True):
|
|||||||
author: AuthorTable = Relationship(back_populates="books")
|
author: AuthorTable = Relationship(back_populates="books")
|
||||||
|
|
||||||
|
|
||||||
class AuthorRepository(BaseRepository):
|
class AuthorFilter(BaseFilter):
|
||||||
def get_db_table(self) -> type[AuthorTable]:
|
name: str | None = None
|
||||||
return AuthorTable
|
|
||||||
|
|
||||||
|
|
||||||
class BookRepository(BaseRepository):
|
class BookFilter(BaseFilter):
|
||||||
def get_db_table(self) -> type[BookTable]:
|
title: str | None = None
|
||||||
return BookTable
|
author_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorRepository(BaseRepository, table=AuthorTable, filter_=AuthorFilter):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BookRepository(BaseRepository, table=BookTable, filter_=BookFilter):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
|
|
||||||
from metaorm import RepositoriesContainer
|
from metaorm import RepositoriesContainer
|
||||||
from tests.models import UserRepository
|
from tests.models import User, UserRepository
|
||||||
|
|
||||||
|
|
||||||
class TestRepositoriesContainer:
|
class TestRepositoriesContainer:
|
||||||
@@ -39,6 +40,69 @@ class TestRepositoriesContainer:
|
|||||||
):
|
):
|
||||||
assert inner_session is outer_session
|
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(
|
async def test_get_repository_returns_repository_instance(
|
||||||
self,
|
self,
|
||||||
repositories_container: RepositoriesContainer,
|
repositories_container: RepositoriesContainer,
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from pydantic_filters import BaseSort, OffsetPagination
|
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from metaorm import AlreadyExistsError, DatabaseSettings, HaveNoSessionError
|
from metaorm import (
|
||||||
|
AlreadyExistsError,
|
||||||
|
BaseRepository,
|
||||||
|
BaseSort,
|
||||||
|
OffsetPagination,
|
||||||
|
RepositorySettings,
|
||||||
|
)
|
||||||
from tests.models import (
|
from tests.models import (
|
||||||
AuthorRepository,
|
AuthorRepository,
|
||||||
AuthorTable,
|
AuthorTable,
|
||||||
@@ -12,18 +17,13 @@ from tests.models import (
|
|||||||
ProductRepository,
|
ProductRepository,
|
||||||
ProductTable,
|
ProductTable,
|
||||||
User,
|
User,
|
||||||
|
UserFilter,
|
||||||
UserRepository,
|
UserRepository,
|
||||||
|
UserTable,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestBaseRepository:
|
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:
|
async def test_create_item(self, user_repository: UserRepository) -> None:
|
||||||
user = User(name="Alice", email="alice@example.com")
|
user = User(name="Alice", email="alice@example.com")
|
||||||
|
|
||||||
@@ -93,12 +93,29 @@ class TestBaseRepository:
|
|||||||
with pytest.raises(AlreadyExistsError):
|
with pytest.raises(AlreadyExistsError):
|
||||||
await user_repository.create_item(user)
|
await user_repository.create_item(user)
|
||||||
|
|
||||||
async def test_transaction_reuses_existing_session(
|
async def test_transaction_scope_allows_crud(
|
||||||
self,
|
self,
|
||||||
user_repository: UserRepository,
|
user_repository: UserRepository,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with user_repository.transaction():
|
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(
|
async def test_get_items_with_pagination(
|
||||||
self,
|
self,
|
||||||
@@ -147,11 +164,83 @@ class TestBaseRepository:
|
|||||||
assert created.name == "Widget"
|
assert created.name == "Widget"
|
||||||
|
|
||||||
async def test_get_filter_type(self) -> None:
|
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(
|
async def test_get_items_with_filter(
|
||||||
self,
|
self,
|
||||||
@@ -217,3 +306,72 @@ class TestBaseRepository:
|
|||||||
|
|
||||||
assert len(updated) == 1
|
assert len(updated) == 1
|
||||||
assert updated[0].title == "Updated"
|
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
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from metaorm import DatabaseSettings
|
from metaorm import RepositorySettings
|
||||||
|
|
||||||
|
|
||||||
class TestDatabaseSettings:
|
class TestRepositorySettings:
|
||||||
def test_default_values(self) -> None:
|
def test_default_values(self) -> None:
|
||||||
settings = DatabaseSettings()
|
settings = RepositorySettings()
|
||||||
|
|
||||||
assert settings.dsn == "sqlite+aiosqlite:///db.sqlite3"
|
assert settings.dsn == "sqlite+aiosqlite:///db.sqlite3"
|
||||||
assert settings.pool_size == 5
|
assert settings.pool_size == 5
|
||||||
@@ -14,7 +14,7 @@ class TestDatabaseSettings:
|
|||||||
assert settings.pool_timeout == 60
|
assert settings.pool_timeout == 60
|
||||||
|
|
||||||
def test_custom_values(self) -> None:
|
def test_custom_values(self) -> None:
|
||||||
settings = DatabaseSettings(
|
settings = RepositorySettings(
|
||||||
dsn="postgresql+asyncpg://user:pass@localhost/db",
|
dsn="postgresql+asyncpg://user:pass@localhost/db",
|
||||||
pool_size=10,
|
pool_size=10,
|
||||||
pool_recycle=120,
|
pool_recycle=120,
|
||||||
@@ -28,7 +28,7 @@ class TestDatabaseSettings:
|
|||||||
|
|
||||||
def test_dsn_must_match_pattern(self) -> None:
|
def test_dsn_must_match_pattern(self) -> None:
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
DatabaseSettings(dsn="invalid_dsn")
|
RepositorySettings(dsn="invalid_dsn")
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"field_name,invalid_value",
|
"field_name,invalid_value",
|
||||||
@@ -44,4 +44,4 @@ class TestDatabaseSettings:
|
|||||||
invalid_value: int,
|
invalid_value: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
DatabaseSettings(**{field_name: invalid_value})
|
RepositorySettings(**{field_name: invalid_value})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from metaorm import BaseTable
|
from metaorm import BaseTable, Field
|
||||||
from tests.models import User
|
from tests.models import User
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
16
uv.lock
generated
16
uv.lock
generated
@@ -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/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/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/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/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/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/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" },
|
{ 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/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/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/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/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/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/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" },
|
{ 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/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/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/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/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/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/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" },
|
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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/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/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/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/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/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/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" },
|
{ 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]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "pydantic", specifier = ">=2.0" },
|
{ 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 = "sqlalchemy", specifier = ">=2.0" },
|
||||||
{ name = "sqlmodel", specifier = ">=0.0.22" },
|
{ name = "sqlmodel", specifier = ">=0.0.22" },
|
||||||
]
|
]
|
||||||
@@ -340,7 +352,7 @@ wheels = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-filters"
|
name = "pydantic-filters"
|
||||||
version = "0.0.0"
|
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 = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user