mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 14:46:24 +02:00
Compare commits
43 Commits
v2.2.0
...
04afef7e33
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04afef7e33 | ||
|
|
666c621fda | ||
|
460b760fa4
|
|||
|
|
65d0b0e0b1 | ||
|
|
2d49cd32db | ||
|
|
a5dd756d87 | ||
|
|
781cfb66c9 | ||
|
|
91b84f8146 | ||
|
|
396e381ac3 | ||
|
|
b4eb4c1ca9 | ||
|
c90717754f
|
|||
|
337985ef38
|
|||
|
|
b5e6dfe6fe | ||
|
|
6681b7ade7 | ||
|
|
6981c33dc8 | ||
|
|
0c7a99039c | ||
|
|
bcb5b0bfda | ||
|
100e1c1aa9
|
|||
|
|
db6c7a565f | ||
|
|
768e405554 | ||
|
|
f0223ebde4 | ||
|
|
f8c9bf69fe | ||
|
6d6fae5538
|
|||
|
|
fc9cd1f034 | ||
|
|
f82225f995 | ||
|
|
e62612a93a | ||
|
|
56f0ea291e | ||
|
|
ee896009ee | ||
|
|
65bf928e12 | ||
|
|
2e9c6c0c90 | ||
|
2c494fcd17
|
|||
|
|
fd7269a372 | ||
|
|
c863744012 | ||
|
|
aedcbf4e04 | ||
|
|
19c013bdec | ||
|
|
81407c3038 | ||
|
|
0fb00d44da | ||
|
|
19232d3436 | ||
|
1eafcb3873
|
|||
|
|
0d67fbb58d | ||
|
|
a59f098930 | ||
|
|
96e34ba8af | ||
|
|
26d649791f |
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -34,5 +34,5 @@ jobs:
|
||||
with:
|
||||
path: site
|
||||
|
||||
- uses: actions/deploy-pages@v4
|
||||
- uses: actions/deploy-pages@v5
|
||||
id: deployment
|
||||
|
||||
34
.pre-commit-config.yaml
Normal file
34
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
args: ["--maxkb=750"]
|
||||
exclude: ^uv.lock$
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: local-ruff-check
|
||||
name: ruff check
|
||||
entry: uv run ruff check --force-exclude --fix --exit-non-zero-on-fix .
|
||||
require_serial: true
|
||||
language: unsupported
|
||||
types: [python]
|
||||
|
||||
- id: local-ruff-format
|
||||
name: ruff format
|
||||
entry: uv run ruff format --force-exclude --exit-non-zero-on-format .
|
||||
require_serial: true
|
||||
language: unsupported
|
||||
types: [python]
|
||||
|
||||
- id: local-ty
|
||||
name: ty check
|
||||
entry: uv run ty check
|
||||
require_serial: true
|
||||
language: unsupported
|
||||
pass_filenames: false
|
||||
@@ -48,8 +48,8 @@ uv add "fastapi-toolsets[all]"
|
||||
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
||||
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
||||
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
||||
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
|
||||
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
||||
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`) and lifecycle callbacks (`WatchedFieldsMixin`, `@watch`) that fire after commit for insert, update, and delete events
|
||||
- **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
|
||||
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
||||
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
||||
|
||||
|
||||
@@ -42,12 +42,17 @@ Declare `searchable_fields`, `facet_fields`, and `order_fields` once on [`CrudFa
|
||||
|
||||
|
||||
## Routes
|
||||
|
||||
```python title="routes.py:1:17"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:1:17"
|
||||
```
|
||||
|
||||
### Offset pagination
|
||||
|
||||
Best for admin panels or any UI that needs a total item count and numbered pages.
|
||||
|
||||
```python title="routes.py:1:36"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:1:36"
|
||||
```python title="routes.py:20:40"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:20:40"
|
||||
```
|
||||
|
||||
**Example request**
|
||||
@@ -61,11 +66,13 @@ GET /articles/offset?page=2&items_per_page=10&search=fastapi&status=published&or
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"pagination_type": "offset",
|
||||
"data": [
|
||||
{ "id": "3f47ac69-...", "title": "FastAPI tips", "status": "published", ... }
|
||||
],
|
||||
"pagination": {
|
||||
"total_count": 42,
|
||||
"pages": 5,
|
||||
"page": 2,
|
||||
"items_per_page": 10,
|
||||
"has_more": true
|
||||
@@ -79,12 +86,14 @@ GET /articles/offset?page=2&items_per_page=10&search=fastapi&status=published&or
|
||||
|
||||
`filter_attributes` always reflects the values visible **after** applying the active filters. Use it to populate filter dropdowns on the client.
|
||||
|
||||
To skip the `COUNT(*)` query for better performance on large tables, pass `include_total=False`. `pagination.total_count` will be `null` in the response, while `has_more` remains accurate.
|
||||
|
||||
### Cursor pagination
|
||||
|
||||
Best for feeds, infinite scroll, or any high-throughput API where offset performance degrades.
|
||||
|
||||
```python title="routes.py:39:59"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:39:59"
|
||||
```python title="routes.py:43:63"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:43:63"
|
||||
```
|
||||
|
||||
**Example request**
|
||||
@@ -98,6 +107,7 @@ GET /articles/cursor?items_per_page=10&status=published&order_by=created_at&orde
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"pagination_type": "cursor",
|
||||
"data": [
|
||||
{ "id": "3f47ac69-...", "title": "FastAPI tips", "status": "published", ... }
|
||||
],
|
||||
@@ -116,6 +126,47 @@ GET /articles/cursor?items_per_page=10&status=published&order_by=created_at&orde
|
||||
|
||||
Pass `next_cursor` as the `cursor` query parameter on the next request to advance to the next page.
|
||||
|
||||
### Unified endpoint (both strategies)
|
||||
|
||||
!!! info "Added in `v2.3.0`"
|
||||
|
||||
[`paginate()`](../module/crud.md#unified-paginate--both-strategies-on-one-endpoint) lets a single endpoint support both strategies via a `pagination_type` query parameter. The `pagination_type` field in the response acts as a discriminator for frontend tooling.
|
||||
|
||||
```python title="routes.py:66:90"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:66:90"
|
||||
```
|
||||
|
||||
**Offset request** (default)
|
||||
|
||||
```
|
||||
GET /articles/?pagination_type=offset&page=1&items_per_page=10
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"pagination_type": "offset",
|
||||
"data": ["..."],
|
||||
"pagination": { "total_count": 42, "pages": 5, "page": 1, "items_per_page": 10, "has_more": true }
|
||||
}
|
||||
```
|
||||
|
||||
**Cursor request**
|
||||
|
||||
```
|
||||
GET /articles/?pagination_type=cursor&items_per_page=10
|
||||
GET /articles/?pagination_type=cursor&items_per_page=10&cursor=eyJ2YWx1ZSI6...
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"pagination_type": "cursor",
|
||||
"data": ["..."],
|
||||
"pagination": { "next_cursor": "eyJ2YWx1ZSI6...", "prev_cursor": null, "items_per_page": 10, "has_more": true }
|
||||
}
|
||||
```
|
||||
|
||||
## Search behaviour
|
||||
|
||||
Both endpoints inherit the same `searchable_fields` declared on `ArticleCrud`:
|
||||
|
||||
@@ -48,8 +48,8 @@ uv add "fastapi-toolsets[all]"
|
||||
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
||||
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
||||
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
||||
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
|
||||
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
||||
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`) and lifecycle callbacks (`WatchedFieldsMixin`) that fire after commit for insert, update, and delete events.
|
||||
- **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
|
||||
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
||||
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ Generic async CRUD operations for SQLAlchemy models with search, pagination, and
|
||||
|
||||
## Overview
|
||||
|
||||
The `crud` module provides [`AsyncCrud`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud), an abstract base class with a full suite of async database operations, and [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory), a convenience function to instantiate it for a given model.
|
||||
The `crud` module provides [`AsyncCrud`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud), a base class with a full suite of async database operations, and [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory), a convenience function to instantiate it for a given model.
|
||||
|
||||
## Creating a CRUD class
|
||||
|
||||
### Factory style
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.crud import CrudFactory
|
||||
from myapp.models import User
|
||||
@@ -18,7 +20,65 @@ from myapp.models import User
|
||||
UserCrud = CrudFactory(model=User)
|
||||
```
|
||||
|
||||
[`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory) dynamically creates a class named `AsyncUserCrud` with `User` as its model.
|
||||
[`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory) dynamically creates a class named `AsyncUserCrud` with `User` as its model. This is the most concise option for straightforward CRUD with no custom logic.
|
||||
|
||||
### Subclass style
|
||||
|
||||
!!! info "Added in `v2.3.0`"
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.crud.factory import AsyncCrud
|
||||
from myapp.models import User
|
||||
|
||||
class UserCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
searchable_fields = [User.username, User.email]
|
||||
default_load_options = [selectinload(User.role)]
|
||||
```
|
||||
|
||||
Subclassing [`AsyncCrud`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud) directly is the preferred style when you need to add custom methods or when the configuration is complex enough to benefit from a named class body.
|
||||
|
||||
### Adding custom methods
|
||||
|
||||
```python
|
||||
class UserCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
|
||||
@classmethod
|
||||
async def get_active(cls, session: AsyncSession) -> list[User]:
|
||||
return await cls.get_multi(session, filters=[User.is_active == True])
|
||||
```
|
||||
|
||||
### Sharing a custom base across multiple models
|
||||
|
||||
Define a generic base class with the shared methods, then subclass it for each model:
|
||||
|
||||
```python
|
||||
from typing import Generic, TypeVar
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from fastapi_toolsets.crud.factory import AsyncCrud
|
||||
|
||||
T = TypeVar("T", bound=DeclarativeBase)
|
||||
|
||||
class AuditedCrud(AsyncCrud[T], Generic[T]):
|
||||
"""Base CRUD with custom function"""
|
||||
|
||||
@classmethod
|
||||
async def get_active(cls, session: AsyncSession):
|
||||
return await cls.get_multi(session, filters=[cls.model.is_active == True])
|
||||
|
||||
|
||||
class UserCrud(AuditedCrud[User]):
|
||||
model = User
|
||||
searchable_fields = [User.username, User.email]
|
||||
```
|
||||
|
||||
You can also use the factory shorthand with the same base by passing `base_class`:
|
||||
|
||||
```python
|
||||
UserCrud = CrudFactory(User, base_class=AuditedCrud)
|
||||
```
|
||||
|
||||
## Basic operations
|
||||
|
||||
@@ -85,44 +145,44 @@ user = await UserCrud.first(session=session, filters=[User.is_active == True])
|
||||
|
||||
!!! info "Added in `v1.1` (only offset_pagination via `paginate` if `<v1.1`)"
|
||||
|
||||
Two pagination strategies are available. Both return a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) but differ in how they navigate through results.
|
||||
Three pagination methods are available. All return a typed response whose `pagination_type` field tells clients which strategy was used.
|
||||
|
||||
| | `offset_paginate` | `cursor_paginate` |
|
||||
|---|---|---|
|
||||
| Total count | Yes | No |
|
||||
| Jump to arbitrary page | Yes | No |
|
||||
| Performance on deep pages | Degrades | Constant |
|
||||
| Stable under concurrent inserts | No | Yes |
|
||||
| Search compatible | Yes | Yes |
|
||||
| Use case | Admin panels, numbered pagination | Feeds, APIs, infinite scroll |
|
||||
| | `offset_paginate` | `cursor_paginate` | `paginate` |
|
||||
|---|---|---|---|
|
||||
| Return type | `OffsetPaginatedResponse` | `CursorPaginatedResponse` | either, based on `pagination_type` param |
|
||||
| Total count | Yes | No | / |
|
||||
| Jump to arbitrary page | Yes | No | / |
|
||||
| Performance on deep pages | Degrades | Constant | / |
|
||||
| Stable under concurrent inserts | No | Yes | / |
|
||||
| Use case | Admin panels, numbered pagination | Feeds, APIs, infinite scroll | single endpoint, both strategies |
|
||||
|
||||
### Offset pagination
|
||||
|
||||
```python
|
||||
@router.get(
|
||||
"",
|
||||
response_model=PaginatedResponse[User],
|
||||
)
|
||||
@router.get("")
|
||||
async def get_users(
|
||||
session: SessionDep,
|
||||
items_per_page: int = 50,
|
||||
page: int = 1,
|
||||
):
|
||||
return await crud.UserCrud.offset_paginate(
|
||||
) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await UserCrud.offset_paginate(
|
||||
session=session,
|
||||
items_per_page=items_per_page,
|
||||
page=page,
|
||||
schema=UserRead,
|
||||
)
|
||||
```
|
||||
|
||||
The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) method returns a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) whose `pagination` field is an [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination) object:
|
||||
The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) method returns an [`OffsetPaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPaginatedResponse):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"pagination_type": "offset",
|
||||
"data": ["..."],
|
||||
"pagination": {
|
||||
"total_count": 100,
|
||||
"pages": 5,
|
||||
"page": 1,
|
||||
"items_per_page": 20,
|
||||
"has_more": true
|
||||
@@ -130,30 +190,63 @@ The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.Async
|
||||
}
|
||||
```
|
||||
|
||||
#### Skipping the COUNT query
|
||||
|
||||
!!! info "Added in `v2.4.1`"
|
||||
|
||||
By default `offset_paginate` runs two queries: one for the page items and one `COUNT(*)` for `total_count`. On large tables the `COUNT` can be expensive. Pass `include_total=False` to skip it:
|
||||
|
||||
```python
|
||||
result = await UserCrud.offset_paginate(
|
||||
session=session,
|
||||
page=page,
|
||||
items_per_page=items_per_page,
|
||||
include_total=False,
|
||||
schema=UserRead,
|
||||
)
|
||||
```
|
||||
|
||||
#### Pagination params dependency
|
||||
|
||||
!!! info "Added in `v2.4.1`"
|
||||
|
||||
Use [`offset_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_params) to generate a FastAPI dependency that injects `page` and `items_per_page` from query parameters with configurable defaults and a `max_page_size` cap:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
params: Annotated[dict, Depends(UserCrud.offset_params(default_page_size=20, max_page_size=100))],
|
||||
) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||
```
|
||||
|
||||
### Cursor pagination
|
||||
|
||||
```python
|
||||
@router.get(
|
||||
"",
|
||||
response_model=PaginatedResponse[UserRead],
|
||||
)
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
cursor: str | None = None,
|
||||
items_per_page: int = 20,
|
||||
):
|
||||
) -> CursorPaginatedResponse[UserRead]:
|
||||
return await UserCrud.cursor_paginate(
|
||||
session=session,
|
||||
cursor=cursor,
|
||||
items_per_page=items_per_page,
|
||||
schema=UserRead,
|
||||
)
|
||||
```
|
||||
|
||||
The [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) method returns a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) whose `pagination` field is a [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination) object:
|
||||
The [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) method returns a [`CursorPaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPaginatedResponse):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"pagination_type": "cursor",
|
||||
"data": ["..."],
|
||||
"pagination": {
|
||||
"next_cursor": "eyJ2YWx1ZSI6ICIzZjQ3YWM2OS0uLi4ifQ==",
|
||||
@@ -180,7 +273,7 @@ The cursor column is set once on [`CrudFactory`](../reference/crud.md#fastapi_to
|
||||
!!! note
|
||||
`cursor_column` is required. Calling [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) on a CRUD class that has no `cursor_column` configured raises a `ValueError`.
|
||||
|
||||
The cursor value is base64-encoded when returned to the client and decoded back to the correct Python type on the next request. The following SQLAlchemy column types are supported:
|
||||
The cursor value is URL-safe base64-encoded (no padding) when returned to the client and decoded back to the correct Python type on the next request. The following SQLAlchemy column types are supported:
|
||||
|
||||
| SQLAlchemy type | Python type |
|
||||
|---|---|
|
||||
@@ -198,6 +291,76 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.id)
|
||||
PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
||||
```
|
||||
|
||||
#### Pagination params dependency
|
||||
|
||||
!!! info "Added in `v2.4.1`"
|
||||
|
||||
Use [`cursor_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_params) to inject `cursor` and `items_per_page` from query parameters with a `max_page_size` cap:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
params: Annotated[dict, Depends(UserCrud.cursor_params(default_page_size=20, max_page_size=100))],
|
||||
) -> CursorPaginatedResponse[UserRead]:
|
||||
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
||||
```
|
||||
|
||||
### Unified endpoint (both strategies)
|
||||
|
||||
!!! info "Added in `v2.3.0`"
|
||||
|
||||
[`paginate()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate) dispatches to `offset_paginate` or `cursor_paginate` based on a `pagination_type` query parameter, letting you expose **one endpoint** that supports both strategies. The `pagination_type` field in the response tells clients which strategy was used, enabling frontend discriminated-union typing.
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.crud import PaginationType
|
||||
from fastapi_toolsets.schemas import PaginatedResponse
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
pagination_type: PaginationType = PaginationType.OFFSET,
|
||||
page: int = Query(1, ge=1, description="Current page (offset only)"),
|
||||
cursor: str | None = Query(None, description="Cursor token (cursor only)"),
|
||||
items_per_page: int = Query(20, ge=1, le=100),
|
||||
) -> PaginatedResponse[UserRead]:
|
||||
return await UserCrud.paginate(
|
||||
session,
|
||||
pagination_type=pagination_type,
|
||||
page=page,
|
||||
cursor=cursor,
|
||||
items_per_page=items_per_page,
|
||||
schema=UserRead,
|
||||
)
|
||||
```
|
||||
|
||||
```
|
||||
GET /users?pagination_type=offset&page=2&items_per_page=10
|
||||
GET /users?pagination_type=cursor&cursor=eyJ2YWx1ZSI6...&items_per_page=10
|
||||
```
|
||||
|
||||
#### Pagination params dependency
|
||||
|
||||
!!! info "Added in `v2.4.1`"
|
||||
|
||||
Use [`paginate_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate_params) to inject all parameters at once with configurable defaults and a `max_page_size` cap:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
from fastapi_toolsets.schemas import PaginatedResponse
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
params: Annotated[dict, Depends(UserCrud.paginate_params(default_page_size=20, max_page_size=100))],
|
||||
) -> PaginatedResponse[UserRead]:
|
||||
return await UserCrud.paginate(session, **params, schema=UserRead)
|
||||
```
|
||||
|
||||
## Search
|
||||
|
||||
Two search strategies are available, both compatible with [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate).
|
||||
@@ -212,6 +375,9 @@ Two search strategies are available, both compatible with [`offset_paginate`](..
|
||||
|
||||
### Full-text search
|
||||
|
||||
!!! info "Added in `v2.2.1`"
|
||||
The model's primary key is always included in `searchable_fields` automatically, so searching by ID works out of the box without any configuration. When no `searchable_fields` are declared, only the primary key is searched.
|
||||
|
||||
Declare `searchable_fields` on the CRUD class. Relationship traversal is supported via tuples:
|
||||
|
||||
```python
|
||||
@@ -237,40 +403,36 @@ result = await UserCrud.offset_paginate(
|
||||
This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate):
|
||||
|
||||
```python
|
||||
@router.get(
|
||||
"",
|
||||
response_model=PaginatedResponse[User],
|
||||
)
|
||||
@router.get("")
|
||||
async def get_users(
|
||||
session: SessionDep,
|
||||
items_per_page: int = 50,
|
||||
page: int = 1,
|
||||
search: str | None = None,
|
||||
):
|
||||
return await crud.UserCrud.offset_paginate(
|
||||
) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await UserCrud.offset_paginate(
|
||||
session=session,
|
||||
items_per_page=items_per_page,
|
||||
page=page,
|
||||
search=search,
|
||||
schema=UserRead,
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
@router.get(
|
||||
"",
|
||||
response_model=PaginatedResponse[User],
|
||||
)
|
||||
@router.get("")
|
||||
async def get_users(
|
||||
session: SessionDep,
|
||||
cursor: str | None = None,
|
||||
items_per_page: int = 50,
|
||||
search: str | None = None,
|
||||
):
|
||||
return await crud.UserCrud.cursor_paginate(
|
||||
) -> CursorPaginatedResponse[UserRead]:
|
||||
return await UserCrud.cursor_paginate(
|
||||
session=session,
|
||||
items_per_page=items_per_page,
|
||||
cursor=cursor,
|
||||
search=search,
|
||||
schema=UserRead,
|
||||
)
|
||||
```
|
||||
|
||||
@@ -341,11 +503,12 @@ async def list_users(
|
||||
session: SessionDep,
|
||||
page: int = 1,
|
||||
filter_by: Annotated[dict[str, list[str]], Depends(UserCrud.filter_params())],
|
||||
) -> PaginatedResponse[UserRead]:
|
||||
) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await UserCrud.offset_paginate(
|
||||
session=session,
|
||||
page=page,
|
||||
filter_by=filter_by,
|
||||
schema=UserRead,
|
||||
)
|
||||
```
|
||||
|
||||
@@ -385,8 +548,8 @@ from fastapi_toolsets.crud import OrderByClause
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
order_by: Annotated[OrderByClause | None, Depends(UserCrud.order_params())],
|
||||
) -> PaginatedResponse[UserRead]:
|
||||
return await UserCrud.offset_paginate(session=session, order_by=order_by)
|
||||
) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await UserCrud.offset_paginate(session=session, order_by=order_by, schema=UserRead)
|
||||
```
|
||||
|
||||
The dependency adds two query parameters to the endpoint:
|
||||
@@ -493,7 +656,7 @@ async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
|
||||
)
|
||||
|
||||
@router.get("")
|
||||
async def list_users(session: SessionDep, page: int = 1) -> PaginatedResponse[UserRead]:
|
||||
async def list_users(session: SessionDep, page: int = 1) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await crud.UserCrud.offset_paginate(
|
||||
session=session,
|
||||
page=page,
|
||||
|
||||
@@ -13,8 +13,13 @@ The `dependencies` module provides two factory functions that create FastAPI dep
|
||||
```python
|
||||
from fastapi_toolsets.dependencies import PathDependency
|
||||
|
||||
# Plain callable
|
||||
UserDep = PathDependency(model=User, field=User.id, session_dep=get_db)
|
||||
|
||||
# Annotated
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
UserDep = PathDependency(model=User, field=User.id, session_dep=SessionDep)
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def get_user(user: User = UserDep):
|
||||
return user
|
||||
@@ -37,8 +42,14 @@ async def get_user(user: User = UserDep):
|
||||
```python
|
||||
from fastapi_toolsets.dependencies import BodyDependency
|
||||
|
||||
# Plain callable
|
||||
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=get_db, body_field="role_id")
|
||||
|
||||
# Annotated
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=SessionDep, body_field="role_id")
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(body: UserCreateSchema, role: Role = RoleDep):
|
||||
user = User(username=body.username, role=role)
|
||||
|
||||
@@ -38,18 +38,20 @@ By context with [`load_fixtures_by_context`](../reference/fixtures.md#fastapi_to
|
||||
from fastapi_toolsets.fixtures import load_fixtures_by_context
|
||||
|
||||
async with db_context() as session:
|
||||
await load_fixtures_by_context(session=session, registry=fixtures, context=Context.TESTING)
|
||||
await load_fixtures_by_context(session, fixtures, Context.TESTING)
|
||||
```
|
||||
|
||||
Directly with [`load_fixtures`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.load_fixtures):
|
||||
Directly by name with [`load_fixtures`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.load_fixtures):
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.fixtures import load_fixtures
|
||||
|
||||
async with db_context() as session:
|
||||
await load_fixtures(session=session, registry=fixtures)
|
||||
await load_fixtures(session, fixtures, "roles", "test_users")
|
||||
```
|
||||
|
||||
Both functions return a `dict[str, list[...]]` mapping each fixture name to the list of loaded instances.
|
||||
|
||||
## Contexts
|
||||
|
||||
[`Context`](../reference/fixtures.md#fastapi_toolsets.fixtures.enum.Context) is an enum with predefined values:
|
||||
@@ -58,10 +60,60 @@ async with db_context() as session:
|
||||
|---------|-------------|
|
||||
| `Context.BASE` | Core data required in all environments |
|
||||
| `Context.TESTING` | Data only loaded during tests |
|
||||
| `Context.DEVELOPMENT` | Data only loaded in development |
|
||||
| `Context.PRODUCTION` | Data only loaded in production |
|
||||
|
||||
A fixture with no `contexts` defined takes `Context.BASE` by default.
|
||||
|
||||
### Custom contexts
|
||||
|
||||
Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is expected.
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
class AppContext(str, Enum):
|
||||
STAGING = "staging"
|
||||
DEMO = "demo"
|
||||
|
||||
@fixtures.register(contexts=[AppContext.STAGING])
|
||||
def staging_data():
|
||||
return [Config(key="feature_x", enabled=True)]
|
||||
|
||||
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
|
||||
```
|
||||
|
||||
### Default context for a registry
|
||||
|
||||
Pass `contexts` to `FixtureRegistry` to set a default for all fixtures registered in it:
|
||||
|
||||
```python
|
||||
testing_registry = FixtureRegistry(contexts=[Context.TESTING])
|
||||
|
||||
@testing_registry.register # implicitly contexts=[Context.TESTING]
|
||||
def test_orders():
|
||||
return [Order(id=1, total=99)]
|
||||
```
|
||||
|
||||
### Same fixture name, multiple context variants
|
||||
|
||||
The same fixture name may be registered under different (non-overlapping) context sets. When multiple contexts are loaded together, all matching variants are merged:
|
||||
|
||||
```python
|
||||
@fixtures.register(contexts=[Context.BASE])
|
||||
def users():
|
||||
return [User(id=1, username="admin")]
|
||||
|
||||
@fixtures.register(contexts=[Context.TESTING])
|
||||
def users():
|
||||
return [User(id=2, username="tester")]
|
||||
|
||||
# loads both admin and tester
|
||||
await load_fixtures_by_context(session, fixtures, Context.BASE, Context.TESTING)
|
||||
```
|
||||
|
||||
Registering two variants with overlapping context sets raises `ValueError`.
|
||||
|
||||
## Load strategies
|
||||
|
||||
[`LoadStrategy`](../reference/fixtures.md#fastapi_toolsets.fixtures.enum.LoadStrategy) controls how the fixture loader handles rows that already exist:
|
||||
@@ -69,20 +121,44 @@ A fixture with no `contexts` defined takes `Context.BASE` by default.
|
||||
| Strategy | Description |
|
||||
|----------|-------------|
|
||||
| `LoadStrategy.INSERT` | Insert only, fail on duplicates |
|
||||
| `LoadStrategy.UPSERT` | Insert or update on conflict |
|
||||
| `LoadStrategy.SKIP` | Skip rows that already exist |
|
||||
| `LoadStrategy.MERGE` | Insert or update on conflict (default) |
|
||||
| `LoadStrategy.SKIP_EXISTING` | Skip rows that already exist |
|
||||
|
||||
```python
|
||||
await load_fixtures_by_context(
|
||||
session, fixtures, Context.BASE, strategy=LoadStrategy.SKIP_EXISTING
|
||||
)
|
||||
```
|
||||
|
||||
## Merging registries
|
||||
|
||||
Split fixtures definitions across modules and merge them:
|
||||
Split fixture definitions across modules and merge them:
|
||||
|
||||
```python
|
||||
from myapp.fixtures.dev import dev_fixtures
|
||||
from myapp.fixtures.prod import prod_fixtures
|
||||
|
||||
fixtures = fixturesRegistry()
|
||||
fixtures = FixtureRegistry()
|
||||
fixtures.include_registry(registry=dev_fixtures)
|
||||
fixtures.include_registry(registry=prod_fixtures)
|
||||
```
|
||||
|
||||
Fixtures with the same name are allowed as long as their context sets do not overlap. Conflicting contexts raise `ValueError`.
|
||||
|
||||
## Looking up fixture instances
|
||||
|
||||
[`get_obj_by_attr`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.get_obj_by_attr) retrieves a specific instance from a fixture function by attribute value — useful when building cross-fixture `depends_on` relationships:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.fixtures import get_obj_by_attr
|
||||
|
||||
@fixtures.register(depends_on=["roles"])
|
||||
def users():
|
||||
admin_role = get_obj_by_attr(roles, "name", "admin")
|
||||
return [User(id=1, username="alice", role_id=admin_role.id)]
|
||||
```
|
||||
|
||||
Raises `StopIteration` if no matching instance is found.
|
||||
|
||||
## Pytest integration
|
||||
|
||||
@@ -111,7 +187,6 @@ async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Rol
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
The load order is resolved automatically from the `depends_on` declarations in your registry. Each generated fixture receives `db_session` as a dependency and returns the list of loaded model instances.
|
||||
|
||||
## CLI integration
|
||||
|
||||
@@ -18,13 +18,15 @@ class Article(Base, UUIDMixin, TimestampMixin):
|
||||
content: Mapped[str]
|
||||
```
|
||||
|
||||
All timestamp columns are timezone-aware (`TIMESTAMPTZ`). All defaults are server-side, so they are also applied when inserting rows via raw SQL outside the ORM.
|
||||
All timestamp columns are timezone-aware (`TIMESTAMPTZ`). All defaults are server-side (`clock_timestamp()`), so they are also applied when inserting rows via raw SQL outside the ORM.
|
||||
|
||||
## Mixins
|
||||
|
||||
### [`UUIDMixin`](../reference/models.md#fastapi_toolsets.models.UUIDMixin)
|
||||
|
||||
Adds a `id: UUID` primary key generated server-side by PostgreSQL using `gen_random_uuid()` (requires PostgreSQL 13+). The value is retrieved via `RETURNING` after insert, so it is available on the Python object immediately after `flush()`.
|
||||
Adds a `id: UUID` primary key generated server-side by PostgreSQL using `gen_random_uuid()`. The value is retrieved via `RETURNING` after insert, so it is available on the Python object immediately after `flush()`.
|
||||
|
||||
!!! warning "Requires PostgreSQL 13+"
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin
|
||||
@@ -36,13 +38,37 @@ class User(Base, UUIDMixin):
|
||||
|
||||
# id is None before flush
|
||||
user = User(username="alice")
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
print(user.id) # UUID('...')
|
||||
```
|
||||
|
||||
### [`UUIDv7Mixin`](../reference/models.md#fastapi_toolsets.models.UUIDv7Mixin)
|
||||
|
||||
!!! info "Added in `v2.3`"
|
||||
|
||||
Adds a `id: UUID` primary key generated server-side by PostgreSQL using `uuidv7()`. It's a time-ordered UUID format that encodes a millisecond-precision timestamp in the most significant bits, making it naturally sortable and index-friendly.
|
||||
|
||||
!!! warning "Requires PostgreSQL 18+"
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDv7Mixin
|
||||
|
||||
class Event(Base, UUIDv7Mixin):
|
||||
__tablename__ = "events"
|
||||
|
||||
name: Mapped[str]
|
||||
|
||||
# id is None before flush
|
||||
event = Event(name="user.signup")
|
||||
session.add(event)
|
||||
await session.flush()
|
||||
print(event.id) # UUID('019...')
|
||||
```
|
||||
|
||||
### [`CreatedAtMixin`](../reference/models.md#fastapi_toolsets.models.CreatedAtMixin)
|
||||
|
||||
Adds a `created_at: datetime` column set to `NOW()` on insert. The column has no `onupdate` hook — it is intentionally immutable after the row is created.
|
||||
Adds a `created_at: datetime` column set to `clock_timestamp()` on insert. The column has no `onupdate` hook — it is intentionally immutable after the row is created.
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin
|
||||
@@ -55,7 +81,7 @@ class Order(Base, UUIDMixin, CreatedAtMixin):
|
||||
|
||||
### [`UpdatedAtMixin`](../reference/models.md#fastapi_toolsets.models.UpdatedAtMixin)
|
||||
|
||||
Adds an `updated_at: datetime` column set to `NOW()` on insert and automatically updated to `NOW()` on every ORM-level update (via SQLAlchemy's `onupdate` hook).
|
||||
Adds an `updated_at: datetime` column set to `clock_timestamp()` on insert and automatically updated to `clock_timestamp()` on every ORM-level update (via SQLAlchemy's `onupdate` hook).
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin
|
||||
@@ -91,6 +117,122 @@ class Article(Base, UUIDMixin, TimestampMixin):
|
||||
title: Mapped[str]
|
||||
```
|
||||
|
||||
### [`WatchedFieldsMixin`](../reference/models.md#fastapi_toolsets.models.WatchedFieldsMixin)
|
||||
|
||||
!!! info "Added in `v2.4`"
|
||||
|
||||
`WatchedFieldsMixin` provides lifecycle callbacks that fire **after commit** — meaning the row is durably persisted when your callback runs. If the transaction rolls back, no callback fires.
|
||||
|
||||
Three callbacks are available, each corresponding to a [`ModelEvent`](../reference/models.md#fastapi_toolsets.models.ModelEvent) value:
|
||||
|
||||
| Callback | Event | Trigger |
|
||||
|---|---|---|
|
||||
| `on_create()` | `ModelEvent.CREATE` | After `INSERT` |
|
||||
| `on_delete()` | `ModelEvent.DELETE` | After `DELETE` |
|
||||
| `on_update(changes)` | `ModelEvent.UPDATE` | After `UPDATE` on a watched field |
|
||||
|
||||
Server-side defaults (e.g. `id`, `created_at`) are fully populated in all callbacks. All callbacks support both `async def` and plain `def`. Use `@watch` to restrict which fields trigger `on_update`:
|
||||
|
||||
| Decorator | `on_update` behaviour |
|
||||
|---|---|
|
||||
| `@watch("status", "role")` | Only fires when `status` or `role` changes |
|
||||
| *(no decorator)* | Fires when **any** mapped field changes |
|
||||
|
||||
`@watch` is inherited through the class hierarchy. If a subclass does not declare its own `@watch`, it uses the filter from the nearest decorated parent. Applying `@watch` on the subclass overrides the parent's filter:
|
||||
|
||||
```python
|
||||
@watch("status")
|
||||
class Order(Base, UUIDMixin, WatchedFieldsMixin):
|
||||
...
|
||||
|
||||
class UrgentOrder(Order):
|
||||
# inherits @watch("status") — on_update fires only for status changes
|
||||
...
|
||||
|
||||
@watch("priority")
|
||||
class PriorityOrder(Order):
|
||||
# overrides parent — on_update fires only for priority changes
|
||||
...
|
||||
```
|
||||
|
||||
#### Option 1 — catch-all with `on_event`
|
||||
|
||||
Override `on_event` to handle all event types in one place. The specific methods delegate here by default:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.models import ModelEvent, UUIDMixin, WatchedFieldsMixin, watch
|
||||
|
||||
@watch("status")
|
||||
class Order(Base, UUIDMixin, WatchedFieldsMixin):
|
||||
__tablename__ = "orders"
|
||||
|
||||
status: Mapped[str]
|
||||
|
||||
async def on_event(self, event: ModelEvent, changes: dict | None = None) -> None:
|
||||
if event == ModelEvent.CREATE:
|
||||
await notify_new_order(self.id)
|
||||
elif event == ModelEvent.DELETE:
|
||||
await notify_order_cancelled(self.id)
|
||||
elif event == ModelEvent.UPDATE:
|
||||
await notify_status_change(self.id, changes["status"])
|
||||
```
|
||||
|
||||
#### Option 2 — targeted overrides
|
||||
|
||||
Override individual methods for more focused logic:
|
||||
|
||||
```python
|
||||
@watch("status")
|
||||
class Order(Base, UUIDMixin, WatchedFieldsMixin):
|
||||
__tablename__ = "orders"
|
||||
|
||||
status: Mapped[str]
|
||||
|
||||
async def on_create(self) -> None:
|
||||
await notify_new_order(self.id)
|
||||
|
||||
async def on_delete(self) -> None:
|
||||
await notify_order_cancelled(self.id)
|
||||
|
||||
async def on_update(self, changes: dict) -> None:
|
||||
if "status" in changes:
|
||||
old = changes["status"]["old"]
|
||||
new = changes["status"]["new"]
|
||||
await notify_status_change(self.id, old, new)
|
||||
```
|
||||
|
||||
#### Field changes format
|
||||
|
||||
The `changes` dict maps each watched field that changed to `{"old": ..., "new": ...}`. Only fields that actually changed are included:
|
||||
|
||||
```python
|
||||
# status changed → {"status": {"old": "pending", "new": "shipped"}}
|
||||
# two fields changed → {"status": {...}, "assigned_to": {...}}
|
||||
```
|
||||
|
||||
!!! info "Multiple flushes in one transaction are merged: the earliest `old` and latest `new` are preserved, and `on_update` fires only once per commit."
|
||||
|
||||
!!! warning "Callbacks fire only for ORM-level changes. Rows updated via raw SQL (`UPDATE ... SET ...`) are not detected."
|
||||
|
||||
!!! warning "Callbacks fire when the **outermost active context** (savepoint or transaction) commits."
|
||||
If you create several related objects using `CrudFactory.create` and need
|
||||
callbacks to see all of them (including associations), wrap the whole
|
||||
operation in a single [`get_transaction`](db.md) or [`lock_tables`](db.md)
|
||||
block. Without it, each `create` call commits its own savepoint and
|
||||
`on_create` fires before the remaining objects exist.
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
|
||||
async with get_transaction(session):
|
||||
order = await OrderCrud.create(session, order_data)
|
||||
item = await ItemCrud.create(session, item_data)
|
||||
await session.refresh(order, attribute_names=["items"])
|
||||
order.items.append(item)
|
||||
# on_create fires here for both order and item,
|
||||
# with the full association already committed.
|
||||
```
|
||||
|
||||
## Composing mixins
|
||||
|
||||
All mixins can be combined in any order. The only constraint is that exactly one primary key must be defined — either via `UUIDMixin` or directly on the model.
|
||||
|
||||
@@ -20,50 +20,115 @@ async def get_user(user: User = UserDep) -> Response[UserSchema]:
|
||||
return Response(data=user, message="User retrieved")
|
||||
```
|
||||
|
||||
### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse)
|
||||
### Paginated response models
|
||||
|
||||
Wraps a list of items with pagination metadata and optional facet values. The `pagination` field accepts either [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination) or [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination) depending on the strategy used.
|
||||
Three classes wrap paginated list results. Pick the one that matches your endpoint's strategy:
|
||||
|
||||
#### [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination)
|
||||
| Class | `pagination` type | `pagination_type` field | Use when |
|
||||
|---|---|---|---|
|
||||
| [`OffsetPaginatedResponse[T]`](#offsetpaginatedresponset) | `OffsetPagination` | `"offset"` (fixed) | endpoint always uses offset |
|
||||
| [`CursorPaginatedResponse[T]`](#cursorpaginatedresponset) | `CursorPagination` | `"cursor"` (fixed) | endpoint always uses cursor |
|
||||
| [`PaginatedResponse[T]`](#paginatedresponset) | `OffsetPagination \| CursorPagination` | — | unified endpoint supporting both strategies |
|
||||
|
||||
Page-number based. Requires `total_count` so clients can compute the total number of pages.
|
||||
#### [`OffsetPaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPaginatedResponse)
|
||||
|
||||
!!! info "Added in `v2.3.0`"
|
||||
|
||||
Use as the return type when the endpoint always uses [`offset_paginate`](crud.md#offset-pagination). The `pagination` field is guaranteed to be an [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination) object; the response always includes a `pagination_type: "offset"` discriminator.
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.schemas import PaginatedResponse, OffsetPagination
|
||||
from fastapi_toolsets.schemas import OffsetPaginatedResponse
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users() -> PaginatedResponse[UserSchema]:
|
||||
return PaginatedResponse(
|
||||
data=users,
|
||||
pagination=OffsetPagination(
|
||||
total_count=100,
|
||||
items_per_page=10,
|
||||
page=1,
|
||||
has_more=True,
|
||||
),
|
||||
async def list_users(
|
||||
page: int = 1,
|
||||
items_per_page: int = 20,
|
||||
) -> OffsetPaginatedResponse[UserSchema]:
|
||||
return await UserCrud.offset_paginate(
|
||||
session, page=page, items_per_page=items_per_page, schema=UserSchema
|
||||
)
|
||||
```
|
||||
|
||||
#### [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination)
|
||||
**Response shape:**
|
||||
|
||||
Cursor based. Efficient for large or frequently updated datasets where offset pagination is impractical. Provides opaque `next_cursor` / `prev_cursor` tokens; no total count is exposed.
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"pagination_type": "offset",
|
||||
"data": ["..."],
|
||||
"pagination": {
|
||||
"total_count": 100,
|
||||
"page": 1,
|
||||
"items_per_page": 20,
|
||||
"has_more": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### [`CursorPaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPaginatedResponse)
|
||||
|
||||
!!! info "Added in `v2.3.0`"
|
||||
|
||||
Use as the return type when the endpoint always uses [`cursor_paginate`](crud.md#cursor-pagination). The `pagination` field is guaranteed to be a [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination) object; the response always includes a `pagination_type: "cursor"` discriminator.
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.schemas import PaginatedResponse, CursorPagination
|
||||
from fastapi_toolsets.schemas import CursorPaginatedResponse
|
||||
|
||||
@router.get("/events")
|
||||
async def list_events() -> PaginatedResponse[EventSchema]:
|
||||
return PaginatedResponse(
|
||||
data=events,
|
||||
pagination=CursorPagination(
|
||||
next_cursor="eyJpZCI6IDQyfQ==",
|
||||
prev_cursor=None,
|
||||
items_per_page=20,
|
||||
has_more=True,
|
||||
),
|
||||
async def list_events(
|
||||
cursor: str | None = None,
|
||||
items_per_page: int = 20,
|
||||
) -> CursorPaginatedResponse[EventSchema]:
|
||||
return await EventCrud.cursor_paginate(
|
||||
session, cursor=cursor, items_per_page=items_per_page, schema=EventSchema
|
||||
)
|
||||
```
|
||||
|
||||
**Response shape:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"pagination_type": "cursor",
|
||||
"data": ["..."],
|
||||
"pagination": {
|
||||
"next_cursor": "eyJpZCI6IDQyfQ==",
|
||||
"prev_cursor": null,
|
||||
"items_per_page": 20,
|
||||
"has_more": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse)
|
||||
|
||||
Return type for endpoints that support **both** pagination strategies via a `pagination_type` query parameter (using [`paginate()`](crud.md#unified-paginate--both-strategies-on-one-endpoint)).
|
||||
|
||||
When used as a return annotation, `PaginatedResponse[T]` automatically expands to `Annotated[Union[CursorPaginatedResponse[T], OffsetPaginatedResponse[T]], Field(discriminator="pagination_type")]`, so FastAPI emits a proper `oneOf` + discriminator in the OpenAPI schema with no extra boilerplate:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.crud import PaginationType
|
||||
from fastapi_toolsets.schemas import PaginatedResponse
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
pagination_type: PaginationType = PaginationType.OFFSET,
|
||||
page: int = 1,
|
||||
cursor: str | None = None,
|
||||
items_per_page: int = 20,
|
||||
) -> PaginatedResponse[UserSchema]:
|
||||
return await UserCrud.paginate(
|
||||
session,
|
||||
pagination_type=pagination_type,
|
||||
page=page,
|
||||
cursor=cursor,
|
||||
items_per_page=items_per_page,
|
||||
schema=UserSchema,
|
||||
)
|
||||
```
|
||||
|
||||
#### Pagination metadata models
|
||||
|
||||
The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#filter-attributes-facets)). It is `None` by default and can be hidden from API responses with `response_model_exclude_none=True`.
|
||||
|
||||
### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse)
|
||||
|
||||
@@ -6,17 +6,29 @@ You can import them directly from `fastapi_toolsets.models`:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.models import (
|
||||
ModelEvent,
|
||||
UUIDMixin,
|
||||
UUIDv7Mixin,
|
||||
CreatedAtMixin,
|
||||
UpdatedAtMixin,
|
||||
TimestampMixin,
|
||||
WatchedFieldsMixin,
|
||||
watch,
|
||||
)
|
||||
```
|
||||
|
||||
## ::: fastapi_toolsets.models.ModelEvent
|
||||
|
||||
## ::: fastapi_toolsets.models.UUIDMixin
|
||||
|
||||
## ::: fastapi_toolsets.models.UUIDv7Mixin
|
||||
|
||||
## ::: fastapi_toolsets.models.CreatedAtMixin
|
||||
|
||||
## ::: fastapi_toolsets.models.UpdatedAtMixin
|
||||
|
||||
## ::: fastapi_toolsets.models.TimestampMixin
|
||||
|
||||
## ::: fastapi_toolsets.models.WatchedFieldsMixin
|
||||
|
||||
## ::: fastapi_toolsets.models.watch
|
||||
|
||||
@@ -14,7 +14,10 @@ from fastapi_toolsets.schemas import (
|
||||
ErrorResponse,
|
||||
OffsetPagination,
|
||||
CursorPagination,
|
||||
PaginationType,
|
||||
PaginatedResponse,
|
||||
OffsetPaginatedResponse,
|
||||
CursorPaginatedResponse,
|
||||
)
|
||||
```
|
||||
|
||||
@@ -34,4 +37,10 @@ from fastapi_toolsets.schemas import (
|
||||
|
||||
## ::: fastapi_toolsets.schemas.CursorPagination
|
||||
|
||||
## ::: fastapi_toolsets.schemas.PaginationType
|
||||
|
||||
## ::: fastapi_toolsets.schemas.PaginatedResponse
|
||||
|
||||
## ::: fastapi_toolsets.schemas.OffsetPaginatedResponse
|
||||
|
||||
## ::: fastapi_toolsets.schemas.CursorPaginatedResponse
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy import Boolean, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
from fastapi_toolsets.models import CreatedAtMixin
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -18,13 +19,10 @@ class Category(Base):
|
||||
articles: Mapped[list["Article"]] = relationship(back_populates="category")
|
||||
|
||||
|
||||
class Article(Base):
|
||||
class Article(Base, CreatedAtMixin):
|
||||
__tablename__ = "articles"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from fastapi_toolsets.crud import OrderByClause
|
||||
from fastapi_toolsets.schemas import PaginatedResponse
|
||||
from fastapi_toolsets.schemas import (
|
||||
CursorPaginatedResponse,
|
||||
OffsetPaginatedResponse,
|
||||
PaginatedResponse,
|
||||
)
|
||||
|
||||
from .crud import ArticleCrud
|
||||
from .db import SessionDep
|
||||
@@ -16,19 +20,20 @@ router = APIRouter(prefix="/articles")
|
||||
@router.get("/offset")
|
||||
async def list_articles_offset(
|
||||
session: SessionDep,
|
||||
params: Annotated[
|
||||
dict,
|
||||
Depends(ArticleCrud.offset_params(default_page_size=20, max_page_size=100)),
|
||||
],
|
||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
||||
order_by: Annotated[
|
||||
OrderByClause | None,
|
||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||
],
|
||||
page: int = Query(1, ge=1),
|
||||
items_per_page: int = Query(20, ge=1, le=100),
|
||||
search: str | None = None,
|
||||
) -> PaginatedResponse[ArticleRead]:
|
||||
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||
return await ArticleCrud.offset_paginate(
|
||||
session=session,
|
||||
page=page,
|
||||
items_per_page=items_per_page,
|
||||
**params,
|
||||
search=search,
|
||||
filter_by=filter_by or None,
|
||||
order_by=order_by,
|
||||
@@ -39,19 +44,44 @@ async def list_articles_offset(
|
||||
@router.get("/cursor")
|
||||
async def list_articles_cursor(
|
||||
session: SessionDep,
|
||||
params: Annotated[
|
||||
dict,
|
||||
Depends(ArticleCrud.cursor_params(default_page_size=20, max_page_size=100)),
|
||||
],
|
||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
||||
order_by: Annotated[
|
||||
OrderByClause | None,
|
||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||
],
|
||||
cursor: str | None = None,
|
||||
items_per_page: int = Query(20, ge=1, le=100),
|
||||
search: str | None = None,
|
||||
) -> PaginatedResponse[ArticleRead]:
|
||||
) -> CursorPaginatedResponse[ArticleRead]:
|
||||
return await ArticleCrud.cursor_paginate(
|
||||
session=session,
|
||||
cursor=cursor,
|
||||
items_per_page=items_per_page,
|
||||
**params,
|
||||
search=search,
|
||||
filter_by=filter_by or None,
|
||||
order_by=order_by,
|
||||
schema=ArticleRead,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_articles(
|
||||
session: SessionDep,
|
||||
params: Annotated[
|
||||
dict,
|
||||
Depends(ArticleCrud.paginate_params(default_page_size=20, max_page_size=100)),
|
||||
],
|
||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
||||
order_by: Annotated[
|
||||
OrderByClause | None,
|
||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||
],
|
||||
search: str | None = None,
|
||||
) -> PaginatedResponse[ArticleRead]:
|
||||
return await ArticleCrud.paginate(
|
||||
session,
|
||||
**params,
|
||||
search=search,
|
||||
filter_by=filter_by or None,
|
||||
order_by=order_by,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "fastapi-toolsets"
|
||||
version = "2.2.0"
|
||||
version = "2.4.3"
|
||||
description = "Production-ready utilities for FastAPI applications"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -67,6 +67,7 @@ dev = [
|
||||
{include-group = "tests"},
|
||||
{include-group = "docs"},
|
||||
"fastapi-toolsets[all]",
|
||||
"prek>=0.3.8",
|
||||
"ruff>=0.1.0",
|
||||
"ty>=0.0.1a0",
|
||||
]
|
||||
@@ -84,7 +85,7 @@ docs = [
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.10,<0.11.0"]
|
||||
requires = ["uv_build>=0.10,<0.12.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
@@ -21,4 +21,4 @@ Example usage:
|
||||
return Response(data={"user": user.username}, message="Success")
|
||||
"""
|
||||
|
||||
__version__ = "2.2.0"
|
||||
__version__ = "2.4.3"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Generic async CRUD operations for SQLAlchemy models."""
|
||||
|
||||
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
||||
from ..schemas import PaginationType
|
||||
from ..types import (
|
||||
FacetFieldType,
|
||||
JoinType,
|
||||
@@ -8,10 +9,11 @@ from ..types import (
|
||||
OrderByClause,
|
||||
SearchFieldType,
|
||||
)
|
||||
from .factory import CrudFactory
|
||||
from .factory import AsyncCrud, CrudFactory
|
||||
from .search import SearchConfig, get_searchable_fields
|
||||
|
||||
__all__ = [
|
||||
"AsyncCrud",
|
||||
"CrudFactory",
|
||||
"FacetFieldType",
|
||||
"get_searchable_fields",
|
||||
@@ -20,6 +22,7 @@ __all__ = [
|
||||
"M2MFieldType",
|
||||
"NoSearchableFieldsError",
|
||||
"OrderByClause",
|
||||
"PaginationType",
|
||||
"SearchConfig",
|
||||
"SearchFieldType",
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@ import uuid as uuid_module
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any, ClassVar, Generic, Literal, Self, cast, overload
|
||||
|
||||
from fastapi import Query
|
||||
@@ -23,7 +24,14 @@ from sqlalchemy.sql.roles import WhereHavingRole
|
||||
|
||||
from ..db import get_transaction
|
||||
from ..exceptions import InvalidOrderFieldError, NotFoundError
|
||||
from ..schemas import CursorPagination, OffsetPagination, PaginatedResponse, Response
|
||||
from ..schemas import (
|
||||
CursorPaginatedResponse,
|
||||
CursorPagination,
|
||||
OffsetPaginatedResponse,
|
||||
OffsetPagination,
|
||||
PaginationType,
|
||||
Response,
|
||||
)
|
||||
from ..types import (
|
||||
FacetFieldType,
|
||||
JoinType,
|
||||
@@ -42,14 +50,58 @@ from .search import (
|
||||
)
|
||||
|
||||
|
||||
def _encode_cursor(value: Any) -> str:
|
||||
"""Encode cursor column value as an base64 string."""
|
||||
return base64.b64encode(json.dumps(str(value)).encode()).decode()
|
||||
class _CursorDirection(str, Enum):
|
||||
NEXT = "next"
|
||||
PREV = "prev"
|
||||
|
||||
|
||||
def _decode_cursor(cursor: str) -> str:
|
||||
"""Decode cursor base64 string."""
|
||||
return json.loads(base64.b64decode(cursor.encode()).decode())
|
||||
def _encode_cursor(
|
||||
value: Any, *, direction: _CursorDirection = _CursorDirection.NEXT
|
||||
) -> str:
|
||||
"""Encode a cursor column value and navigation direction as a URL-safe base64 string."""
|
||||
return (
|
||||
base64.urlsafe_b64encode(
|
||||
json.dumps({"val": str(value), "dir": direction}).encode()
|
||||
)
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
|
||||
def _decode_cursor(cursor: str) -> tuple[str, _CursorDirection]:
|
||||
"""Decode a URL-safe base64 cursor string into ``(raw_value, direction)``."""
|
||||
padded = cursor + "=" * (-len(cursor) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode())
|
||||
return payload["val"], _CursorDirection(payload["dir"])
|
||||
|
||||
|
||||
def _page_size_query(default: int, max_size: int) -> int:
|
||||
"""Return a FastAPI ``Query`` for the ``items_per_page`` parameter."""
|
||||
return Query(
|
||||
default,
|
||||
ge=1,
|
||||
le=max_size,
|
||||
description=f"Number of items per page (max {max_size})",
|
||||
)
|
||||
|
||||
|
||||
def _parse_cursor_value(raw_val: str, col_type: Any) -> Any:
|
||||
"""Parse a raw cursor string value back into the appropriate Python type."""
|
||||
if isinstance(col_type, Integer):
|
||||
return int(raw_val)
|
||||
if isinstance(col_type, Uuid):
|
||||
return uuid_module.UUID(raw_val)
|
||||
if isinstance(col_type, DateTime):
|
||||
return datetime.fromisoformat(raw_val)
|
||||
if isinstance(col_type, Date):
|
||||
return date.fromisoformat(raw_val)
|
||||
if isinstance(col_type, (Float, Numeric)):
|
||||
return Decimal(raw_val)
|
||||
raise ValueError(
|
||||
f"Unsupported cursor column type: {type(col_type).__name__!r}. "
|
||||
"Supported types: Integer, BigInteger, SmallInteger, Uuid, "
|
||||
"DateTime, Date, Float, Numeric."
|
||||
)
|
||||
|
||||
|
||||
def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
|
||||
@@ -79,13 +131,34 @@ class AsyncCrud(Generic[ModelType]):
|
||||
facet_fields: ClassVar[Sequence[FacetFieldType] | None] = None
|
||||
order_fields: ClassVar[Sequence[QueryableAttribute[Any]] | None] = None
|
||||
m2m_fields: ClassVar[M2MFieldType | None] = None
|
||||
default_load_options: ClassVar[list[ExecutableOption] | None] = None
|
||||
default_load_options: ClassVar[Sequence[ExecutableOption] | None] = None
|
||||
cursor_column: ClassVar[Any | None] = None
|
||||
|
||||
@classmethod
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
super().__init_subclass__(**kwargs)
|
||||
if "model" not in cls.__dict__:
|
||||
return
|
||||
model: type[DeclarativeBase] = cls.__dict__["model"]
|
||||
pk_key = model.__mapper__.primary_key[0].key
|
||||
assert pk_key is not None
|
||||
pk_col = getattr(model, pk_key)
|
||||
|
||||
raw_fields: Sequence[SearchFieldType] | None = cls.__dict__.get(
|
||||
"searchable_fields", None
|
||||
)
|
||||
if raw_fields is None:
|
||||
cls.searchable_fields = [pk_col]
|
||||
else:
|
||||
if not any(
|
||||
not isinstance(f, tuple) and f.key == pk_key for f in raw_fields
|
||||
):
|
||||
cls.searchable_fields = [pk_col, *raw_fields]
|
||||
|
||||
@classmethod
|
||||
def _resolve_load_options(
|
||||
cls, load_options: list[ExecutableOption] | None
|
||||
) -> list[ExecutableOption] | None:
|
||||
cls, load_options: Sequence[ExecutableOption] | None
|
||||
) -> Sequence[ExecutableOption] | None:
|
||||
"""Return load_options if provided, else fall back to default_load_options."""
|
||||
if load_options is not None:
|
||||
return load_options
|
||||
@@ -196,6 +269,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
) -> Callable[..., Awaitable[dict[str, list[str]]]]:
|
||||
"""Return a FastAPI dependency that collects facet filter values from query parameters.
|
||||
|
||||
Args:
|
||||
facet_fields: Override the facet fields for this dependency. Falls back to the
|
||||
class-level ``facet_fields`` if not provided.
|
||||
@@ -221,7 +295,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
return {k: v for k, v in kwargs.items() if v is not None}
|
||||
|
||||
dependency.__name__ = f"{cls.model.__name__}FilterParams"
|
||||
dependency.__signature__ = inspect.Signature( # type: ignore[attr-defined]
|
||||
dependency.__signature__ = inspect.Signature( # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
|
||||
parameters=[
|
||||
inspect.Parameter(
|
||||
k,
|
||||
@@ -235,6 +309,121 @@ class AsyncCrud(Generic[ModelType]):
|
||||
|
||||
return dependency
|
||||
|
||||
@classmethod
|
||||
def offset_params(
|
||||
cls: type[Self],
|
||||
*,
|
||||
default_page_size: int = 20,
|
||||
max_page_size: int = 100,
|
||||
include_total: bool = True,
|
||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||
"""Return a FastAPI dependency that collects offset pagination params from query params.
|
||||
|
||||
Args:
|
||||
default_page_size: Default value for the ``items_per_page`` query parameter.
|
||||
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
||||
``le`` on the ``Query``).
|
||||
include_total: Server-side flag forwarded as-is to ``include_total`` in
|
||||
:meth:`offset_paginate`. Not exposed as a query parameter.
|
||||
|
||||
Returns:
|
||||
An async dependency that resolves to a dict with ``page``,
|
||||
``items_per_page``, and ``include_total`` keys, ready to be
|
||||
unpacked into :meth:`offset_paginate`.
|
||||
"""
|
||||
|
||||
async def dependency(
|
||||
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"page": page,
|
||||
"items_per_page": items_per_page,
|
||||
"include_total": include_total,
|
||||
}
|
||||
|
||||
dependency.__name__ = f"{cls.model.__name__}OffsetParams"
|
||||
return dependency
|
||||
|
||||
@classmethod
|
||||
def cursor_params(
|
||||
cls: type[Self],
|
||||
*,
|
||||
default_page_size: int = 20,
|
||||
max_page_size: int = 100,
|
||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||
"""Return a FastAPI dependency that collects cursor pagination params from query params.
|
||||
|
||||
Args:
|
||||
default_page_size: Default value for the ``items_per_page`` query parameter.
|
||||
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
||||
``le`` on the ``Query``).
|
||||
|
||||
Returns:
|
||||
An async dependency that resolves to a dict with ``cursor`` and
|
||||
``items_per_page`` keys, ready to be unpacked into
|
||||
:meth:`cursor_paginate`.
|
||||
"""
|
||||
|
||||
async def dependency(
|
||||
cursor: str | None = Query(
|
||||
None, description="Cursor token from a previous response"
|
||||
),
|
||||
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
||||
) -> dict[str, Any]:
|
||||
return {"cursor": cursor, "items_per_page": items_per_page}
|
||||
|
||||
dependency.__name__ = f"{cls.model.__name__}CursorParams"
|
||||
return dependency
|
||||
|
||||
@classmethod
|
||||
def paginate_params(
|
||||
cls: type[Self],
|
||||
*,
|
||||
default_page_size: int = 20,
|
||||
max_page_size: int = 100,
|
||||
default_pagination_type: PaginationType = PaginationType.OFFSET,
|
||||
include_total: bool = True,
|
||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||
"""Return a FastAPI dependency that collects all pagination params from query params.
|
||||
|
||||
Args:
|
||||
default_page_size: Default value for the ``items_per_page`` query parameter.
|
||||
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
||||
``le`` on the ``Query``).
|
||||
default_pagination_type: Default pagination strategy.
|
||||
include_total: Server-side flag forwarded as-is to ``include_total`` in
|
||||
:meth:`paginate`. Not exposed as a query parameter.
|
||||
|
||||
Returns:
|
||||
An async dependency that resolves to a dict with ``pagination_type``,
|
||||
``page``, ``cursor``, ``items_per_page``, and ``include_total`` keys,
|
||||
ready to be unpacked into :meth:`paginate`.
|
||||
"""
|
||||
|
||||
async def dependency(
|
||||
pagination_type: PaginationType = Query(
|
||||
default_pagination_type, description="Pagination strategy"
|
||||
),
|
||||
page: int = Query(
|
||||
1, ge=1, description="Page number (1-indexed, offset only)"
|
||||
),
|
||||
cursor: str | None = Query(
|
||||
None, description="Cursor token from a previous response (cursor only)"
|
||||
),
|
||||
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"pagination_type": pagination_type,
|
||||
"page": page,
|
||||
"cursor": cursor,
|
||||
"items_per_page": items_per_page,
|
||||
"include_total": include_total,
|
||||
}
|
||||
|
||||
dependency.__name__ = f"{cls.model.__name__}PaginateParams"
|
||||
return dependency
|
||||
|
||||
@classmethod
|
||||
def order_params(
|
||||
cls: type[Self],
|
||||
@@ -360,7 +549,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: type[SchemaType],
|
||||
) -> Response[SchemaType]: ...
|
||||
|
||||
@@ -374,7 +563,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: None = ...,
|
||||
) -> ModelType: ...
|
||||
|
||||
@@ -387,7 +576,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: type[BaseModel] | None = None,
|
||||
) -> ModelType | Response[Any]:
|
||||
"""Get exactly one record. Raises NotFoundError if not found.
|
||||
@@ -432,7 +621,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: type[SchemaType],
|
||||
) -> Response[SchemaType] | None: ...
|
||||
|
||||
@@ -446,7 +635,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: None = ...,
|
||||
) -> ModelType | None: ...
|
||||
|
||||
@@ -459,7 +648,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: type[BaseModel] | None = None,
|
||||
) -> ModelType | Response[Any] | None:
|
||||
"""Get exactly one record, or ``None`` if not found.
|
||||
@@ -511,7 +700,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: type[SchemaType],
|
||||
) -> Response[SchemaType] | None: ...
|
||||
|
||||
@@ -525,7 +714,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: None = ...,
|
||||
) -> ModelType | None: ...
|
||||
|
||||
@@ -538,7 +727,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: type[BaseModel] | None = None,
|
||||
) -> ModelType | Response[Any] | None:
|
||||
"""Get the first matching record, or None.
|
||||
@@ -582,7 +771,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
filters: list[Any] | None = None,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
@@ -860,16 +1049,17 @@ class AsyncCrud(Generic[ModelType]):
|
||||
filters: list[Any] | None = None,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
page: int = 1,
|
||||
items_per_page: int = 20,
|
||||
include_total: bool = True,
|
||||
search: str | SearchConfig | None = None,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||
schema: type[BaseModel],
|
||||
) -> PaginatedResponse[Any]:
|
||||
) -> OffsetPaginatedResponse[Any]:
|
||||
"""Get paginated results using offset-based pagination.
|
||||
|
||||
Args:
|
||||
@@ -881,6 +1071,8 @@ class AsyncCrud(Generic[ModelType]):
|
||||
order_by: Column or list of columns to order by
|
||||
page: Page number (1-indexed)
|
||||
items_per_page: Number of items per page
|
||||
include_total: When ``False``, skip the ``COUNT`` query;
|
||||
``pagination.total_count`` will be ``None``.
|
||||
search: Search query string or SearchConfig object
|
||||
search_fields: Fields to search in (overrides class default)
|
||||
facet_fields: Columns to compute distinct values for (overrides class default)
|
||||
@@ -925,10 +1117,10 @@ class AsyncCrud(Generic[ModelType]):
|
||||
if order_by is not None:
|
||||
q = q.order_by(order_by)
|
||||
|
||||
if include_total:
|
||||
q = q.offset(offset).limit(items_per_page)
|
||||
result = await session.execute(q)
|
||||
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
||||
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
||||
|
||||
# Count query (with same joins and filters)
|
||||
pk_col = cls.model.__mapper__.primary_key[0]
|
||||
@@ -945,19 +1137,30 @@ class AsyncCrud(Generic[ModelType]):
|
||||
count_q = count_q.where(and_(*filters))
|
||||
|
||||
count_result = await session.execute(count_q)
|
||||
total_count = count_result.scalar_one()
|
||||
total_count: int | None = count_result.scalar_one()
|
||||
has_more = page * items_per_page < total_count
|
||||
else:
|
||||
# Fetch one extra row to detect if a next page exists without COUNT
|
||||
q = q.offset(offset).limit(items_per_page + 1)
|
||||
result = await session.execute(q)
|
||||
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
||||
has_more = len(raw_items) > items_per_page
|
||||
raw_items = raw_items[:items_per_page]
|
||||
total_count = None
|
||||
|
||||
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
||||
|
||||
filter_attributes = await cls._build_filter_attributes(
|
||||
session, facet_fields, filters, search_joins
|
||||
)
|
||||
|
||||
return PaginatedResponse(
|
||||
return OffsetPaginatedResponse(
|
||||
data=items,
|
||||
pagination=OffsetPagination(
|
||||
total_count=total_count,
|
||||
items_per_page=items_per_page,
|
||||
page=page,
|
||||
has_more=page * items_per_page < total_count,
|
||||
has_more=has_more,
|
||||
),
|
||||
filter_attributes=filter_attributes,
|
||||
)
|
||||
@@ -971,7 +1174,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
filters: list[Any] | None = None,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
items_per_page: int = 20,
|
||||
search: str | SearchConfig | None = None,
|
||||
@@ -979,7 +1182,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||
schema: type[BaseModel],
|
||||
) -> PaginatedResponse[Any]:
|
||||
) -> CursorPaginatedResponse[Any]:
|
||||
"""Get paginated results using cursor-based pagination.
|
||||
|
||||
Args:
|
||||
@@ -1018,25 +1221,14 @@ class AsyncCrud(Generic[ModelType]):
|
||||
cursor_column: Any = cls.cursor_column
|
||||
cursor_col_name: str = cursor_column.key
|
||||
|
||||
direction = _CursorDirection.NEXT
|
||||
if cursor is not None:
|
||||
raw_val = _decode_cursor(cursor)
|
||||
raw_val, direction = _decode_cursor(cursor)
|
||||
col_type = cursor_column.property.columns[0].type
|
||||
if isinstance(col_type, Integer):
|
||||
cursor_val: Any = int(raw_val)
|
||||
elif isinstance(col_type, Uuid):
|
||||
cursor_val = uuid_module.UUID(raw_val)
|
||||
elif isinstance(col_type, DateTime):
|
||||
cursor_val = datetime.fromisoformat(raw_val)
|
||||
elif isinstance(col_type, Date):
|
||||
cursor_val = date.fromisoformat(raw_val)
|
||||
elif isinstance(col_type, (Float, Numeric)):
|
||||
cursor_val = Decimal(raw_val)
|
||||
cursor_val: Any = _parse_cursor_value(raw_val, col_type)
|
||||
if direction is _CursorDirection.PREV:
|
||||
filters.append(cursor_column < cursor_val)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported cursor column type: {type(col_type).__name__!r}. "
|
||||
"Supported types: Integer, BigInteger, SmallInteger, Uuid, "
|
||||
"DateTime, Date, Float, Numeric."
|
||||
)
|
||||
filters.append(cursor_column > cursor_val)
|
||||
|
||||
# Build search filters
|
||||
@@ -1064,12 +1256,15 @@ class AsyncCrud(Generic[ModelType]):
|
||||
if resolved := cls._resolve_load_options(load_options):
|
||||
q = q.options(*resolved)
|
||||
|
||||
# Cursor column is always the primary sort
|
||||
# Cursor column is always the primary sort; reverse direction for prev traversal
|
||||
if direction is _CursorDirection.PREV:
|
||||
q = q.order_by(cursor_column.desc())
|
||||
else:
|
||||
q = q.order_by(cursor_column)
|
||||
if order_by is not None:
|
||||
q = q.order_by(order_by)
|
||||
|
||||
# Fetch one extra to detect whether a next page exists
|
||||
# Fetch one extra to detect whether another page exists in this direction
|
||||
q = q.limit(items_per_page + 1)
|
||||
result = await session.execute(q)
|
||||
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
||||
@@ -1077,15 +1272,36 @@ class AsyncCrud(Generic[ModelType]):
|
||||
has_more = len(raw_items) > items_per_page
|
||||
items_page = raw_items[:items_per_page]
|
||||
|
||||
# next_cursor points past the last item on this page
|
||||
next_cursor: str | None = None
|
||||
if has_more and items_page:
|
||||
next_cursor = _encode_cursor(getattr(items_page[-1], cursor_col_name))
|
||||
# Restore ascending order when traversing backward
|
||||
if direction is _CursorDirection.PREV:
|
||||
items_page = list(reversed(items_page))
|
||||
|
||||
# prev_cursor points to the first item on this page or None when on the first page
|
||||
# next_cursor: points past the last item in ascending order
|
||||
next_cursor: str | None = None
|
||||
if direction is _CursorDirection.NEXT:
|
||||
if has_more and items_page:
|
||||
next_cursor = _encode_cursor(
|
||||
getattr(items_page[-1], cursor_col_name),
|
||||
direction=_CursorDirection.NEXT,
|
||||
)
|
||||
else:
|
||||
# Going backward: always provide a next_cursor to allow returning forward
|
||||
if items_page:
|
||||
next_cursor = _encode_cursor(
|
||||
getattr(items_page[-1], cursor_col_name),
|
||||
direction=_CursorDirection.NEXT,
|
||||
)
|
||||
|
||||
# prev_cursor: points before the first item in ascending order
|
||||
prev_cursor: str | None = None
|
||||
if cursor is not None and items_page:
|
||||
prev_cursor = _encode_cursor(getattr(items_page[0], cursor_col_name))
|
||||
if direction is _CursorDirection.NEXT and cursor is not None and items_page:
|
||||
prev_cursor = _encode_cursor(
|
||||
getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
|
||||
)
|
||||
elif direction is _CursorDirection.PREV and has_more and items_page:
|
||||
prev_cursor = _encode_cursor(
|
||||
getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
|
||||
)
|
||||
|
||||
items: list[Any] = [schema.model_validate(item) for item in items_page]
|
||||
|
||||
@@ -1093,7 +1309,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
session, facet_fields, filters, search_joins
|
||||
)
|
||||
|
||||
return PaginatedResponse(
|
||||
return CursorPaginatedResponse(
|
||||
data=items,
|
||||
pagination=CursorPagination(
|
||||
next_cursor=next_cursor,
|
||||
@@ -1104,21 +1320,169 @@ class AsyncCrud(Generic[ModelType]):
|
||||
filter_attributes=filter_attributes,
|
||||
)
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
async def paginate( # pragma: no cover
|
||||
cls: type[Self],
|
||||
session: AsyncSession,
|
||||
*,
|
||||
pagination_type: Literal[PaginationType.OFFSET],
|
||||
filters: list[Any] | None = ...,
|
||||
joins: JoinType | None = ...,
|
||||
outer_join: bool = ...,
|
||||
load_options: Sequence[ExecutableOption] | None = ...,
|
||||
order_by: OrderByClause | None = ...,
|
||||
page: int = ...,
|
||||
cursor: str | None = ...,
|
||||
items_per_page: int = ...,
|
||||
include_total: bool = ...,
|
||||
search: str | SearchConfig | None = ...,
|
||||
search_fields: Sequence[SearchFieldType] | None = ...,
|
||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||
filter_by: dict[str, Any] | BaseModel | None = ...,
|
||||
schema: type[BaseModel],
|
||||
) -> OffsetPaginatedResponse[Any]: ...
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
async def paginate( # pragma: no cover
|
||||
cls: type[Self],
|
||||
session: AsyncSession,
|
||||
*,
|
||||
pagination_type: Literal[PaginationType.CURSOR],
|
||||
filters: list[Any] | None = ...,
|
||||
joins: JoinType | None = ...,
|
||||
outer_join: bool = ...,
|
||||
load_options: Sequence[ExecutableOption] | None = ...,
|
||||
order_by: OrderByClause | None = ...,
|
||||
page: int = ...,
|
||||
cursor: str | None = ...,
|
||||
items_per_page: int = ...,
|
||||
include_total: bool = ...,
|
||||
search: str | SearchConfig | None = ...,
|
||||
search_fields: Sequence[SearchFieldType] | None = ...,
|
||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||
filter_by: dict[str, Any] | BaseModel | None = ...,
|
||||
schema: type[BaseModel],
|
||||
) -> CursorPaginatedResponse[Any]: ...
|
||||
|
||||
@classmethod
|
||||
async def paginate(
|
||||
cls: type[Self],
|
||||
session: AsyncSession,
|
||||
*,
|
||||
pagination_type: PaginationType = PaginationType.OFFSET,
|
||||
filters: list[Any] | None = None,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
page: int = 1,
|
||||
cursor: str | None = None,
|
||||
items_per_page: int = 20,
|
||||
include_total: bool = True,
|
||||
search: str | SearchConfig | None = None,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||
schema: type[BaseModel],
|
||||
) -> OffsetPaginatedResponse[Any] | CursorPaginatedResponse[Any]:
|
||||
"""Get paginated results using either offset or cursor pagination.
|
||||
|
||||
Args:
|
||||
session: DB async session.
|
||||
pagination_type: Pagination strategy. Defaults to
|
||||
``PaginationType.OFFSET``.
|
||||
filters: List of SQLAlchemy filter conditions.
|
||||
joins: List of ``(model, condition)`` tuples for joining related
|
||||
tables.
|
||||
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN.
|
||||
load_options: SQLAlchemy loader options. Falls back to
|
||||
``default_load_options`` when not provided.
|
||||
order_by: Column or expression to order results by.
|
||||
page: Page number (1-indexed). Only used when
|
||||
``pagination_type`` is ``OFFSET``.
|
||||
cursor: Cursor token from a previous
|
||||
:class:`.CursorPaginatedResponse`. Only used when
|
||||
``pagination_type`` is ``CURSOR``.
|
||||
items_per_page: Number of items per page (default 20).
|
||||
include_total: When ``False``, skip the ``COUNT`` query;
|
||||
only applies when ``pagination_type`` is ``OFFSET``.
|
||||
search: Search query string or :class:`.SearchConfig` object.
|
||||
search_fields: Fields to search in (overrides class default).
|
||||
facet_fields: Columns to compute distinct values for (overrides
|
||||
class default).
|
||||
filter_by: Dict of ``{column_key: value}`` to filter by declared
|
||||
facet fields. Keys must match the ``column.key`` of a facet
|
||||
field. Scalar → equality, list → IN clause. Raises
|
||||
:exc:`.InvalidFacetFilterError` for unknown keys.
|
||||
schema: Pydantic schema to serialize each item into.
|
||||
|
||||
Returns:
|
||||
:class:`.OffsetPaginatedResponse` when ``pagination_type`` is
|
||||
``OFFSET``, :class:`.CursorPaginatedResponse` when it is
|
||||
``CURSOR``.
|
||||
"""
|
||||
if items_per_page < 1:
|
||||
raise ValueError(f"items_per_page must be >= 1, got {items_per_page}")
|
||||
match pagination_type:
|
||||
case PaginationType.CURSOR:
|
||||
return await cls.cursor_paginate(
|
||||
session,
|
||||
cursor=cursor,
|
||||
filters=filters,
|
||||
joins=joins,
|
||||
outer_join=outer_join,
|
||||
load_options=load_options,
|
||||
order_by=order_by,
|
||||
items_per_page=items_per_page,
|
||||
search=search,
|
||||
search_fields=search_fields,
|
||||
facet_fields=facet_fields,
|
||||
filter_by=filter_by,
|
||||
schema=schema,
|
||||
)
|
||||
case PaginationType.OFFSET:
|
||||
if page < 1:
|
||||
raise ValueError(f"page must be >= 1, got {page}")
|
||||
return await cls.offset_paginate(
|
||||
session,
|
||||
filters=filters,
|
||||
joins=joins,
|
||||
outer_join=outer_join,
|
||||
load_options=load_options,
|
||||
order_by=order_by,
|
||||
page=page,
|
||||
items_per_page=items_per_page,
|
||||
include_total=include_total,
|
||||
search=search,
|
||||
search_fields=search_fields,
|
||||
facet_fields=facet_fields,
|
||||
filter_by=filter_by,
|
||||
schema=schema,
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"Unknown pagination_type: {pagination_type!r}")
|
||||
|
||||
|
||||
def CrudFactory(
|
||||
model: type[ModelType],
|
||||
*,
|
||||
base_class: type[AsyncCrud[Any]] = AsyncCrud,
|
||||
searchable_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||
m2m_fields: M2MFieldType | None = None,
|
||||
default_load_options: list[ExecutableOption] | None = None,
|
||||
default_load_options: Sequence[ExecutableOption] | None = None,
|
||||
cursor_column: Any | None = None,
|
||||
) -> type[AsyncCrud[ModelType]]:
|
||||
"""Create a CRUD class for a specific model.
|
||||
|
||||
Args:
|
||||
model: SQLAlchemy model class
|
||||
base_class: Optional base class to inherit from instead of ``AsyncCrud``.
|
||||
Use this to share custom methods across multiple CRUD classes while
|
||||
still using the factory shorthand.
|
||||
searchable_fields: Optional list of searchable fields
|
||||
facet_fields: Optional list of columns to compute distinct values for in paginated
|
||||
responses. Supports direct columns (``User.status``) and relationship tuples
|
||||
@@ -1209,11 +1573,24 @@ def CrudFactory(
|
||||
joins=[(Post, Post.user_id == User.id)],
|
||||
outer_join=True,
|
||||
)
|
||||
|
||||
# With a shared custom base class:
|
||||
from typing import Generic, TypeVar
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
T = TypeVar("T", bound=DeclarativeBase)
|
||||
|
||||
class AuditedCrud(AsyncCrud[T], Generic[T]):
|
||||
@classmethod
|
||||
async def get_active(cls, session):
|
||||
return await cls.get_multi(session, filters=[cls.model.is_active == True])
|
||||
|
||||
UserCrud = CrudFactory(User, base_class=AuditedCrud)
|
||||
```
|
||||
"""
|
||||
cls = type(
|
||||
f"Async{model.__name__}Crud",
|
||||
(AsyncCrud,),
|
||||
(base_class,),
|
||||
{
|
||||
"model": model,
|
||||
"searchable_fields": searchable_fields,
|
||||
|
||||
@@ -56,6 +56,7 @@ def create_db_dependency(
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with session_maker() as session:
|
||||
await session.connection()
|
||||
yield session
|
||||
if session.in_transaction():
|
||||
await session.commit()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Dependency factories for FastAPI routes."""
|
||||
|
||||
import inspect
|
||||
import typing
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.params import Depends as DependsClass
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .crud import CrudFactory
|
||||
@@ -13,6 +15,15 @@ from .types import ModelType, SessionDependency
|
||||
__all__ = ["BodyDependency", "PathDependency"]
|
||||
|
||||
|
||||
def _unwrap_session_dep(session_dep: SessionDependency) -> Callable[..., Any]:
|
||||
"""Extract the plain callable from ``Annotated[AsyncSession, Depends(fn)]`` if needed."""
|
||||
if typing.get_origin(session_dep) is typing.Annotated:
|
||||
for arg in typing.get_args(session_dep)[1:]:
|
||||
if isinstance(arg, DependsClass):
|
||||
return arg.dependency
|
||||
return session_dep
|
||||
|
||||
|
||||
def PathDependency(
|
||||
model: type[ModelType],
|
||||
field: Any,
|
||||
@@ -44,6 +55,7 @@ def PathDependency(
|
||||
): ...
|
||||
```
|
||||
"""
|
||||
session_callable = _unwrap_session_dep(session_dep)
|
||||
crud = CrudFactory(model)
|
||||
name = (
|
||||
param_name
|
||||
@@ -53,7 +65,7 @@ def PathDependency(
|
||||
python_type = field.type.python_type
|
||||
|
||||
async def dependency(
|
||||
session: AsyncSession = Depends(session_dep), **kwargs: Any
|
||||
session: AsyncSession = Depends(session_callable), **kwargs: Any
|
||||
) -> ModelType:
|
||||
value = kwargs[name]
|
||||
return await crud.get(session, filters=[field == value])
|
||||
@@ -70,7 +82,7 @@ def PathDependency(
|
||||
"session",
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
annotation=AsyncSession,
|
||||
default=Depends(session_dep),
|
||||
default=Depends(session_callable),
|
||||
),
|
||||
]
|
||||
),
|
||||
@@ -112,11 +124,12 @@ def BodyDependency(
|
||||
): ...
|
||||
```
|
||||
"""
|
||||
session_callable = _unwrap_session_dep(session_dep)
|
||||
crud = CrudFactory(model)
|
||||
python_type = field.type.python_type
|
||||
|
||||
async def dependency(
|
||||
session: AsyncSession = Depends(session_dep), **kwargs: Any
|
||||
session: AsyncSession = Depends(session_callable), **kwargs: Any
|
||||
) -> ModelType:
|
||||
value = kwargs[body_field]
|
||||
return await crud.get(session, filters=[field == value])
|
||||
@@ -133,7 +146,7 @@ def BodyDependency(
|
||||
"session",
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
annotation=AsyncSession,
|
||||
default=Depends(session_dep),
|
||||
default=Depends(session_callable),
|
||||
),
|
||||
]
|
||||
),
|
||||
|
||||
@@ -30,7 +30,7 @@ def init_exceptions_handlers(app: FastAPI) -> FastAPI:
|
||||
"""
|
||||
_register_exception_handlers(app)
|
||||
_original_openapi = app.openapi
|
||||
app.openapi = lambda: _patched_openapi(app, _original_openapi) # type: ignore[method-assign]
|
||||
app.openapi = lambda: _patched_openapi(app, _original_openapi) # type: ignore[method-assign] # ty:ignore[invalid-assignment]
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
@@ -12,6 +13,13 @@ from .enum import Context
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
def _normalize_contexts(
|
||||
contexts: list[str | Enum] | tuple[str | Enum, ...],
|
||||
) -> list[str]:
|
||||
"""Convert a sequence of any Enum subclass and/or plain strings to a list of strings."""
|
||||
return [c.value if isinstance(c, Enum) else c for c in contexts]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fixture:
|
||||
"""A fixture definition with metadata."""
|
||||
@@ -50,17 +58,42 @@ class FixtureRegistry:
|
||||
Post(id=1, title="Test", user_id=1),
|
||||
]
|
||||
```
|
||||
|
||||
Fixtures with the same name may be registered for **different** contexts.
|
||||
When multiple contexts are loaded together, their instances are merged:
|
||||
|
||||
```python
|
||||
@fixtures.register(contexts=[Context.BASE])
|
||||
def users():
|
||||
return [User(id=1, username="admin")]
|
||||
|
||||
@fixtures.register(contexts=[Context.TESTING])
|
||||
def users():
|
||||
return [User(id=2, username="tester")]
|
||||
# load_fixtures_by_context(..., Context.BASE, Context.TESTING)
|
||||
# → loads both User(admin) and User(tester) under the "users" name
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
contexts: list[str | Context] | None = None,
|
||||
contexts: list[str | Enum] | None = None,
|
||||
) -> None:
|
||||
self._fixtures: dict[str, Fixture] = {}
|
||||
self._fixtures: dict[str, list[Fixture]] = {}
|
||||
self._default_contexts: list[str] | None = (
|
||||
[c.value if isinstance(c, Context) else c for c in contexts]
|
||||
if contexts
|
||||
else None
|
||||
_normalize_contexts(contexts) if contexts else None
|
||||
)
|
||||
|
||||
def _validate_no_context_overlap(self, name: str, new_contexts: list[str]) -> None:
|
||||
"""Raise ``ValueError`` if any existing variant for *name* overlaps."""
|
||||
existing_variants = self._fixtures.get(name, [])
|
||||
new_set = set(new_contexts)
|
||||
for variant in existing_variants:
|
||||
if set(variant.contexts) & new_set:
|
||||
raise ValueError(
|
||||
f"Fixture '{name}' already exists in the current registry "
|
||||
f"with overlapping contexts. Use distinct context sets for "
|
||||
f"each variant of the same fixture name."
|
||||
)
|
||||
|
||||
def register(
|
||||
@@ -69,7 +102,7 @@ class FixtureRegistry:
|
||||
*,
|
||||
name: str | None = None,
|
||||
depends_on: list[str] | None = None,
|
||||
contexts: list[str | Context] | None = None,
|
||||
contexts: list[str | Enum] | None = None,
|
||||
) -> Callable[..., Any]:
|
||||
"""Register a fixture function.
|
||||
|
||||
@@ -79,7 +112,8 @@ class FixtureRegistry:
|
||||
func: Fixture function returning list of model instances
|
||||
name: Fixture name (defaults to function name)
|
||||
depends_on: List of fixture names this depends on
|
||||
contexts: List of contexts this fixture belongs to
|
||||
contexts: List of contexts this fixture belongs to. Both
|
||||
:class:`Context` enum values and plain strings are accepted.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -90,7 +124,6 @@ class FixtureRegistry:
|
||||
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
|
||||
def test_users():
|
||||
return [User(id=1, username="test", role_id=1)]
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
@@ -98,20 +131,21 @@ class FixtureRegistry:
|
||||
) -> Callable[[], Sequence[DeclarativeBase]]:
|
||||
fixture_name = name or cast(Any, fn).__name__
|
||||
if contexts is not None:
|
||||
fixture_contexts = [
|
||||
c.value if isinstance(c, Context) else c for c in contexts
|
||||
]
|
||||
fixture_contexts = _normalize_contexts(contexts)
|
||||
elif self._default_contexts is not None:
|
||||
fixture_contexts = self._default_contexts
|
||||
else:
|
||||
fixture_contexts = [Context.BASE.value]
|
||||
|
||||
self._fixtures[fixture_name] = Fixture(
|
||||
self._validate_no_context_overlap(fixture_name, fixture_contexts)
|
||||
self._fixtures.setdefault(fixture_name, []).append(
|
||||
Fixture(
|
||||
name=fixture_name,
|
||||
func=fn,
|
||||
depends_on=depends_on or [],
|
||||
contexts=fixture_contexts,
|
||||
)
|
||||
)
|
||||
return fn
|
||||
|
||||
if func is not None:
|
||||
@@ -121,11 +155,14 @@ class FixtureRegistry:
|
||||
def include_registry(self, registry: "FixtureRegistry") -> None:
|
||||
"""Include another `FixtureRegistry` in the same current `FixtureRegistry`.
|
||||
|
||||
Fixtures with the same name are allowed as long as their context sets
|
||||
do not overlap. Conflicting contexts raise :class:`ValueError`.
|
||||
|
||||
Args:
|
||||
registry: The `FixtureRegistry` to include
|
||||
|
||||
Raises:
|
||||
ValueError: If a fixture name already exists in the current registry
|
||||
ValueError: If a fixture name already exists with overlapping contexts
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -139,31 +176,73 @@ class FixtureRegistry:
|
||||
registry.include_registry(registry=dev_registry)
|
||||
```
|
||||
"""
|
||||
for name, fixture in registry._fixtures.items():
|
||||
if name in self._fixtures:
|
||||
raise ValueError(
|
||||
f"Fixture '{name}' already exists in the current registry"
|
||||
)
|
||||
self._fixtures[name] = fixture
|
||||
for name, variants in registry._fixtures.items():
|
||||
for fixture in variants:
|
||||
self._validate_no_context_overlap(name, fixture.contexts)
|
||||
self._fixtures.setdefault(name, []).append(fixture)
|
||||
|
||||
def get(self, name: str) -> Fixture:
|
||||
"""Get a fixture by name."""
|
||||
"""Get a fixture by name.
|
||||
|
||||
Raises:
|
||||
KeyError: If no fixture with *name* is registered.
|
||||
ValueError: If the fixture has multiple context variants — use
|
||||
:meth:`get_variants` in that case.
|
||||
"""
|
||||
if name not in self._fixtures:
|
||||
raise KeyError(f"Fixture '{name}' not found")
|
||||
return self._fixtures[name]
|
||||
variants = self._fixtures[name]
|
||||
if len(variants) > 1:
|
||||
raise ValueError(
|
||||
f"Fixture '{name}' has {len(variants)} context variants. "
|
||||
f"Use get_variants('{name}') to retrieve them."
|
||||
)
|
||||
return variants[0]
|
||||
|
||||
def get_variants(self, name: str, *contexts: str | Enum) -> list[Fixture]:
|
||||
"""Return all registered variants for *name*, optionally filtered by context.
|
||||
|
||||
Args:
|
||||
name: Fixture name.
|
||||
*contexts: If given, only return variants whose context set
|
||||
intersects with these values. Both :class:`Context` enum
|
||||
values and plain strings are accepted.
|
||||
|
||||
Returns:
|
||||
List of matching :class:`Fixture` objects (may be empty when a
|
||||
context filter is applied and nothing matches).
|
||||
|
||||
Raises:
|
||||
KeyError: If no fixture with *name* is registered.
|
||||
"""
|
||||
if name not in self._fixtures:
|
||||
raise KeyError(f"Fixture '{name}' not found")
|
||||
variants = self._fixtures[name]
|
||||
if not contexts:
|
||||
return list(variants)
|
||||
context_values = set(_normalize_contexts(contexts))
|
||||
return [v for v in variants if set(v.contexts) & context_values]
|
||||
|
||||
def get_all(self) -> list[Fixture]:
|
||||
"""Get all registered fixtures."""
|
||||
return list(self._fixtures.values())
|
||||
"""Get all registered fixtures (all variants of all names)."""
|
||||
return [f for variants in self._fixtures.values() for f in variants]
|
||||
|
||||
def get_by_context(self, *contexts: str | Context) -> list[Fixture]:
|
||||
def get_by_context(self, *contexts: str | Enum) -> list[Fixture]:
|
||||
"""Get fixtures for specific contexts."""
|
||||
context_values = {c.value if isinstance(c, Context) else c for c in contexts}
|
||||
return [f for f in self._fixtures.values() if set(f.contexts) & context_values]
|
||||
context_values = set(_normalize_contexts(contexts))
|
||||
return [
|
||||
f
|
||||
for variants in self._fixtures.values()
|
||||
for f in variants
|
||||
if set(f.contexts) & context_values
|
||||
]
|
||||
|
||||
def resolve_dependencies(self, *names: str) -> list[str]:
|
||||
"""Resolve fixture dependencies in topological order.
|
||||
|
||||
When a fixture name has multiple context variants, the union of all
|
||||
variants' ``depends_on`` lists is used.
|
||||
|
||||
Args:
|
||||
*names: Fixture names to resolve
|
||||
|
||||
@@ -185,9 +264,20 @@ class FixtureRegistry:
|
||||
raise ValueError(f"Circular dependency detected: {name}")
|
||||
|
||||
visiting.add(name)
|
||||
fixture = self.get(name)
|
||||
variants = self._fixtures.get(name)
|
||||
if variants is None:
|
||||
raise KeyError(f"Fixture '{name}' not found")
|
||||
|
||||
for dep in fixture.depends_on:
|
||||
# Union of depends_on across all variants, preserving first-seen order.
|
||||
seen_deps: set[str] = set()
|
||||
all_deps: list[str] = []
|
||||
for variant in variants:
|
||||
for dep in variant.depends_on:
|
||||
if dep not in seen_deps:
|
||||
all_deps.append(dep)
|
||||
seen_deps.add(dep)
|
||||
|
||||
for dep in all_deps:
|
||||
visit(dep)
|
||||
|
||||
visiting.remove(name)
|
||||
@@ -199,7 +289,7 @@ class FixtureRegistry:
|
||||
|
||||
return resolved
|
||||
|
||||
def resolve_context_dependencies(self, *contexts: str | Context) -> list[str]:
|
||||
def resolve_context_dependencies(self, *contexts: str | Enum) -> list[str]:
|
||||
"""Resolve all fixtures for contexts with dependencies.
|
||||
|
||||
Args:
|
||||
@@ -209,7 +299,9 @@ class FixtureRegistry:
|
||||
List of fixture names in load order
|
||||
"""
|
||||
context_fixtures = self.get_by_context(*contexts)
|
||||
names = [f.name for f in context_fixtures]
|
||||
# Deduplicate names while preserving first-seen order (a name can
|
||||
# appear multiple times if it has variants in different contexts).
|
||||
names = list(dict.fromkeys(f.name for f in context_fixtures))
|
||||
|
||||
all_deps: set[str] = set()
|
||||
for name in names:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Fixture loading utilities for database seeding."""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -10,7 +11,7 @@ from ..db import get_transaction
|
||||
from ..logger import get_logger
|
||||
from ..types import ModelType
|
||||
from .enum import LoadStrategy
|
||||
from .registry import Context, FixtureRegistry
|
||||
from .registry import FixtureRegistry, _normalize_contexts
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
@@ -20,13 +21,35 @@ async def _load_ordered(
|
||||
registry: FixtureRegistry,
|
||||
ordered_names: list[str],
|
||||
strategy: LoadStrategy,
|
||||
contexts: tuple[str, ...] | None = None,
|
||||
) -> dict[str, list[DeclarativeBase]]:
|
||||
"""Load fixtures in order."""
|
||||
"""Load fixtures in order.
|
||||
|
||||
When *contexts* is provided only variants whose context set intersects with
|
||||
*contexts* are called for each name; their instances are concatenated.
|
||||
When *contexts* is ``None`` all variants of each name are loaded.
|
||||
"""
|
||||
results: dict[str, list[DeclarativeBase]] = {}
|
||||
|
||||
for name in ordered_names:
|
||||
fixture = registry.get(name)
|
||||
instances = list(fixture.func())
|
||||
variants = (
|
||||
registry.get_variants(name, *contexts)
|
||||
if contexts is not None
|
||||
else registry.get_variants(name)
|
||||
)
|
||||
|
||||
# Cross-context dependency fallback: if we're loading by context but
|
||||
# no variant matches (e.g. a "base"-only fixture required by a
|
||||
# "testing" fixture), load all available variants so the dependency
|
||||
# is satisfied.
|
||||
if contexts is not None and not variants:
|
||||
variants = registry.get_variants(name)
|
||||
|
||||
if not variants:
|
||||
results[name] = []
|
||||
continue
|
||||
|
||||
instances = [inst for v in variants for inst in v.func()]
|
||||
|
||||
if not instances:
|
||||
results[name] = []
|
||||
@@ -109,6 +132,8 @@ async def load_fixtures(
|
||||
) -> dict[str, list[DeclarativeBase]]:
|
||||
"""Load specific fixtures by name with dependencies.
|
||||
|
||||
All context variants of each requested fixture are loaded and merged.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
registry: Fixture registry
|
||||
@@ -125,19 +150,27 @@ async def load_fixtures(
|
||||
async def load_fixtures_by_context(
|
||||
session: AsyncSession,
|
||||
registry: FixtureRegistry,
|
||||
*contexts: str | Context,
|
||||
*contexts: str | Enum,
|
||||
strategy: LoadStrategy = LoadStrategy.MERGE,
|
||||
) -> dict[str, list[DeclarativeBase]]:
|
||||
"""Load all fixtures for specific contexts.
|
||||
|
||||
For each fixture name, only the variants whose context set intersects with
|
||||
*contexts* are loaded. When a name has variants in multiple of the
|
||||
requested contexts, their instances are merged before being inserted.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
registry: Fixture registry
|
||||
*contexts: Contexts to load (e.g., Context.BASE, Context.TESTING)
|
||||
*contexts: Contexts to load (e.g., ``Context.BASE``, ``Context.TESTING``,
|
||||
or plain strings for custom contexts)
|
||||
strategy: How to handle existing records
|
||||
|
||||
Returns:
|
||||
Dict mapping fixture names to loaded instances
|
||||
"""
|
||||
context_strings = tuple(_normalize_contexts(contexts))
|
||||
ordered = registry.resolve_context_dependencies(*contexts)
|
||||
return await _load_ordered(session, registry, ordered, strategy)
|
||||
return await _load_ordered(
|
||||
session, registry, ordered, strategy, contexts=context_strings
|
||||
)
|
||||
|
||||
@@ -66,7 +66,7 @@ def configure_logging(
|
||||
_SENTINEL = object()
|
||||
|
||||
|
||||
def get_logger(name: str | None = _SENTINEL) -> logging.Logger: # type: ignore[assignment]
|
||||
def get_logger(name: str | None = _SENTINEL) -> logging.Logger: # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
|
||||
"""Return a logger with the given *name*.
|
||||
|
||||
A thin convenience wrapper around :func:`logging.getLogger` that keeps
|
||||
|
||||
21
src/fastapi_toolsets/models/__init__.py
Normal file
21
src/fastapi_toolsets/models/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""SQLAlchemy model mixins for common column patterns."""
|
||||
|
||||
from .columns import (
|
||||
CreatedAtMixin,
|
||||
TimestampMixin,
|
||||
UUIDMixin,
|
||||
UUIDv7Mixin,
|
||||
UpdatedAtMixin,
|
||||
)
|
||||
from .watched import ModelEvent, WatchedFieldsMixin, watch
|
||||
|
||||
__all__ = [
|
||||
"ModelEvent",
|
||||
"UUIDMixin",
|
||||
"UUIDv7Mixin",
|
||||
"CreatedAtMixin",
|
||||
"UpdatedAtMixin",
|
||||
"TimestampMixin",
|
||||
"WatchedFieldsMixin",
|
||||
"watch",
|
||||
]
|
||||
@@ -1,13 +1,14 @@
|
||||
"""SQLAlchemy model mixins for common column patterns."""
|
||||
"""SQLAlchemy column mixins for common column patterns."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Uuid, func, text
|
||||
from sqlalchemy import DateTime, Uuid, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
__all__ = [
|
||||
"UUIDMixin",
|
||||
"UUIDv7Mixin",
|
||||
"CreatedAtMixin",
|
||||
"UpdatedAtMixin",
|
||||
"TimestampMixin",
|
||||
@@ -24,12 +25,22 @@ class UUIDMixin:
|
||||
)
|
||||
|
||||
|
||||
class UUIDv7Mixin:
|
||||
"""Mixin that adds a UUIDv7 primary key auto-generated by the database."""
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid,
|
||||
primary_key=True,
|
||||
server_default=text("uuidv7()"),
|
||||
)
|
||||
|
||||
|
||||
class CreatedAtMixin:
|
||||
"""Mixin that adds a ``created_at`` timestamp column."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
server_default=text("clock_timestamp()"),
|
||||
)
|
||||
|
||||
|
||||
@@ -38,8 +49,8 @@ class UpdatedAtMixin:
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
server_default=text("clock_timestamp()"),
|
||||
onupdate=text("clock_timestamp()"),
|
||||
)
|
||||
|
||||
|
||||
269
src/fastapi_toolsets/models/watched.py
Normal file
269
src/fastapi_toolsets/models/watched.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""Field-change monitoring via SQLAlchemy session events."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import weakref
|
||||
from collections.abc import Awaitable
|
||||
from enum import Enum
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value
|
||||
|
||||
from ..logger import get_logger
|
||||
|
||||
__all__ = ["ModelEvent", "WatchedFieldsMixin", "watch"]
|
||||
|
||||
_logger = get_logger()
|
||||
_T = TypeVar("_T")
|
||||
_CALLBACK_ERROR_MSG = "WatchedFieldsMixin callback raised an unhandled exception"
|
||||
_WATCHED_FIELDS: weakref.WeakKeyDictionary[type, list[str]] = (
|
||||
weakref.WeakKeyDictionary()
|
||||
)
|
||||
_SESSION_PENDING_NEW = "_ft_pending_new"
|
||||
_SESSION_CREATES = "_ft_creates"
|
||||
_SESSION_DELETES = "_ft_deletes"
|
||||
_SESSION_UPDATES = "_ft_updates"
|
||||
_SESSION_SAVEPOINT_DEPTH = "_ft_sp_depth"
|
||||
|
||||
|
||||
class ModelEvent(str, Enum):
|
||||
"""Event types emitted by :class:`WatchedFieldsMixin`."""
|
||||
|
||||
CREATE = "create"
|
||||
DELETE = "delete"
|
||||
UPDATE = "update"
|
||||
|
||||
|
||||
def watch(*fields: str) -> Any:
|
||||
"""Class decorator to filter which fields trigger ``on_update``.
|
||||
|
||||
Args:
|
||||
*fields: One or more field names to watch. At least one name is required.
|
||||
|
||||
Raises:
|
||||
ValueError: If called with no field names.
|
||||
"""
|
||||
if not fields:
|
||||
raise ValueError("@watch requires at least one field name.")
|
||||
|
||||
def decorator(cls: type[_T]) -> type[_T]:
|
||||
_WATCHED_FIELDS[cls] = list(fields)
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _snapshot_column_attrs(obj: Any) -> dict[str, Any]:
|
||||
"""Read currently-loaded column values into a plain dict."""
|
||||
state = sa_inspect(obj) # InstanceState
|
||||
state_dict = state.dict
|
||||
return {
|
||||
prop.key: state_dict[prop.key]
|
||||
for prop in state.mapper.column_attrs
|
||||
if prop.key in state_dict
|
||||
}
|
||||
|
||||
|
||||
def _get_watched_fields(cls: type) -> list[str] | None:
|
||||
"""Return the watched fields for *cls*, walking the MRO to inherit from parents."""
|
||||
for klass in cls.__mro__:
|
||||
if klass in _WATCHED_FIELDS:
|
||||
return _WATCHED_FIELDS[klass]
|
||||
return None
|
||||
|
||||
|
||||
def _upsert_changes(
|
||||
pending: dict[int, tuple[Any, dict[str, dict[str, Any]]]],
|
||||
obj: Any,
|
||||
changes: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
"""Insert or merge *changes* into *pending* for *obj*."""
|
||||
key = id(obj)
|
||||
if key in pending:
|
||||
existing = pending[key][1]
|
||||
for field, change in changes.items():
|
||||
if field in existing:
|
||||
existing[field]["new"] = change["new"]
|
||||
else:
|
||||
existing[field] = change
|
||||
else:
|
||||
pending[key] = (obj, changes)
|
||||
|
||||
|
||||
@event.listens_for(AsyncSession.sync_session_class, "after_transaction_create")
|
||||
def _after_transaction_create(session: Any, transaction: Any) -> None:
|
||||
if transaction.nested:
|
||||
session.info[_SESSION_SAVEPOINT_DEPTH] = (
|
||||
session.info.get(_SESSION_SAVEPOINT_DEPTH, 0) + 1
|
||||
)
|
||||
|
||||
|
||||
@event.listens_for(AsyncSession.sync_session_class, "after_transaction_end")
|
||||
def _after_transaction_end(session: Any, transaction: Any) -> None:
|
||||
if transaction.nested:
|
||||
depth = session.info.get(_SESSION_SAVEPOINT_DEPTH, 0)
|
||||
if depth > 0: # pragma: no branch
|
||||
session.info[_SESSION_SAVEPOINT_DEPTH] = depth - 1
|
||||
|
||||
|
||||
@event.listens_for(AsyncSession.sync_session_class, "after_flush")
|
||||
def _after_flush(session: Any, flush_context: Any) -> None:
|
||||
# New objects: capture references while session.new is still populated.
|
||||
# Values are read in _after_flush_postexec once RETURNING has been processed.
|
||||
for obj in session.new:
|
||||
if isinstance(obj, WatchedFieldsMixin):
|
||||
session.info.setdefault(_SESSION_PENDING_NEW, []).append(obj)
|
||||
|
||||
# Deleted objects: capture before they leave the identity map.
|
||||
for obj in session.deleted:
|
||||
if isinstance(obj, WatchedFieldsMixin):
|
||||
session.info.setdefault(_SESSION_DELETES, []).append(obj)
|
||||
|
||||
# Dirty objects: read old/new from SQLAlchemy attribute history.
|
||||
for obj in session.dirty:
|
||||
if not isinstance(obj, WatchedFieldsMixin):
|
||||
continue
|
||||
|
||||
# None = not in dict = watch all fields; list = specific fields only
|
||||
watched = _get_watched_fields(type(obj))
|
||||
changes: dict[str, dict[str, Any]] = {}
|
||||
|
||||
attrs = (
|
||||
# Specific fields
|
||||
((field, sa_inspect(obj).attrs[field]) for field in watched)
|
||||
if watched is not None
|
||||
# All mapped fields
|
||||
else ((s.key, s) for s in sa_inspect(obj).attrs)
|
||||
)
|
||||
for field, attr_state in attrs:
|
||||
history = attr_state.history
|
||||
if history.has_changes() and history.deleted:
|
||||
changes[field] = {
|
||||
"old": history.deleted[0],
|
||||
"new": history.added[0] if history.added else None,
|
||||
}
|
||||
|
||||
if changes:
|
||||
_upsert_changes(
|
||||
session.info.setdefault(_SESSION_UPDATES, {}),
|
||||
obj,
|
||||
changes,
|
||||
)
|
||||
|
||||
|
||||
@event.listens_for(AsyncSession.sync_session_class, "after_flush_postexec")
|
||||
def _after_flush_postexec(session: Any, flush_context: Any) -> None:
|
||||
# New objects are now persistent and RETURNING values have been applied,
|
||||
# so server defaults (id, created_at, …) are available via getattr.
|
||||
pending_new: list[Any] = session.info.pop(_SESSION_PENDING_NEW, [])
|
||||
if not pending_new:
|
||||
return
|
||||
session.info.setdefault(_SESSION_CREATES, []).extend(pending_new)
|
||||
|
||||
|
||||
@event.listens_for(AsyncSession.sync_session_class, "after_rollback")
|
||||
def _after_rollback(session: Any) -> None:
|
||||
session.info.pop(_SESSION_PENDING_NEW, None)
|
||||
session.info.pop(_SESSION_CREATES, None)
|
||||
session.info.pop(_SESSION_DELETES, None)
|
||||
session.info.pop(_SESSION_UPDATES, None)
|
||||
|
||||
|
||||
def _task_error_handler(task: asyncio.Task[Any]) -> None:
|
||||
if not task.cancelled() and (exc := task.exception()):
|
||||
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||
|
||||
|
||||
def _schedule_with_snapshot(
|
||||
loop: asyncio.AbstractEventLoop, obj: Any, fn: Any, *args: Any
|
||||
) -> None:
|
||||
"""Snapshot *obj*'s column attrs now (before expire_on_commit wipes them),
|
||||
then schedule a coroutine that restores the snapshot and calls *fn*.
|
||||
"""
|
||||
snapshot = _snapshot_column_attrs(obj)
|
||||
|
||||
async def _run(
|
||||
obj: Any = obj,
|
||||
fn: Any = fn,
|
||||
snapshot: dict[str, Any] = snapshot,
|
||||
args: tuple = args,
|
||||
) -> None:
|
||||
for key, value in snapshot.items():
|
||||
_sa_set_committed_value(obj, key, value)
|
||||
try:
|
||||
result = fn(*args)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except Exception as exc:
|
||||
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||
|
||||
task = loop.create_task(_run())
|
||||
task.add_done_callback(_task_error_handler)
|
||||
|
||||
|
||||
@event.listens_for(AsyncSession.sync_session_class, "after_commit")
|
||||
def _after_commit(session: Any) -> None:
|
||||
if session.info.get(_SESSION_SAVEPOINT_DEPTH, 0) > 0:
|
||||
return
|
||||
|
||||
creates: list[Any] = session.info.pop(_SESSION_CREATES, [])
|
||||
deletes: list[Any] = session.info.pop(_SESSION_DELETES, [])
|
||||
field_changes: dict[int, tuple[Any, dict[str, dict[str, Any]]]] = session.info.pop(
|
||||
_SESSION_UPDATES, {}
|
||||
)
|
||||
|
||||
if creates and deletes:
|
||||
transient_ids = {id(o) for o in creates} & {id(o) for o in deletes}
|
||||
if transient_ids:
|
||||
creates = [o for o in creates if id(o) not in transient_ids]
|
||||
deletes = [o for o in deletes if id(o) not in transient_ids]
|
||||
field_changes = {
|
||||
k: v for k, v in field_changes.items() if k not in transient_ids
|
||||
}
|
||||
|
||||
if not creates and not deletes and not field_changes:
|
||||
return
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
for obj in creates:
|
||||
_schedule_with_snapshot(loop, obj, obj.on_create)
|
||||
|
||||
for obj in deletes:
|
||||
_schedule_with_snapshot(loop, obj, obj.on_delete)
|
||||
|
||||
for obj, changes in field_changes.values():
|
||||
_schedule_with_snapshot(loop, obj, obj.on_update, changes)
|
||||
|
||||
|
||||
class WatchedFieldsMixin:
|
||||
"""Mixin that enables lifecycle callbacks for SQLAlchemy models."""
|
||||
|
||||
def on_event(
|
||||
self, event: ModelEvent, changes: dict[str, dict[str, Any]] | None = None
|
||||
) -> Awaitable[None] | None:
|
||||
"""Catch-all callback fired for every lifecycle event.
|
||||
|
||||
Args:
|
||||
event: The event type (:attr:`ModelEvent.CREATE`, :attr:`ModelEvent.DELETE`,
|
||||
or :attr:`ModelEvent.UPDATE`).
|
||||
changes: Field changes for :attr:`ModelEvent.UPDATE`, ``None`` otherwise.
|
||||
"""
|
||||
|
||||
def on_create(self) -> Awaitable[None] | None:
|
||||
"""Called after INSERT commit."""
|
||||
return self.on_event(ModelEvent.CREATE)
|
||||
|
||||
def on_delete(self) -> Awaitable[None] | None:
|
||||
"""Called after DELETE commit."""
|
||||
return self.on_event(ModelEvent.DELETE)
|
||||
|
||||
def on_update(self, changes: dict[str, dict[str, Any]]) -> Awaitable[None] | None:
|
||||
"""Called after UPDATE commit when watched fields change."""
|
||||
return self.on_event(ModelEvent.UPDATE, changes=changes)
|
||||
@@ -7,6 +7,7 @@ from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
@@ -15,13 +16,8 @@ from sqlalchemy.ext.asyncio import (
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..db import (
|
||||
cleanup_tables as _cleanup_tables,
|
||||
create_database,
|
||||
create_db_context,
|
||||
)
|
||||
from ..db import cleanup_tables as _cleanup_tables
|
||||
from ..db import create_database
|
||||
|
||||
|
||||
async def cleanup_tables(
|
||||
@@ -129,7 +125,8 @@ async def create_worker_database(
|
||||
worker_url = worker_database_url(
|
||||
database_url=database_url, default_test_db=default_test_db
|
||||
)
|
||||
worker_db_name: str = make_url(worker_url).database # type: ignore[assignment]
|
||||
worker_db_name = make_url(worker_url).database
|
||||
assert worker_db_name is not None
|
||||
|
||||
engine = create_async_engine(database_url, isolation_level="AUTOCOMMIT")
|
||||
try:
|
||||
@@ -268,15 +265,12 @@ async def create_db_session(
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(base.metadata.create_all)
|
||||
|
||||
# Create session using existing db context utility
|
||||
session_maker = async_sessionmaker(engine, expire_on_commit=expire_on_commit)
|
||||
get_session = create_db_context(session_maker)
|
||||
|
||||
async with get_session() as session:
|
||||
async with session_maker() as session:
|
||||
yield session
|
||||
|
||||
if cleanup:
|
||||
await cleanup_tables(session, base)
|
||||
await _cleanup_tables(session=session, base=base)
|
||||
|
||||
if drop_tables:
|
||||
async with engine.begin() as conn:
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
"""Base Pydantic schemas for API responses."""
|
||||
|
||||
import math
|
||||
from enum import Enum
|
||||
from typing import Any, ClassVar, Generic
|
||||
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
from .types import DataT
|
||||
|
||||
__all__ = [
|
||||
"ApiError",
|
||||
"CursorPagination",
|
||||
"CursorPaginatedResponse",
|
||||
"ErrorResponse",
|
||||
"OffsetPagination",
|
||||
"OffsetPaginatedResponse",
|
||||
"PaginatedResponse",
|
||||
"PaginationType",
|
||||
"PydanticBase",
|
||||
"Response",
|
||||
"ResponseStatus",
|
||||
@@ -95,17 +99,29 @@ class OffsetPagination(PydanticBase):
|
||||
"""Pagination metadata for offset-based list responses.
|
||||
|
||||
Attributes:
|
||||
total_count: Total number of items across all pages
|
||||
total_count: Total number of items across all pages.
|
||||
``None`` when ``include_total=False``.
|
||||
items_per_page: Number of items per page
|
||||
page: Current page number (1-indexed)
|
||||
has_more: Whether there are more pages
|
||||
pages: Total number of pages
|
||||
"""
|
||||
|
||||
total_count: int
|
||||
total_count: int | None
|
||||
items_per_page: int
|
||||
page: int
|
||||
has_more: bool
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def pages(self) -> int | None:
|
||||
"""Total number of pages, or ``None`` when ``total_count`` is unknown."""
|
||||
if self.total_count is None:
|
||||
return None
|
||||
if self.items_per_page == 0:
|
||||
return 0
|
||||
return math.ceil(self.total_count / self.items_per_page)
|
||||
|
||||
|
||||
class CursorPagination(PydanticBase):
|
||||
"""Pagination metadata for cursor-based list responses.
|
||||
@@ -123,9 +139,66 @@ class CursorPagination(PydanticBase):
|
||||
has_more: bool
|
||||
|
||||
|
||||
class PaginationType(str, Enum):
|
||||
"""Pagination strategy selector for :meth:`.AsyncCrud.paginate`."""
|
||||
|
||||
OFFSET = "offset"
|
||||
CURSOR = "cursor"
|
||||
|
||||
|
||||
class PaginatedResponse(BaseResponse, Generic[DataT]):
|
||||
"""Paginated API response for list endpoints."""
|
||||
"""Paginated API response for list endpoints.
|
||||
|
||||
Base class and return type for endpoints that support both pagination
|
||||
strategies. Use :class:`OffsetPaginatedResponse` or
|
||||
:class:`CursorPaginatedResponse` when the strategy is fixed.
|
||||
|
||||
When used as ``PaginatedResponse[T]`` in a return annotation, subscripting
|
||||
returns ``Annotated[Union[CursorPaginatedResponse[T], OffsetPaginatedResponse[T]], Field(discriminator="pagination_type")]``
|
||||
so FastAPI emits a proper ``oneOf`` + discriminator in the OpenAPI schema.
|
||||
"""
|
||||
|
||||
data: list[DataT]
|
||||
pagination: OffsetPagination | CursorPagination
|
||||
pagination_type: PaginationType | None = None
|
||||
filter_attributes: dict[str, list[Any]] | None = None
|
||||
|
||||
_discriminated_union_cache: ClassVar[dict[Any, Any]] = {}
|
||||
|
||||
def __class_getitem__( # ty:ignore[invalid-method-override]
|
||||
cls, item: type[Any] | tuple[type[Any], ...]
|
||||
) -> type[Any]:
|
||||
if cls is PaginatedResponse and not isinstance(item, TypeVar):
|
||||
cached = cls._discriminated_union_cache.get(item)
|
||||
if cached is None:
|
||||
cached = Annotated[
|
||||
Union[CursorPaginatedResponse[item], OffsetPaginatedResponse[item]], # ty:ignore[invalid-type-form]
|
||||
Field(discriminator="pagination_type"),
|
||||
]
|
||||
cls._discriminated_union_cache[item] = cached
|
||||
return cached # ty:ignore[invalid-return-type]
|
||||
return super().__class_getitem__(item)
|
||||
|
||||
|
||||
class OffsetPaginatedResponse(PaginatedResponse[DataT]):
|
||||
"""Paginated response with typed offset-based pagination metadata.
|
||||
|
||||
The ``pagination_type`` field is always ``"offset"`` and acts as a
|
||||
discriminator, allowing frontend clients to narrow the union type returned
|
||||
by a unified ``paginate()`` endpoint.
|
||||
"""
|
||||
|
||||
pagination: OffsetPagination
|
||||
pagination_type: Literal[PaginationType.OFFSET] = PaginationType.OFFSET
|
||||
|
||||
|
||||
class CursorPaginatedResponse(PaginatedResponse[DataT]):
|
||||
"""Paginated response with typed cursor-based pagination metadata.
|
||||
|
||||
The ``pagination_type`` field is always ``"cursor"`` and acts as a
|
||||
discriminator, allowing frontend clients to narrow the union type returned
|
||||
by a unified ``paginate()`` endpoint.
|
||||
"""
|
||||
|
||||
pagination: CursorPagination
|
||||
pagination_type: Literal[PaginationType.CURSOR] = PaginationType.CURSOR
|
||||
|
||||
@@ -24,4 +24,4 @@ SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any],
|
||||
FacetFieldType = SearchFieldType
|
||||
|
||||
# Dependency type aliases
|
||||
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]]
|
||||
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] | Any
|
||||
|
||||
@@ -321,30 +321,3 @@ async def db_session(engine):
|
||||
# Drop tables after test
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_role_data() -> RoleCreate:
|
||||
"""Sample role creation data."""
|
||||
return RoleCreate(name="admin")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user_data() -> UserCreate:
|
||||
"""Sample user creation data."""
|
||||
return UserCreate(
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_post_data() -> PostCreate:
|
||||
"""Sample post creation data."""
|
||||
return PostCreate(
|
||||
title="Test Post",
|
||||
content="Test content",
|
||||
is_published=True,
|
||||
author_id=uuid.uuid4(),
|
||||
)
|
||||
|
||||
@@ -6,8 +6,8 @@ import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from fastapi_toolsets.crud import CrudFactory
|
||||
from fastapi_toolsets.crud.factory import AsyncCrud
|
||||
from fastapi_toolsets.crud import CrudFactory, PaginationType
|
||||
from fastapi_toolsets.crud.factory import AsyncCrud, _CursorDirection
|
||||
from fastapi_toolsets.exceptions import NotFoundError
|
||||
|
||||
from .conftest import (
|
||||
@@ -86,6 +86,101 @@ class TestCrudFactory:
|
||||
assert crud_with.default_load_options == options
|
||||
assert crud_without.default_load_options is None
|
||||
|
||||
def test_base_class_custom_methods_inherited(self):
|
||||
"""CrudFactory with base_class inherits custom methods from that base."""
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
T = TypeVar("T", bound=DeclarativeBase)
|
||||
|
||||
class CustomBase(AsyncCrud[T], Generic[T]):
|
||||
@classmethod
|
||||
def custom_method(cls) -> str:
|
||||
return f"custom:{cls.model.__name__}"
|
||||
|
||||
UserCrudCustom = CrudFactory(User, base_class=CustomBase)
|
||||
PostCrudCustom = CrudFactory(Post, base_class=CustomBase)
|
||||
|
||||
assert issubclass(UserCrudCustom, CustomBase)
|
||||
assert issubclass(PostCrudCustom, CustomBase)
|
||||
assert UserCrudCustom.custom_method() == "custom:User"
|
||||
assert PostCrudCustom.custom_method() == "custom:Post"
|
||||
|
||||
def test_base_class_pk_injected(self):
|
||||
"""PK is still injected when using a custom base_class."""
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
T = TypeVar("T", bound=DeclarativeBase)
|
||||
|
||||
class CustomBase(AsyncCrud[T], Generic[T]):
|
||||
pass
|
||||
|
||||
crud = CrudFactory(User, base_class=CustomBase)
|
||||
assert crud.searchable_fields is not None
|
||||
assert User.id in crud.searchable_fields
|
||||
|
||||
|
||||
class TestAsyncCrudSubclass:
|
||||
"""Tests for direct AsyncCrud subclassing (alternative to CrudFactory)."""
|
||||
|
||||
def test_subclass_with_model_only(self):
|
||||
"""Subclassing with just model auto-injects PK into searchable_fields."""
|
||||
|
||||
class UserCrudDirect(AsyncCrud[User]):
|
||||
model = User
|
||||
|
||||
assert UserCrudDirect.searchable_fields == [User.id]
|
||||
|
||||
def test_subclass_with_explicit_fields_prepends_pk(self):
|
||||
"""Subclassing with searchable_fields prepends PK automatically."""
|
||||
|
||||
class UserCrudDirect(AsyncCrud[User]):
|
||||
model = User
|
||||
searchable_fields = [User.username]
|
||||
|
||||
assert UserCrudDirect.searchable_fields == [User.id, User.username]
|
||||
|
||||
def test_subclass_with_pk_already_in_fields(self):
|
||||
"""PK is not duplicated when already in searchable_fields."""
|
||||
|
||||
class UserCrudDirect(AsyncCrud[User]):
|
||||
model = User
|
||||
searchable_fields = [User.id, User.username]
|
||||
|
||||
assert UserCrudDirect.searchable_fields == [User.id, User.username]
|
||||
|
||||
def test_subclass_has_default_class_vars(self):
|
||||
"""Other ClassVars are None by default on a direct subclass."""
|
||||
|
||||
class UserCrudDirect(AsyncCrud[User]):
|
||||
model = User
|
||||
|
||||
assert UserCrudDirect.facet_fields is None
|
||||
assert UserCrudDirect.default_load_options is None
|
||||
assert UserCrudDirect.cursor_column is None
|
||||
|
||||
def test_subclass_with_load_options(self):
|
||||
"""Direct subclass can declare default_load_options."""
|
||||
opts = [selectinload(User.role)]
|
||||
|
||||
class UserCrudDirect(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = opts
|
||||
|
||||
assert UserCrudDirect.default_load_options is opts
|
||||
|
||||
def test_abstract_base_without_model_not_processed(self):
|
||||
"""Intermediate abstract class without model is not processed."""
|
||||
|
||||
class AbstractCrud(AsyncCrud[User]):
|
||||
pass
|
||||
|
||||
# Should not raise, and searchable_fields inherits base default (None)
|
||||
assert AbstractCrud.searchable_fields is None
|
||||
|
||||
|
||||
class TestResolveLoadOptions:
|
||||
"""Tests for _resolve_load_options logic."""
|
||||
@@ -1664,6 +1759,52 @@ class TestSchemaResponse:
|
||||
assert result.data[0].username == "pg_user"
|
||||
assert not hasattr(result.data[0], "email")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_include_total_false_skips_count(self, db_session: AsyncSession):
|
||||
"""offset_paginate with include_total=False returns total_count=None."""
|
||||
from fastapi_toolsets.schemas import OffsetPagination
|
||||
|
||||
for i in range(5):
|
||||
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||
|
||||
result = await RoleCrud.offset_paginate(
|
||||
db_session, items_per_page=10, include_total=False, schema=RoleRead
|
||||
)
|
||||
|
||||
assert isinstance(result.pagination, OffsetPagination)
|
||||
assert result.pagination.total_count is None
|
||||
assert len(result.data) == 5
|
||||
assert result.pagination.has_more is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_include_total_false_has_more_true(self, db_session: AsyncSession):
|
||||
"""offset_paginate with include_total=False sets has_more via extra-row probe."""
|
||||
for i in range(15):
|
||||
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||
|
||||
result = await RoleCrud.offset_paginate(
|
||||
db_session, items_per_page=10, include_total=False, schema=RoleRead
|
||||
)
|
||||
|
||||
assert result.pagination.total_count is None
|
||||
assert result.pagination.has_more is True
|
||||
assert len(result.data) == 10
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_include_total_false_exact_page_boundary(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""offset_paginate with include_total=False: has_more=False when items == page size."""
|
||||
for i in range(10):
|
||||
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||
|
||||
result = await RoleCrud.offset_paginate(
|
||||
db_session, items_per_page=10, include_total=False, schema=RoleRead
|
||||
)
|
||||
|
||||
assert result.pagination.has_more is False
|
||||
assert len(result.data) == 10
|
||||
|
||||
|
||||
class TestCursorPaginate:
|
||||
"""Tests for cursor-based pagination via cursor_paginate()."""
|
||||
@@ -1874,11 +2015,8 @@ class TestCursorPaginatePrevCursor:
|
||||
assert page2.pagination.prev_cursor is not None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prev_cursor_points_to_first_item(self, db_session: AsyncSession):
|
||||
"""prev_cursor encodes the value of the first item on the current page."""
|
||||
import base64
|
||||
import json
|
||||
|
||||
async def test_prev_cursor_navigates_back(self, db_session: AsyncSession):
|
||||
"""prev_cursor on page 2 navigates back to the same items as page 1."""
|
||||
for i in range(10):
|
||||
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||
|
||||
@@ -1897,12 +2035,83 @@ class TestCursorPaginatePrevCursor:
|
||||
assert isinstance(page2.pagination, CursorPagination)
|
||||
assert page2.pagination.prev_cursor is not None
|
||||
|
||||
# Decode prev_cursor and compare to first item's id
|
||||
decoded = json.loads(
|
||||
base64.b64decode(page2.pagination.prev_cursor.encode()).decode()
|
||||
# Using prev_cursor should return the same items as page 1
|
||||
back_to_page1 = await RoleCursorCrud.cursor_paginate(
|
||||
db_session,
|
||||
cursor=page2.pagination.prev_cursor,
|
||||
items_per_page=5,
|
||||
schema=RoleRead,
|
||||
)
|
||||
first_item_id = str(page2.data[0].id)
|
||||
assert decoded == first_item_id
|
||||
assert isinstance(back_to_page1.pagination, CursorPagination)
|
||||
assert [r.id for r in back_to_page1.data] == [r.id for r in page1.data]
|
||||
assert back_to_page1.pagination.prev_cursor is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prev_cursor_empty_result_when_no_items_before(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""Going backward past the first item returns an empty page."""
|
||||
from fastapi_toolsets.crud.factory import _encode_cursor
|
||||
from fastapi_toolsets.schemas import CursorPagination
|
||||
|
||||
await IntRoleCursorCrud.create(db_session, IntRoleCreate(name="role00"))
|
||||
|
||||
page1 = await IntRoleCursorCrud.cursor_paginate(
|
||||
db_session, items_per_page=5, schema=IntRoleRead
|
||||
)
|
||||
assert isinstance(page1.pagination, CursorPagination)
|
||||
|
||||
# Manually craft a backward cursor before any existing id
|
||||
before_all = _encode_cursor(0, direction=_CursorDirection.PREV)
|
||||
empty = await IntRoleCursorCrud.cursor_paginate(
|
||||
db_session, cursor=before_all, items_per_page=5, schema=IntRoleRead
|
||||
)
|
||||
|
||||
assert isinstance(empty.pagination, CursorPagination)
|
||||
assert empty.data == []
|
||||
assert empty.pagination.next_cursor is None
|
||||
assert empty.pagination.prev_cursor is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prev_cursor_set_when_more_pages_behind(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""Going backward on page 2 (of 3) still exposes a prev_cursor for page 1."""
|
||||
for i in range(9):
|
||||
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||
|
||||
from fastapi_toolsets.schemas import CursorPagination
|
||||
|
||||
page1 = await RoleCursorCrud.cursor_paginate(
|
||||
db_session, items_per_page=3, schema=RoleRead
|
||||
)
|
||||
assert isinstance(page1.pagination, CursorPagination)
|
||||
page2 = await RoleCursorCrud.cursor_paginate(
|
||||
db_session,
|
||||
cursor=page1.pagination.next_cursor,
|
||||
items_per_page=3,
|
||||
schema=RoleRead,
|
||||
)
|
||||
assert isinstance(page2.pagination, CursorPagination)
|
||||
page3 = await RoleCursorCrud.cursor_paginate(
|
||||
db_session,
|
||||
cursor=page2.pagination.next_cursor,
|
||||
items_per_page=3,
|
||||
schema=RoleRead,
|
||||
)
|
||||
assert isinstance(page3.pagination, CursorPagination)
|
||||
assert page3.pagination.prev_cursor is not None
|
||||
|
||||
# Going back to page 2 should still have a prev_cursor pointing at page 1
|
||||
back_to_page2 = await RoleCursorCrud.cursor_paginate(
|
||||
db_session,
|
||||
cursor=page3.pagination.prev_cursor,
|
||||
items_per_page=3,
|
||||
schema=RoleRead,
|
||||
)
|
||||
assert isinstance(back_to_page2.pagination, CursorPagination)
|
||||
assert [r.id for r in back_to_page2.data] == [r.id for r in page2.data]
|
||||
assert back_to_page2.pagination.prev_cursor is not None
|
||||
|
||||
|
||||
class TestCursorPaginateWithSearch:
|
||||
@@ -2289,3 +2498,89 @@ class TestCursorPaginateColumnTypes:
|
||||
page1_ids = {p.id for p in page1.data}
|
||||
page2_ids = {p.id for p in page2.data}
|
||||
assert page1_ids.isdisjoint(page2_ids)
|
||||
|
||||
|
||||
class TestPaginate:
|
||||
"""Tests for the unified paginate() method."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offset_pagination(self, db_session: AsyncSession):
|
||||
"""paginate() with OFFSET returns OffsetPaginatedResponse."""
|
||||
from fastapi_toolsets.schemas import OffsetPagination
|
||||
|
||||
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
await RoleCrud.create(db_session, RoleCreate(name="user"))
|
||||
|
||||
result = await RoleCrud.paginate(
|
||||
db_session,
|
||||
pagination_type=PaginationType.OFFSET,
|
||||
schema=RoleRead,
|
||||
)
|
||||
|
||||
assert isinstance(result.pagination, OffsetPagination)
|
||||
assert result.pagination_type == PaginationType.OFFSET
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cursor_pagination(self, db_session: AsyncSession):
|
||||
"""paginate() with CURSOR returns CursorPaginatedResponse."""
|
||||
from fastapi_toolsets.schemas import CursorPagination
|
||||
|
||||
await RoleCursorCrud.create(db_session, RoleCreate(name="admin"))
|
||||
|
||||
result = await RoleCursorCrud.paginate(
|
||||
db_session,
|
||||
pagination_type=PaginationType.CURSOR,
|
||||
schema=RoleRead,
|
||||
)
|
||||
|
||||
assert isinstance(result.pagination, CursorPagination)
|
||||
assert result.pagination_type == PaginationType.CURSOR
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invalid_items_per_page_raises(self, db_session: AsyncSession):
|
||||
"""paginate() raises ValueError when items_per_page < 1."""
|
||||
with pytest.raises(ValueError, match="items_per_page"):
|
||||
await RoleCrud.paginate(
|
||||
db_session,
|
||||
pagination_type=PaginationType.OFFSET,
|
||||
items_per_page=0,
|
||||
schema=RoleRead,
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invalid_page_raises(self, db_session: AsyncSession):
|
||||
"""paginate() raises ValueError when page < 1 for offset pagination."""
|
||||
with pytest.raises(ValueError, match="page"):
|
||||
await RoleCrud.paginate(
|
||||
db_session,
|
||||
pagination_type=PaginationType.OFFSET,
|
||||
page=0,
|
||||
schema=RoleRead,
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unknown_pagination_type_raises(self, db_session: AsyncSession):
|
||||
"""paginate() raises ValueError for unknown pagination_type."""
|
||||
with pytest.raises(ValueError, match="Unknown pagination_type"):
|
||||
await RoleCrud.paginate(
|
||||
db_session,
|
||||
pagination_type="unknown",
|
||||
schema=RoleRead,
|
||||
) # type: ignore[no-matching-overload] # ty:ignore[no-matching-overload]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offset_include_total_false(self, db_session: AsyncSession):
|
||||
"""paginate() passes include_total=False through to offset_paginate."""
|
||||
from fastapi_toolsets.schemas import OffsetPagination
|
||||
|
||||
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
|
||||
result = await RoleCrud.paginate(
|
||||
db_session,
|
||||
pagination_type=PaginationType.OFFSET,
|
||||
include_total=False,
|
||||
schema=RoleRead,
|
||||
)
|
||||
|
||||
assert isinstance(result.pagination, OffsetPagination)
|
||||
assert result.pagination.total_count is None
|
||||
|
||||
@@ -14,12 +14,14 @@ from fastapi_toolsets.crud import (
|
||||
get_searchable_fields,
|
||||
)
|
||||
from fastapi_toolsets.exceptions import InvalidOrderFieldError
|
||||
from fastapi_toolsets.schemas import OffsetPagination
|
||||
from fastapi_toolsets.schemas import OffsetPagination, PaginationType
|
||||
|
||||
from .conftest import (
|
||||
Role,
|
||||
RoleCreate,
|
||||
RoleCrud,
|
||||
RoleCursorCrud,
|
||||
RoleRead,
|
||||
User,
|
||||
UserCreate,
|
||||
UserCrud,
|
||||
@@ -211,14 +213,17 @@ class TestPaginateSearch:
|
||||
assert result.data[0].username == "active_john"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_auto_detect_fields(self, db_session: AsyncSession):
|
||||
"""Auto-detect searchable fields when not specified."""
|
||||
async def test_search_explicit_fields(self, db_session: AsyncSession):
|
||||
"""Search works when search_fields are passed per call."""
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="findme", email="other@test.com")
|
||||
)
|
||||
|
||||
result = await UserCrud.offset_paginate(
|
||||
db_session, search="findme", schema=UserRead
|
||||
db_session,
|
||||
search="findme",
|
||||
search_fields=[User.username],
|
||||
schema=UserRead,
|
||||
)
|
||||
|
||||
assert isinstance(result.pagination, OffsetPagination)
|
||||
@@ -985,7 +990,7 @@ class TestFilterParamsSchema:
|
||||
UserFacetCrud = CrudFactory(User, facet_fields=[User.username])
|
||||
dep = UserFacetCrud.filter_params()
|
||||
|
||||
assert dep.__name__ == "UserFilterParams" # type: ignore[union-attr]
|
||||
assert dep.__name__ == "UserFilterParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_integration_with_offset_paginate(self, db_session: AsyncSession):
|
||||
@@ -1190,3 +1195,245 @@ class TestOrderParamsSchema:
|
||||
|
||||
assert results[0].username == "alice"
|
||||
assert results[1].username == "charlie"
|
||||
|
||||
|
||||
class TestOffsetParamsSchema:
|
||||
"""Tests for AsyncCrud.offset_params()."""
|
||||
|
||||
def test_returns_page_and_items_per_page_params(self):
|
||||
"""Returned dependency has page and items_per_page params only."""
|
||||
dep = RoleCrud.offset_params()
|
||||
param_names = set(inspect.signature(dep).parameters)
|
||||
assert param_names == {"page", "items_per_page"}
|
||||
|
||||
def test_dependency_name_includes_model_name(self):
|
||||
"""Dependency function is named after the model."""
|
||||
dep = RoleCrud.offset_params()
|
||||
assert getattr(dep, "__name__") == "RoleOffsetParams"
|
||||
|
||||
def test_default_page_size_reflected_in_items_per_page_default(self):
|
||||
"""default_page_size is used as the default for items_per_page."""
|
||||
dep = RoleCrud.offset_params(default_page_size=42)
|
||||
sig = inspect.signature(dep)
|
||||
assert sig.parameters["items_per_page"].default.default == 42
|
||||
|
||||
def test_max_page_size_reflected_in_items_per_page_le(self):
|
||||
"""max_page_size is used as le constraint on items_per_page."""
|
||||
dep = RoleCrud.offset_params(max_page_size=50)
|
||||
sig = inspect.signature(dep)
|
||||
le = next(
|
||||
m.le
|
||||
for m in sig.parameters["items_per_page"].default.metadata
|
||||
if hasattr(m, "le")
|
||||
)
|
||||
assert le == 50
|
||||
|
||||
def test_include_total_not_a_query_param(self):
|
||||
"""include_total is not exposed as a query parameter."""
|
||||
dep = RoleCrud.offset_params()
|
||||
param_names = set(inspect.signature(dep).parameters)
|
||||
assert "include_total" not in param_names
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_include_total_true_forwarded_in_result(self):
|
||||
"""include_total=True factory arg appears in the resolved dict."""
|
||||
result = await RoleCrud.offset_params(include_total=True)(
|
||||
page=1, items_per_page=10
|
||||
)
|
||||
assert result["include_total"] is True
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_include_total_false_forwarded_in_result(self):
|
||||
"""include_total=False factory arg appears in the resolved dict."""
|
||||
result = await RoleCrud.offset_params(include_total=False)(
|
||||
page=1, items_per_page=10
|
||||
)
|
||||
assert result["include_total"] is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_awaiting_dep_returns_dict(self):
|
||||
"""Awaiting the dependency returns a dict with page, items_per_page, include_total."""
|
||||
dep = RoleCrud.offset_params(include_total=False)
|
||||
result = await dep(page=2, items_per_page=10)
|
||||
assert result == {"page": 2, "items_per_page": 10, "include_total": False}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_integrates_with_offset_paginate(self, db_session: AsyncSession):
|
||||
"""offset_params output can be unpacked directly into offset_paginate."""
|
||||
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
dep = RoleCrud.offset_params()
|
||||
params = await dep(page=1, items_per_page=10)
|
||||
result = await RoleCrud.offset_paginate(db_session, **params, schema=RoleRead)
|
||||
assert result.pagination.page == 1
|
||||
assert result.pagination.items_per_page == 10
|
||||
|
||||
|
||||
class TestCursorParamsSchema:
|
||||
"""Tests for AsyncCrud.cursor_params()."""
|
||||
|
||||
def test_returns_cursor_and_items_per_page_params(self):
|
||||
"""Returned dependency has cursor and items_per_page params."""
|
||||
dep = RoleCursorCrud.cursor_params()
|
||||
param_names = set(inspect.signature(dep).parameters)
|
||||
assert param_names == {"cursor", "items_per_page"}
|
||||
|
||||
def test_dependency_name_includes_model_name(self):
|
||||
"""Dependency function is named after the model."""
|
||||
dep = RoleCursorCrud.cursor_params()
|
||||
assert getattr(dep, "__name__") == "RoleCursorParams"
|
||||
|
||||
def test_default_page_size_reflected_in_items_per_page_default(self):
|
||||
"""default_page_size is used as the default for items_per_page."""
|
||||
dep = RoleCursorCrud.cursor_params(default_page_size=15)
|
||||
sig = inspect.signature(dep)
|
||||
assert sig.parameters["items_per_page"].default.default == 15
|
||||
|
||||
def test_max_page_size_reflected_in_items_per_page_le(self):
|
||||
"""max_page_size is used as le constraint on items_per_page."""
|
||||
dep = RoleCursorCrud.cursor_params(max_page_size=75)
|
||||
sig = inspect.signature(dep)
|
||||
le = next(
|
||||
m.le
|
||||
for m in sig.parameters["items_per_page"].default.metadata
|
||||
if hasattr(m, "le")
|
||||
)
|
||||
assert le == 75
|
||||
|
||||
def test_cursor_defaults_to_none(self):
|
||||
"""cursor defaults to None."""
|
||||
dep = RoleCursorCrud.cursor_params()
|
||||
sig = inspect.signature(dep)
|
||||
assert sig.parameters["cursor"].default.default is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_awaiting_dep_returns_dict(self):
|
||||
"""Awaiting the dependency returns a dict with cursor and items_per_page."""
|
||||
dep = RoleCursorCrud.cursor_params()
|
||||
result = await dep(cursor=None, items_per_page=5)
|
||||
assert result == {"cursor": None, "items_per_page": 5}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_integrates_with_cursor_paginate(self, db_session: AsyncSession):
|
||||
"""cursor_params output can be unpacked directly into cursor_paginate."""
|
||||
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
dep = RoleCursorCrud.cursor_params()
|
||||
params = await dep(cursor=None, items_per_page=10)
|
||||
result = await RoleCursorCrud.cursor_paginate(
|
||||
db_session, **params, schema=RoleRead
|
||||
)
|
||||
assert result.pagination.items_per_page == 10
|
||||
|
||||
|
||||
class TestPaginateParamsSchema:
|
||||
"""Tests for AsyncCrud.paginate_params()."""
|
||||
|
||||
def test_returns_all_params(self):
|
||||
"""Returned dependency has pagination_type, page, cursor, items_per_page (no include_total)."""
|
||||
dep = RoleCursorCrud.paginate_params()
|
||||
param_names = set(inspect.signature(dep).parameters)
|
||||
assert param_names == {"pagination_type", "page", "cursor", "items_per_page"}
|
||||
|
||||
def test_dependency_name_includes_model_name(self):
|
||||
"""Dependency function is named after the model."""
|
||||
dep = RoleCursorCrud.paginate_params()
|
||||
assert getattr(dep, "__name__") == "RolePaginateParams"
|
||||
|
||||
def test_default_pagination_type(self):
|
||||
"""default_pagination_type is reflected in pagination_type default."""
|
||||
from fastapi_toolsets.schemas import PaginationType
|
||||
|
||||
dep = RoleCursorCrud.paginate_params(
|
||||
default_pagination_type=PaginationType.CURSOR
|
||||
)
|
||||
sig = inspect.signature(dep)
|
||||
assert (
|
||||
sig.parameters["pagination_type"].default.default == PaginationType.CURSOR
|
||||
)
|
||||
|
||||
def test_default_page_size(self):
|
||||
"""default_page_size is reflected in items_per_page default."""
|
||||
dep = RoleCursorCrud.paginate_params(default_page_size=15)
|
||||
sig = inspect.signature(dep)
|
||||
assert sig.parameters["items_per_page"].default.default == 15
|
||||
|
||||
def test_max_page_size_le_constraint(self):
|
||||
"""max_page_size is used as le constraint on items_per_page."""
|
||||
dep = RoleCursorCrud.paginate_params(max_page_size=60)
|
||||
sig = inspect.signature(dep)
|
||||
le = next(
|
||||
m.le
|
||||
for m in sig.parameters["items_per_page"].default.metadata
|
||||
if hasattr(m, "le")
|
||||
)
|
||||
assert le == 60
|
||||
|
||||
def test_include_total_not_a_query_param(self):
|
||||
"""include_total is not exposed as a query parameter."""
|
||||
dep = RoleCursorCrud.paginate_params()
|
||||
assert "include_total" not in set(inspect.signature(dep).parameters)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_include_total_forwarded_in_result(self):
|
||||
"""include_total factory arg appears in the resolved dict."""
|
||||
result_true = await RoleCursorCrud.paginate_params(include_total=True)(
|
||||
pagination_type=PaginationType.OFFSET,
|
||||
page=1,
|
||||
cursor=None,
|
||||
items_per_page=10,
|
||||
)
|
||||
result_false = await RoleCursorCrud.paginate_params(include_total=False)(
|
||||
pagination_type=PaginationType.OFFSET,
|
||||
page=1,
|
||||
cursor=None,
|
||||
items_per_page=10,
|
||||
)
|
||||
assert result_true["include_total"] is True
|
||||
assert result_false["include_total"] is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_awaiting_dep_returns_dict(self):
|
||||
"""Awaiting the dependency returns a dict with all pagination keys."""
|
||||
dep = RoleCursorCrud.paginate_params()
|
||||
result = await dep(
|
||||
pagination_type=PaginationType.OFFSET,
|
||||
page=2,
|
||||
cursor=None,
|
||||
items_per_page=10,
|
||||
)
|
||||
assert result == {
|
||||
"pagination_type": PaginationType.OFFSET,
|
||||
"page": 2,
|
||||
"cursor": None,
|
||||
"items_per_page": 10,
|
||||
"include_total": True,
|
||||
}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_integrates_with_paginate_offset(self, db_session: AsyncSession):
|
||||
"""paginate_params output unpacks into paginate() for offset strategy."""
|
||||
from fastapi_toolsets.schemas import OffsetPagination
|
||||
|
||||
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
params = await RoleCursorCrud.paginate_params()(
|
||||
pagination_type=PaginationType.OFFSET,
|
||||
page=1,
|
||||
cursor=None,
|
||||
items_per_page=10,
|
||||
)
|
||||
result = await RoleCursorCrud.paginate(db_session, **params, schema=RoleRead)
|
||||
assert isinstance(result.pagination, OffsetPagination)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_integrates_with_paginate_cursor(self, db_session: AsyncSession):
|
||||
"""paginate_params output unpacks into paginate() for cursor strategy."""
|
||||
from fastapi_toolsets.schemas import CursorPagination
|
||||
|
||||
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
params = await RoleCursorCrud.paginate_params()(
|
||||
pagination_type=PaginationType.CURSOR,
|
||||
page=1,
|
||||
cursor=None,
|
||||
items_per_page=10,
|
||||
)
|
||||
result = await RoleCursorCrud.paginate(db_session, **params, schema=RoleRead)
|
||||
assert isinstance(result.pagination, CursorPagination)
|
||||
|
||||
@@ -68,6 +68,55 @@ class TestCreateDbDependency:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_in_transaction_on_yield(self):
|
||||
"""Session is already in a transaction when the endpoint body starts."""
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
get_db = create_db_dependency(session_factory)
|
||||
|
||||
async for session in get_db():
|
||||
assert session.in_transaction()
|
||||
break
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_after_lock_tables_is_persisted(self):
|
||||
"""Changes made after lock_tables exits (before endpoint returns) are committed.
|
||||
|
||||
Regression: without the auto-begin fix, lock_tables would start and commit a
|
||||
real outer transaction, leaving the session idle. Any modifications after that
|
||||
point were silently dropped.
|
||||
"""
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
try:
|
||||
get_db = create_db_dependency(session_factory)
|
||||
|
||||
async for session in get_db():
|
||||
async with lock_tables(session, [Role]):
|
||||
role = Role(name="lock_then_update")
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
# lock_tables has exited — outer transaction must still be open
|
||||
assert session.in_transaction()
|
||||
role.name = "updated_after_lock"
|
||||
|
||||
async with session_factory() as verify:
|
||||
result = await RoleCrud.first(
|
||||
verify, [Role.name == "updated_after_lock"]
|
||||
)
|
||||
assert result is not None
|
||||
finally:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class TestCreateDbContext:
|
||||
"""Tests for create_db_context."""
|
||||
@@ -363,7 +412,8 @@ class TestCreateDatabase:
|
||||
.set(database="test_create_db_general")
|
||||
.render_as_string(hide_password=False)
|
||||
)
|
||||
expected_db: str = make_url(target_url).database # type: ignore[assignment]
|
||||
expected_db = make_url(target_url).database
|
||||
assert expected_db is not None
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||
try:
|
||||
|
||||
@@ -3,20 +3,42 @@
|
||||
import inspect
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, cast
|
||||
from typing import Annotated, Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi.params import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fastapi_toolsets.dependencies import BodyDependency, PathDependency
|
||||
from fastapi_toolsets.dependencies import (
|
||||
BodyDependency,
|
||||
PathDependency,
|
||||
_unwrap_session_dep,
|
||||
)
|
||||
|
||||
from .conftest import Role, RoleCreate, RoleCrud, User
|
||||
|
||||
|
||||
async def mock_get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Mock session dependency for testing."""
|
||||
yield None
|
||||
yield None # type: ignore[misc] # ty:ignore[invalid-yield]
|
||||
|
||||
|
||||
MockSessionDep = Annotated[AsyncSession, Depends(mock_get_db)]
|
||||
|
||||
|
||||
class TestUnwrapSessionDep:
|
||||
def test_plain_callable_returned_as_is(self):
|
||||
"""Plain callable is returned unchanged."""
|
||||
assert _unwrap_session_dep(mock_get_db) is mock_get_db
|
||||
|
||||
def test_annotated_with_depends_unwrapped(self):
|
||||
"""Annotated form with Depends is unwrapped to the plain callable."""
|
||||
assert _unwrap_session_dep(MockSessionDep) is mock_get_db
|
||||
|
||||
def test_annotated_without_depends_returned_as_is(self):
|
||||
"""Annotated form with no Depends falls back to returning session_dep as-is."""
|
||||
annotated_no_dep = Annotated[AsyncSession, "not_a_depends"]
|
||||
assert _unwrap_session_dep(annotated_no_dep) is annotated_no_dep
|
||||
|
||||
|
||||
class TestPathDependency:
|
||||
@@ -95,6 +117,39 @@ class TestPathDependency:
|
||||
assert result.id == role.id
|
||||
assert result.name == "test_role"
|
||||
|
||||
def test_annotated_session_dep_returns_depends_instance(self):
|
||||
"""PathDependency accepts Annotated[AsyncSession, Depends(...)] form."""
|
||||
dep = PathDependency(Role, Role.id, session_dep=MockSessionDep)
|
||||
assert isinstance(dep, Depends)
|
||||
|
||||
def test_annotated_session_dep_signature(self):
|
||||
"""PathDependency with Annotated session_dep produces a valid signature."""
|
||||
dep = cast(Any, PathDependency(Role, Role.id, session_dep=MockSessionDep))
|
||||
sig = inspect.signature(dep.dependency)
|
||||
|
||||
assert "role_id" in sig.parameters
|
||||
assert "session" in sig.parameters
|
||||
assert isinstance(sig.parameters["session"].default, Depends)
|
||||
|
||||
def test_annotated_session_dep_unwraps_callable(self):
|
||||
"""PathDependency with Annotated form uses the underlying callable, not the Annotated type."""
|
||||
dep = cast(Any, PathDependency(Role, Role.id, session_dep=MockSessionDep))
|
||||
sig = inspect.signature(dep.dependency)
|
||||
|
||||
inner_dep = sig.parameters["session"].default
|
||||
assert inner_dep.dependency is mock_get_db
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_annotated_session_dep_fetches_object(self, db_session):
|
||||
"""PathDependency with Annotated session_dep correctly fetches object from database."""
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="annotated_role"))
|
||||
|
||||
dep = cast(Any, PathDependency(Role, Role.id, session_dep=MockSessionDep))
|
||||
result = await dep.dependency(session=db_session, role_id=role.id)
|
||||
|
||||
assert result.id == role.id
|
||||
assert result.name == "annotated_role"
|
||||
|
||||
|
||||
class TestBodyDependency:
|
||||
"""Tests for BodyDependency factory."""
|
||||
@@ -184,3 +239,39 @@ class TestBodyDependency:
|
||||
|
||||
assert result.id == role.id
|
||||
assert result.name == "body_test_role"
|
||||
|
||||
def test_annotated_session_dep_returns_depends_instance(self):
|
||||
"""BodyDependency accepts Annotated[AsyncSession, Depends(...)] form."""
|
||||
dep = BodyDependency(
|
||||
Role, Role.id, session_dep=MockSessionDep, body_field="role_id"
|
||||
)
|
||||
assert isinstance(dep, Depends)
|
||||
|
||||
def test_annotated_session_dep_unwraps_callable(self):
|
||||
"""BodyDependency with Annotated form uses the underlying callable, not the Annotated type."""
|
||||
dep = cast(
|
||||
Any,
|
||||
BodyDependency(
|
||||
Role, Role.id, session_dep=MockSessionDep, body_field="role_id"
|
||||
),
|
||||
)
|
||||
sig = inspect.signature(dep.dependency)
|
||||
|
||||
inner_dep = sig.parameters["session"].default
|
||||
assert inner_dep.dependency is mock_get_db
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_annotated_session_dep_fetches_object(self, db_session):
|
||||
"""BodyDependency with Annotated session_dep correctly fetches object from database."""
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="body_annotated_role"))
|
||||
|
||||
dep = cast(
|
||||
Any,
|
||||
BodyDependency(
|
||||
Role, Role.id, session_dep=MockSessionDep, body_field="role_id"
|
||||
),
|
||||
)
|
||||
result = await dep.dependency(session=db_session, role_id=role.id)
|
||||
|
||||
assert result.id == role.id
|
||||
assert result.name == "body_annotated_role"
|
||||
|
||||
@@ -10,12 +10,13 @@ import datetime
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from docs_src.examples.pagination_search.db import get_db
|
||||
from docs_src.examples.pagination_search.models import Article, Base, Category
|
||||
from docs_src.examples.pagination_search.routes import router
|
||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||
from fastapi_toolsets.pytest import create_db_session
|
||||
|
||||
from .conftest import DATABASE_URL
|
||||
|
||||
@@ -35,20 +36,8 @@ def build_app(session: AsyncSession) -> FastAPI:
|
||||
@pytest.fixture(scope="function")
|
||||
async def ex_db_session():
|
||||
"""Isolated session for the example models (separate tables from conftest)."""
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = session_factory()
|
||||
|
||||
try:
|
||||
async with create_db_session(DATABASE_URL, Base) as session:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -393,3 +382,105 @@ class TestCursorSorting:
|
||||
body = resp.json()
|
||||
assert body["error_code"] == "SORT-422"
|
||||
assert body["status"] == "FAIL"
|
||||
|
||||
|
||||
class TestPaginateUnified:
|
||||
"""Tests for the unified GET /articles/ endpoint using paginate()."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_defaults_to_offset_pagination(
|
||||
self, client: AsyncClient, ex_db_session
|
||||
):
|
||||
"""Without pagination_type, defaults to offset pagination."""
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["pagination_type"] == "offset"
|
||||
assert "total_count" in body["pagination"]
|
||||
assert body["pagination"]["total_count"] == 3
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_explicit_offset_pagination(self, client: AsyncClient, ex_db_session):
|
||||
"""pagination_type=offset returns OffsetPagination metadata."""
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get(
|
||||
"/articles/?pagination_type=offset&page=1&items_per_page=2"
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["pagination_type"] == "offset"
|
||||
assert body["pagination"]["total_count"] == 3
|
||||
assert body["pagination"]["page"] == 1
|
||||
assert body["pagination"]["has_more"] is True
|
||||
assert len(body["data"]) == 2
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cursor_pagination_type(self, client: AsyncClient, ex_db_session):
|
||||
"""pagination_type=cursor returns CursorPagination metadata."""
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/?pagination_type=cursor&items_per_page=2")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["pagination_type"] == "cursor"
|
||||
assert "next_cursor" in body["pagination"]
|
||||
assert "total_count" not in body["pagination"]
|
||||
assert body["pagination"]["has_more"] is True
|
||||
assert len(body["data"]) == 2
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cursor_pagination_navigate_pages(
|
||||
self, client: AsyncClient, ex_db_session
|
||||
):
|
||||
"""Cursor from first page can be used to fetch the next page."""
|
||||
await seed(ex_db_session)
|
||||
|
||||
first = await client.get("/articles/?pagination_type=cursor&items_per_page=2")
|
||||
assert first.status_code == 200
|
||||
first_body = first.json()
|
||||
next_cursor = first_body["pagination"]["next_cursor"]
|
||||
assert next_cursor is not None
|
||||
|
||||
second = await client.get(
|
||||
f"/articles/?pagination_type=cursor&items_per_page=2&cursor={next_cursor}"
|
||||
)
|
||||
assert second.status_code == 200
|
||||
second_body = second.json()
|
||||
assert second_body["pagination_type"] == "cursor"
|
||||
assert second_body["pagination"]["has_more"] is False
|
||||
assert len(second_body["data"]) == 1
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cursor_pagination_with_search(
|
||||
self, client: AsyncClient, ex_db_session
|
||||
):
|
||||
"""paginate() with cursor type respects search parameter."""
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/?pagination_type=cursor&search=fastapi")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["pagination_type"] == "cursor"
|
||||
assert len(body["data"]) == 1
|
||||
assert body["data"][0]["title"] == "FastAPI tips"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offset_pagination_with_filter(
|
||||
self, client: AsyncClient, ex_db_session
|
||||
):
|
||||
"""paginate() with offset type respects filter_by parameter."""
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/?pagination_type=offset&status=published")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["pagination_type"] == "offset"
|
||||
assert body["pagination"]["total_count"] == 2
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for fastapi_toolsets.fixtures module."""
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -16,7 +17,20 @@ from fastapi_toolsets.fixtures import (
|
||||
|
||||
from fastapi_toolsets.fixtures.utils import _get_primary_key
|
||||
|
||||
from .conftest import IntRole, Permission, Role, User
|
||||
from .conftest import IntRole, Permission, Role, RoleCrud, User, UserCrud
|
||||
|
||||
|
||||
class AppContext(str, Enum):
|
||||
"""Example user-defined str+Enum context."""
|
||||
|
||||
STAGING = "staging"
|
||||
DEMO = "demo"
|
||||
|
||||
|
||||
class PlainEnumContext(Enum):
|
||||
"""Example user-defined plain Enum context (no str mixin)."""
|
||||
|
||||
STAGING = "staging"
|
||||
|
||||
|
||||
class TestContext:
|
||||
@@ -39,6 +53,86 @@ class TestContext:
|
||||
assert Context.TESTING.value == "testing"
|
||||
|
||||
|
||||
class TestCustomEnumContext:
|
||||
"""Custom Enum types are accepted wherever Context/str are expected."""
|
||||
|
||||
def test_cannot_subclass_context_with_members(self):
|
||||
"""Python prohibits extending an Enum that already has members."""
|
||||
with pytest.raises(TypeError):
|
||||
|
||||
class MyContext(Context): # noqa: F841 # ty: ignore[subclass-of-final-class]
|
||||
STAGING = "staging"
|
||||
|
||||
def test_custom_enum_values_interchangeable_with_context(self):
|
||||
"""A custom enum with the same .value as a built-in Context member is
|
||||
treated as the same context — fixtures registered under one are found
|
||||
by the other."""
|
||||
|
||||
class AppContextFull(str, Enum):
|
||||
BASE = "base"
|
||||
STAGING = "staging"
|
||||
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[Context.BASE])
|
||||
def roles():
|
||||
return []
|
||||
|
||||
# AppContextFull.BASE has value "base" — same as Context.BASE
|
||||
fixtures = registry.get_by_context(AppContextFull.BASE)
|
||||
assert len(fixtures) == 1
|
||||
|
||||
def test_custom_enum_registry_default_contexts(self):
|
||||
"""FixtureRegistry(contexts=[...]) accepts a custom Enum."""
|
||||
registry = FixtureRegistry(contexts=[AppContext.STAGING])
|
||||
|
||||
@registry.register
|
||||
def data():
|
||||
return []
|
||||
|
||||
fixture = registry.get("data")
|
||||
assert fixture.contexts == ["staging"]
|
||||
|
||||
def test_custom_enum_resolve_context_dependencies(self):
|
||||
"""resolve_context_dependencies accepts a custom Enum context."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[AppContext.STAGING])
|
||||
def staging_roles():
|
||||
return []
|
||||
|
||||
order = registry.resolve_context_dependencies(AppContext.STAGING)
|
||||
assert "staging_roles" in order
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_custom_enum_e2e(self, db_session: AsyncSession):
|
||||
"""End-to-end: register with custom Enum, load with the same Enum."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[AppContext.STAGING])
|
||||
def staging_roles():
|
||||
return [Role(id=uuid.uuid4(), name="staging-admin")]
|
||||
|
||||
result = await load_fixtures_by_context(
|
||||
db_session, registry, AppContext.STAGING
|
||||
)
|
||||
assert len(result["staging_roles"]) == 1
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_plain_enum_e2e(self, db_session: AsyncSession):
|
||||
"""End-to-end: register with plain Enum, load with the same Enum."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[PlainEnumContext.STAGING])
|
||||
def staging_roles():
|
||||
return [Role(id=uuid.uuid4(), name="plain-staging-admin")]
|
||||
|
||||
result = await load_fixtures_by_context(
|
||||
db_session, registry, PlainEnumContext.STAGING
|
||||
)
|
||||
assert len(result["staging_roles"]) == 1
|
||||
|
||||
|
||||
class TestLoadStrategy:
|
||||
"""Tests for LoadStrategy enum."""
|
||||
|
||||
@@ -407,6 +501,37 @@ class TestDependencyResolution:
|
||||
with pytest.raises(ValueError, match="Circular dependency"):
|
||||
registry.resolve_dependencies("a")
|
||||
|
||||
def test_resolve_raises_for_unknown_dependency(self):
|
||||
"""KeyError when depends_on references an unregistered fixture."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(depends_on=["ghost"])
|
||||
def users():
|
||||
return []
|
||||
|
||||
with pytest.raises(KeyError, match="ghost"):
|
||||
registry.resolve_dependencies("users")
|
||||
|
||||
def test_resolve_deduplicates_shared_depends_on_across_variants(self):
|
||||
"""A dep shared by two same-name variants appears only once in the order."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[Context.BASE])
|
||||
def roles():
|
||||
return []
|
||||
|
||||
@registry.register(depends_on=["roles"], contexts=[Context.BASE])
|
||||
def items():
|
||||
return []
|
||||
|
||||
@registry.register(depends_on=["roles"], contexts=[Context.TESTING])
|
||||
def items(): # noqa: F811
|
||||
return []
|
||||
|
||||
order = registry.resolve_dependencies("items")
|
||||
assert order.count("roles") == 1
|
||||
assert order.index("roles") < order.index("items")
|
||||
|
||||
def test_resolve_context_dependencies(self):
|
||||
"""Resolve all fixtures for a context with dependencies."""
|
||||
registry = FixtureRegistry()
|
||||
@@ -447,8 +572,6 @@ class TestLoadFixtures:
|
||||
assert "roles" in result
|
||||
assert len(result["roles"]) == 2
|
||||
|
||||
from .conftest import RoleCrud
|
||||
|
||||
count = await RoleCrud.count(db_session)
|
||||
assert count == 2
|
||||
|
||||
@@ -479,8 +602,6 @@ class TestLoadFixtures:
|
||||
assert "roles" in result
|
||||
assert "users" in result
|
||||
|
||||
from .conftest import RoleCrud, UserCrud
|
||||
|
||||
assert await RoleCrud.count(db_session) == 1
|
||||
assert await UserCrud.count(db_session) == 1
|
||||
|
||||
@@ -497,8 +618,6 @@ class TestLoadFixtures:
|
||||
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
||||
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
||||
|
||||
from .conftest import RoleCrud
|
||||
|
||||
count = await RoleCrud.count(db_session)
|
||||
assert count == 1
|
||||
|
||||
@@ -526,8 +645,6 @@ class TestLoadFixtures:
|
||||
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||
)
|
||||
|
||||
from .conftest import RoleCrud
|
||||
|
||||
role = await RoleCrud.first(db_session, [Role.id == role_id])
|
||||
assert role is not None
|
||||
assert role.name == "original"
|
||||
@@ -553,8 +670,6 @@ class TestLoadFixtures:
|
||||
assert "roles" in result
|
||||
assert len(result["roles"]) == 2
|
||||
|
||||
from .conftest import RoleCrud
|
||||
|
||||
count = await RoleCrud.count(db_session)
|
||||
assert count == 2
|
||||
|
||||
@@ -594,8 +709,6 @@ class TestLoadFixtures:
|
||||
assert "roles" in result
|
||||
assert "other_roles" in result
|
||||
|
||||
from .conftest import RoleCrud
|
||||
|
||||
count = await RoleCrud.count(db_session)
|
||||
assert count == 2
|
||||
|
||||
@@ -660,8 +773,6 @@ class TestLoadFixturesByContext:
|
||||
|
||||
await load_fixtures_by_context(db_session, registry, Context.BASE)
|
||||
|
||||
from .conftest import RoleCrud
|
||||
|
||||
count = await RoleCrud.count(db_session)
|
||||
assert count == 1
|
||||
|
||||
@@ -688,8 +799,6 @@ class TestLoadFixturesByContext:
|
||||
db_session, registry, Context.BASE, Context.TESTING
|
||||
)
|
||||
|
||||
from .conftest import RoleCrud
|
||||
|
||||
count = await RoleCrud.count(db_session)
|
||||
assert count == 2
|
||||
|
||||
@@ -717,8 +826,6 @@ class TestLoadFixturesByContext:
|
||||
|
||||
await load_fixtures_by_context(db_session, registry, Context.TESTING)
|
||||
|
||||
from .conftest import RoleCrud, UserCrud
|
||||
|
||||
assert await RoleCrud.count(db_session) == 1
|
||||
assert await UserCrud.count(db_session) == 1
|
||||
|
||||
@@ -813,3 +920,28 @@ class TestGetPrimaryKey:
|
||||
instance = Permission(subject="post") # action is None
|
||||
pk = _get_primary_key(instance)
|
||||
assert pk is None
|
||||
|
||||
|
||||
class TestRegistryGetVariants:
|
||||
"""Tests for FixtureRegistry.get and get_variants edge cases."""
|
||||
|
||||
def test_get_raises_value_error_for_multi_variant(self):
|
||||
"""get() raises ValueError when the fixture has multiple context variants."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[Context.BASE])
|
||||
def items():
|
||||
return []
|
||||
|
||||
@registry.register(contexts=[Context.TESTING])
|
||||
def items(): # noqa: F811
|
||||
return []
|
||||
|
||||
with pytest.raises(ValueError, match="get_variants"):
|
||||
registry.get("items")
|
||||
|
||||
def test_get_variants_raises_key_error_for_unknown(self):
|
||||
"""get_variants() raises KeyError for an unregistered name."""
|
||||
registry = FixtureRegistry()
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
registry.get_variants("no_such_fixture")
|
||||
|
||||
@@ -101,7 +101,7 @@ class TestMetricsImportGuard:
|
||||
with patch("builtins.__import__", side_effect=blocking_import):
|
||||
mod = importlib.import_module("fastapi_toolsets.metrics")
|
||||
with pytest.raises(ImportError, match="prometheus_client"):
|
||||
mod.init_metrics(None, None) # type: ignore[arg-type]
|
||||
mod.init_metrics(None, None) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
||||
finally:
|
||||
for key in list(sys.modules):
|
||||
if key.startswith("fastapi_toolsets.metrics"):
|
||||
@@ -171,8 +171,15 @@ class TestPytestImportGuard:
|
||||
class TestCliImportGuard:
|
||||
"""Tests for CLI module import guard when typer is missing."""
|
||||
|
||||
def test_import_raises_without_typer(self):
|
||||
"""Importing cli.app raises when typer is missing."""
|
||||
@pytest.mark.parametrize(
|
||||
"expected_match",
|
||||
[
|
||||
"typer",
|
||||
r"pip install fastapi-toolsets\[cli\]",
|
||||
],
|
||||
)
|
||||
def test_import_raises_without_typer(self, expected_match):
|
||||
"""Importing cli.app raises when typer is missing, with an informative error message."""
|
||||
saved, blocking_import = _reload_without_package(
|
||||
"fastapi_toolsets.cli.app", ["typer"]
|
||||
)
|
||||
@@ -186,33 +193,7 @@ class TestCliImportGuard:
|
||||
|
||||
try:
|
||||
with patch("builtins.__import__", side_effect=blocking_import):
|
||||
with pytest.raises(ImportError, match="typer"):
|
||||
importlib.import_module("fastapi_toolsets.cli.app")
|
||||
finally:
|
||||
for key in list(sys.modules):
|
||||
if key.startswith("fastapi_toolsets.cli.app") or key.startswith(
|
||||
"fastapi_toolsets.cli.config"
|
||||
):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules.update(saved)
|
||||
|
||||
def test_error_message_suggests_cli_extra(self):
|
||||
"""Error message suggests installing the cli extra."""
|
||||
saved, blocking_import = _reload_without_package(
|
||||
"fastapi_toolsets.cli.app", ["typer"]
|
||||
)
|
||||
config_keys = [
|
||||
k for k in sys.modules if k.startswith("fastapi_toolsets.cli.config")
|
||||
]
|
||||
for key in config_keys:
|
||||
if key not in saved:
|
||||
saved[key] = sys.modules.pop(key)
|
||||
|
||||
try:
|
||||
with patch("builtins.__import__", side_effect=blocking_import):
|
||||
with pytest.raises(
|
||||
ImportError, match=r"pip install fastapi-toolsets\[cli\]"
|
||||
):
|
||||
with pytest.raises(ImportError, match=expected_match):
|
||||
importlib.import_module("fastapi_toolsets.cli.app")
|
||||
finally:
|
||||
for key in list(sys.modules):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for fastapi_toolsets.metrics module."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
@@ -287,6 +286,16 @@ class TestIncludeRegistry:
|
||||
class TestInitMetrics:
|
||||
"""Tests for init_metrics function."""
|
||||
|
||||
@pytest.fixture
|
||||
def metrics_client(self):
|
||||
"""Create a FastAPI app with MetricsRegistry and return a TestClient."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
init_metrics(app, registry)
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
def test_returns_app(self):
|
||||
"""Returns the FastAPI app."""
|
||||
app = FastAPI()
|
||||
@@ -294,26 +303,14 @@ class TestInitMetrics:
|
||||
result = init_metrics(app, registry)
|
||||
assert result is app
|
||||
|
||||
def test_metrics_endpoint_responds(self):
|
||||
def test_metrics_endpoint_responds(self, metrics_client):
|
||||
"""The /metrics endpoint returns 200."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
init_metrics(app, registry)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/metrics")
|
||||
|
||||
response = metrics_client.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_metrics_endpoint_content_type(self):
|
||||
def test_metrics_endpoint_content_type(self, metrics_client):
|
||||
"""The /metrics endpoint returns prometheus content type."""
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
init_metrics(app, registry)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/metrics")
|
||||
|
||||
response = metrics_client.get("/metrics")
|
||||
assert "text/plain" in response.headers["content-type"]
|
||||
|
||||
def test_custom_path(self):
|
||||
@@ -445,11 +442,10 @@ class TestInitMetrics:
|
||||
class TestMultiProcessMode:
|
||||
"""Tests for multi-process Prometheus mode."""
|
||||
|
||||
def test_multiprocess_with_env_var(self):
|
||||
def test_multiprocess_with_env_var(self, monkeypatch):
|
||||
"""Multi-process mode works when PROMETHEUS_MULTIPROC_DIR is set."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.environ["PROMETHEUS_MULTIPROC_DIR"] = tmpdir
|
||||
try:
|
||||
monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", tmpdir)
|
||||
# Use a separate registry to avoid conflicts with default
|
||||
prom_registry = CollectorRegistry()
|
||||
app = FastAPI()
|
||||
@@ -469,12 +465,10 @@ class TestMultiProcessMode:
|
||||
response = client.get("/metrics")
|
||||
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
del os.environ["PROMETHEUS_MULTIPROC_DIR"]
|
||||
|
||||
def test_single_process_without_env_var(self):
|
||||
def test_single_process_without_env_var(self, monkeypatch):
|
||||
"""Single-process mode when PROMETHEUS_MULTIPROC_DIR is not set."""
|
||||
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
|
||||
monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR", raising=False)
|
||||
|
||||
app = FastAPI()
|
||||
registry = MetricsRegistry()
|
||||
|
||||
1068
tests/test_models.py
1068
tests/test_models.py
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,10 @@ from fastapi import Depends, FastAPI
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
from fastapi_toolsets.fixtures import Context, FixtureRegistry
|
||||
from fastapi_toolsets.pytest import (
|
||||
create_async_client,
|
||||
@@ -336,6 +337,55 @@ class TestCreateDbSession:
|
||||
result = await session.execute(select(Role))
|
||||
assert result.all() == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_transaction_commits_visible_to_separate_session(self):
|
||||
"""Data written via get_transaction() is committed and visible to other sessions."""
|
||||
role_id = uuid.uuid4()
|
||||
|
||||
async with create_db_session(DATABASE_URL, Base, drop_tables=False) as session:
|
||||
# Simulate what _create_fixture_function does: insert via get_transaction
|
||||
# with no explicit commit afterward.
|
||||
async with get_transaction(session):
|
||||
role = Role(id=role_id, name="visible_to_other_session")
|
||||
session.add(role)
|
||||
|
||||
# The data must have been committed (begin/commit, not a savepoint),
|
||||
# so a separate engine/session can read it.
|
||||
other_engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
try:
|
||||
other_session_maker = async_sessionmaker(
|
||||
other_engine, expire_on_commit=False
|
||||
)
|
||||
async with other_session_maker() as other:
|
||||
result = await other.execute(select(Role).where(Role.id == role_id))
|
||||
fetched = result.scalar_one_or_none()
|
||||
assert fetched is not None, (
|
||||
"Fixture data inserted via get_transaction() must be committed "
|
||||
"and visible to a separate session. If create_db_session uses "
|
||||
"create_db_context, auto-begin forces get_transaction() into "
|
||||
"savepoints instead of real commits."
|
||||
)
|
||||
assert fetched.name == "visible_to_other_session"
|
||||
finally:
|
||||
await other_engine.dispose()
|
||||
|
||||
# Cleanup
|
||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as _:
|
||||
pass
|
||||
|
||||
|
||||
class TestDeprecatedCleanupTables:
|
||||
"""Tests for the deprecated cleanup_tables re-export in fastapi_toolsets.pytest."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_emits_deprecation_warning(self):
|
||||
"""cleanup_tables imported from fastapi_toolsets.pytest emits DeprecationWarning."""
|
||||
from fastapi_toolsets.pytest.utils import cleanup_tables
|
||||
|
||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
||||
with pytest.warns(DeprecationWarning, match="fastapi_toolsets.db"):
|
||||
await cleanup_tables(session, Base)
|
||||
|
||||
|
||||
class TestGetXdistWorker:
|
||||
"""Tests for _get_xdist_worker helper."""
|
||||
|
||||
@@ -6,9 +6,12 @@ from pydantic import ValidationError
|
||||
from fastapi_toolsets.schemas import (
|
||||
ApiError,
|
||||
CursorPagination,
|
||||
CursorPaginatedResponse,
|
||||
ErrorResponse,
|
||||
OffsetPagination,
|
||||
OffsetPaginatedResponse,
|
||||
PaginatedResponse,
|
||||
PaginationType,
|
||||
Response,
|
||||
ResponseStatus,
|
||||
)
|
||||
@@ -198,6 +201,88 @@ class TestOffsetPagination:
|
||||
assert data["page"] == 2
|
||||
assert data["has_more"] is True
|
||||
|
||||
def test_total_count_can_be_none(self):
|
||||
"""total_count accepts None (include_total=False mode)."""
|
||||
pagination = OffsetPagination(
|
||||
total_count=None,
|
||||
items_per_page=20,
|
||||
page=1,
|
||||
has_more=True,
|
||||
)
|
||||
assert pagination.total_count is None
|
||||
|
||||
def test_serialization_with_none_total_count(self):
|
||||
"""OffsetPagination serializes total_count=None correctly."""
|
||||
pagination = OffsetPagination(
|
||||
total_count=None,
|
||||
items_per_page=20,
|
||||
page=1,
|
||||
has_more=False,
|
||||
)
|
||||
data = pagination.model_dump()
|
||||
assert data["total_count"] is None
|
||||
|
||||
def test_pages_computed(self):
|
||||
"""pages is ceil(total_count / items_per_page)."""
|
||||
pagination = OffsetPagination(
|
||||
total_count=42,
|
||||
items_per_page=10,
|
||||
page=1,
|
||||
has_more=True,
|
||||
)
|
||||
assert pagination.pages == 5
|
||||
|
||||
def test_pages_exact_division(self):
|
||||
"""pages is exact when total_count is evenly divisible."""
|
||||
pagination = OffsetPagination(
|
||||
total_count=40,
|
||||
items_per_page=10,
|
||||
page=1,
|
||||
has_more=False,
|
||||
)
|
||||
assert pagination.pages == 4
|
||||
|
||||
def test_pages_zero_total(self):
|
||||
"""pages is 0 when total_count is 0."""
|
||||
pagination = OffsetPagination(
|
||||
total_count=0,
|
||||
items_per_page=10,
|
||||
page=1,
|
||||
has_more=False,
|
||||
)
|
||||
assert pagination.pages == 0
|
||||
|
||||
def test_pages_zero_items_per_page(self):
|
||||
"""pages is 0 when items_per_page is 0."""
|
||||
pagination = OffsetPagination(
|
||||
total_count=100,
|
||||
items_per_page=0,
|
||||
page=1,
|
||||
has_more=False,
|
||||
)
|
||||
assert pagination.pages == 0
|
||||
|
||||
def test_pages_none_when_total_count_none(self):
|
||||
"""pages is None when total_count is None (include_total=False)."""
|
||||
pagination = OffsetPagination(
|
||||
total_count=None,
|
||||
items_per_page=20,
|
||||
page=1,
|
||||
has_more=True,
|
||||
)
|
||||
assert pagination.pages is None
|
||||
|
||||
def test_pages_in_serialization(self):
|
||||
"""pages appears in model_dump output."""
|
||||
pagination = OffsetPagination(
|
||||
total_count=25,
|
||||
items_per_page=10,
|
||||
page=1,
|
||||
has_more=True,
|
||||
)
|
||||
data = pagination.model_dump()
|
||||
assert data["pages"] == 3
|
||||
|
||||
|
||||
class TestCursorPagination:
|
||||
"""Tests for CursorPagination schema."""
|
||||
@@ -301,7 +386,7 @@ class TestPaginatedResponse:
|
||||
page=1,
|
||||
has_more=False,
|
||||
)
|
||||
response = PaginatedResponse[dict](
|
||||
response = PaginatedResponse(
|
||||
data=[],
|
||||
pagination=pagination,
|
||||
)
|
||||
@@ -310,13 +395,32 @@ class TestPaginatedResponse:
|
||||
assert response.data == []
|
||||
assert response.pagination.total_count == 0
|
||||
|
||||
def test_class_getitem_with_concrete_type_returns_discriminated_union(self):
|
||||
"""PaginatedResponse[T] with a concrete type returns a discriminated Annotated union."""
|
||||
import typing
|
||||
|
||||
alias = PaginatedResponse[dict]
|
||||
args = typing.get_args(alias)
|
||||
# args[0] is the Union, args[1] is the FieldInfo discriminator
|
||||
union_args = typing.get_args(args[0])
|
||||
assert CursorPaginatedResponse[dict] in union_args
|
||||
assert OffsetPaginatedResponse[dict] in union_args
|
||||
|
||||
def test_class_getitem_is_cached(self):
|
||||
"""Repeated subscripting with the same type returns the identical cached object."""
|
||||
assert PaginatedResponse[dict] is PaginatedResponse[dict]
|
||||
|
||||
def test_class_getitem_with_typevar_returns_generic(self):
|
||||
"""PaginatedResponse[TypeVar] falls through to Pydantic generic parametrisation."""
|
||||
from typing import TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
alias = PaginatedResponse[T]
|
||||
# Should be a generic alias, not an Annotated union
|
||||
assert not hasattr(alias, "__metadata__")
|
||||
|
||||
def test_generic_type_hint(self):
|
||||
"""PaginatedResponse supports generic type hints."""
|
||||
|
||||
class UserOut:
|
||||
id: int
|
||||
name: str
|
||||
|
||||
pagination = OffsetPagination(
|
||||
total_count=1,
|
||||
items_per_page=10,
|
||||
@@ -371,6 +475,191 @@ class TestPaginatedResponse:
|
||||
assert isinstance(response.pagination, CursorPagination)
|
||||
|
||||
|
||||
class TestPaginationType:
|
||||
"""Tests for PaginationType enum."""
|
||||
|
||||
def test_offset_value(self):
|
||||
"""OFFSET has string value 'offset'."""
|
||||
assert PaginationType.OFFSET == "offset"
|
||||
assert PaginationType.OFFSET.value == "offset"
|
||||
|
||||
def test_cursor_value(self):
|
||||
"""CURSOR has string value 'cursor'."""
|
||||
assert PaginationType.CURSOR == "cursor"
|
||||
assert PaginationType.CURSOR.value == "cursor"
|
||||
|
||||
def test_is_string_enum(self):
|
||||
"""PaginationType is a string enum."""
|
||||
assert isinstance(PaginationType.OFFSET, str)
|
||||
assert isinstance(PaginationType.CURSOR, str)
|
||||
|
||||
def test_members(self):
|
||||
"""PaginationType has exactly two members."""
|
||||
assert set(PaginationType) == {PaginationType.OFFSET, PaginationType.CURSOR}
|
||||
|
||||
|
||||
class TestOffsetPaginatedResponse:
|
||||
"""Tests for OffsetPaginatedResponse schema."""
|
||||
|
||||
def test_pagination_type_is_offset(self):
|
||||
"""pagination_type is always PaginationType.OFFSET."""
|
||||
response = OffsetPaginatedResponse(
|
||||
data=[],
|
||||
pagination=OffsetPagination(
|
||||
total_count=0, items_per_page=10, page=1, has_more=False
|
||||
),
|
||||
)
|
||||
assert response.pagination_type is PaginationType.OFFSET
|
||||
|
||||
def test_pagination_type_serializes_to_string(self):
|
||||
"""pagination_type serializes to 'offset' in JSON mode."""
|
||||
response = OffsetPaginatedResponse(
|
||||
data=[],
|
||||
pagination=OffsetPagination(
|
||||
total_count=0, items_per_page=10, page=1, has_more=False
|
||||
),
|
||||
)
|
||||
assert response.model_dump(mode="json")["pagination_type"] == "offset"
|
||||
|
||||
def test_pagination_field_is_typed(self):
|
||||
"""pagination field is OffsetPagination, not the union."""
|
||||
response = OffsetPaginatedResponse(
|
||||
data=[{"id": 1}],
|
||||
pagination=OffsetPagination(
|
||||
total_count=10, items_per_page=5, page=2, has_more=True
|
||||
),
|
||||
)
|
||||
assert isinstance(response.pagination, OffsetPagination)
|
||||
assert response.pagination.total_count == 10
|
||||
assert response.pagination.page == 2
|
||||
|
||||
def test_is_subclass_of_paginated_response(self):
|
||||
"""OffsetPaginatedResponse IS a PaginatedResponse."""
|
||||
response = OffsetPaginatedResponse(
|
||||
data=[],
|
||||
pagination=OffsetPagination(
|
||||
total_count=0, items_per_page=10, page=1, has_more=False
|
||||
),
|
||||
)
|
||||
assert isinstance(response, PaginatedResponse)
|
||||
|
||||
def test_pagination_type_default_cannot_be_overridden_to_cursor(self):
|
||||
"""pagination_type rejects values other than OFFSET."""
|
||||
with pytest.raises(ValidationError):
|
||||
OffsetPaginatedResponse(
|
||||
data=[],
|
||||
pagination=OffsetPagination(
|
||||
total_count=0, items_per_page=10, page=1, has_more=False
|
||||
),
|
||||
pagination_type=PaginationType.CURSOR, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
||||
)
|
||||
|
||||
def test_filter_attributes_defaults_to_none(self):
|
||||
"""filter_attributes defaults to None."""
|
||||
response = OffsetPaginatedResponse(
|
||||
data=[],
|
||||
pagination=OffsetPagination(
|
||||
total_count=0, items_per_page=10, page=1, has_more=False
|
||||
),
|
||||
)
|
||||
assert response.filter_attributes is None
|
||||
|
||||
def test_full_serialization(self):
|
||||
"""Full JSON serialization includes all expected fields."""
|
||||
response = OffsetPaginatedResponse(
|
||||
data=[{"id": 1}],
|
||||
pagination=OffsetPagination(
|
||||
total_count=1, items_per_page=10, page=1, has_more=False
|
||||
),
|
||||
filter_attributes={"status": ["active"]},
|
||||
)
|
||||
data = response.model_dump(mode="json")
|
||||
|
||||
assert data["pagination_type"] == "offset"
|
||||
assert data["status"] == "SUCCESS"
|
||||
assert data["data"] == [{"id": 1}]
|
||||
assert data["pagination"]["total_count"] == 1
|
||||
assert data["filter_attributes"] == {"status": ["active"]}
|
||||
|
||||
|
||||
class TestCursorPaginatedResponse:
|
||||
"""Tests for CursorPaginatedResponse schema."""
|
||||
|
||||
def test_pagination_type_is_cursor(self):
|
||||
"""pagination_type is always PaginationType.CURSOR."""
|
||||
response = CursorPaginatedResponse(
|
||||
data=[],
|
||||
pagination=CursorPagination(
|
||||
next_cursor=None, items_per_page=10, has_more=False
|
||||
),
|
||||
)
|
||||
assert response.pagination_type is PaginationType.CURSOR
|
||||
|
||||
def test_pagination_type_serializes_to_string(self):
|
||||
"""pagination_type serializes to 'cursor' in JSON mode."""
|
||||
response = CursorPaginatedResponse(
|
||||
data=[],
|
||||
pagination=CursorPagination(
|
||||
next_cursor=None, items_per_page=10, has_more=False
|
||||
),
|
||||
)
|
||||
assert response.model_dump(mode="json")["pagination_type"] == "cursor"
|
||||
|
||||
def test_pagination_field_is_typed(self):
|
||||
"""pagination field is CursorPagination, not the union."""
|
||||
response = CursorPaginatedResponse(
|
||||
data=[{"id": 1}],
|
||||
pagination=CursorPagination(
|
||||
next_cursor="abc123",
|
||||
prev_cursor=None,
|
||||
items_per_page=20,
|
||||
has_more=True,
|
||||
),
|
||||
)
|
||||
assert isinstance(response.pagination, CursorPagination)
|
||||
assert response.pagination.next_cursor == "abc123"
|
||||
assert response.pagination.has_more is True
|
||||
|
||||
def test_is_subclass_of_paginated_response(self):
|
||||
"""CursorPaginatedResponse IS a PaginatedResponse."""
|
||||
response = CursorPaginatedResponse(
|
||||
data=[],
|
||||
pagination=CursorPagination(
|
||||
next_cursor=None, items_per_page=10, has_more=False
|
||||
),
|
||||
)
|
||||
assert isinstance(response, PaginatedResponse)
|
||||
|
||||
def test_pagination_type_default_cannot_be_overridden_to_offset(self):
|
||||
"""pagination_type rejects values other than CURSOR."""
|
||||
with pytest.raises(ValidationError):
|
||||
CursorPaginatedResponse(
|
||||
data=[],
|
||||
pagination=CursorPagination(
|
||||
next_cursor=None, items_per_page=10, has_more=False
|
||||
),
|
||||
pagination_type=PaginationType.OFFSET, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
||||
)
|
||||
|
||||
def test_full_serialization(self):
|
||||
"""Full JSON serialization includes all expected fields."""
|
||||
response = CursorPaginatedResponse(
|
||||
data=[{"id": 1}],
|
||||
pagination=CursorPagination(
|
||||
next_cursor="tok_next",
|
||||
prev_cursor="tok_prev",
|
||||
items_per_page=10,
|
||||
has_more=True,
|
||||
),
|
||||
)
|
||||
data = response.model_dump(mode="json")
|
||||
|
||||
assert data["pagination_type"] == "cursor"
|
||||
assert data["status"] == "SUCCESS"
|
||||
assert data["pagination"]["next_cursor"] == "tok_next"
|
||||
assert data["pagination"]["prev_cursor"] == "tok_prev"
|
||||
|
||||
|
||||
class TestFromAttributes:
|
||||
"""Tests for from_attributes config (ORM mode)."""
|
||||
|
||||
|
||||
323
uv.lock
generated
323
uv.lock
generated
@@ -113,101 +113,101 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.4"
|
||||
version = "7.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -251,7 +251,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-toolsets"
|
||||
version = "2.2.0"
|
||||
version = "2.4.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "asyncpg" },
|
||||
@@ -286,6 +286,7 @@ dev = [
|
||||
{ name = "fastapi-toolsets", extra = ["all"] },
|
||||
{ name = "httpx" },
|
||||
{ name = "mkdocstrings-python" },
|
||||
{ name = "prek" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-anyio" },
|
||||
{ name = "pytest-cov" },
|
||||
@@ -328,6 +329,7 @@ dev = [
|
||||
{ name = "fastapi-toolsets", extras = ["all"] },
|
||||
{ name = "httpx", specifier = ">=0.25.0" },
|
||||
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
||||
{ name = "prek", specifier = ">=0.3.8" },
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-anyio", specifier = ">=0.0.0" },
|
||||
{ name = "pytest-cov", specifier = ">=4.0.0" },
|
||||
@@ -417,6 +419,7 @@ wheels = [
|
||||
name = "griffelib"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
|
||||
]
|
||||
@@ -720,6 +723,30 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prek"
|
||||
version = "0.3.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/62/ee/03e8180e3fda9de25b6480bd15cc2bde40d573868d50648b0e527b35562f/prek-0.3.8.tar.gz", hash = "sha256:434a214256516f187a3ab15f869d950243be66b94ad47987ee4281b69643a2d9", size = 400224, upload-time = "2026-03-23T08:23:35.981Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/84/40d2ddf362d12c4cd4a25a8c89a862edf87cdfbf1422aa41aac8e315d409/prek-0.3.8-py3-none-linux_armv6l.whl", hash = "sha256:6fb646ada60658fa6dd7771b2e0fb097f005151be222f869dada3eb26d79ed33", size = 5226646, upload-time = "2026-03-23T08:23:18.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/52/7308a033fa43b7e8e188797bd2b3b017c0f0adda70fa7af575b1f43ea888/prek-0.3.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3d7fdadb15efc19c09953c7a33cf2061a70f367d1e1957358d3ad5cc49d0616", size = 5620104, upload-time = "2026-03-23T08:23:40.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b1/f106ac000a91511a9cd80169868daf2f5b693480ef5232cec5517a38a512/prek-0.3.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:72728c3295e79ca443f8c1ec037d2a5b914ec73a358f69cf1bc1964511876bf8", size = 5199867, upload-time = "2026-03-23T08:23:38.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e9/970713f4b019f69de9844e1bab37b8ddb67558e410916f4eb5869a696165/prek-0.3.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:48efc28f2f53b5b8087efca9daaed91572d62df97d5f24a1c7a087fecb5017de", size = 5441801, upload-time = "2026-03-23T08:23:32.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/a4/7ef44032b181753e19452ec3b09abb3a32607cf6b0a0508f0604becaaf2b/prek-0.3.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6ca9d63bacbc448a5c18e955c78d3ac5176c3a17c3baacdd949b1a623e08a36", size = 5155107, upload-time = "2026-03-23T08:23:31.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/77/4d9c8985dbba84149760785dfe07093ea1e29d710257dfb7c89615e2234c/prek-0.3.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1000f7029696b4fe712fb1fefd4c55b9c4de72b65509c8e50296370a06f9dc3f", size = 5566541, upload-time = "2026-03-23T08:23:45.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/1a/81e6769ac1f7f8346d09ce2ab0b47cf06466acd9ff72e87e5d1f0d98cd32/prek-0.3.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ff0bed0e2c1286522987d982168a86cbbd0d069d840506a46c9fda983515517", size = 6552991, upload-time = "2026-03-23T08:23:21.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/fa/ce2df0dd2dc75a9437a52463239d0782998943d7b04e191fb89b83016c34/prek-0.3.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fb087ac0ffda3ac65bbbae9a38326a7fd27ee007bb4a94323ce1eb539d8bbec", size = 5832972, upload-time = "2026-03-23T08:23:20.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/6b/9d4269df9073216d296244595a21c253b6475dfc9076c0bd2906be7a436c/prek-0.3.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2e1e5e206ff7b31bd079cce525daddc96cd6bc544d20dc128921ad92f7a4c85d", size = 5448371, upload-time = "2026-03-23T08:23:41.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/1d/1e4d8a78abefa5b9d086e5a9f1638a74b5e540eec8a648d9946707701f29/prek-0.3.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dcea3fe23832a4481bccb7c45f55650cb233be7c805602e788bb7dba60f2d861", size = 5270546, upload-time = "2026-03-23T08:23:24.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/07/34f36551a6319ae36e272bea63a42f59d41d2d47ab0d5fb00eb7b4e88e87/prek-0.3.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:4d25e647e9682f6818ab5c31e7a4b842993c14782a6ffcd128d22b784e0d677f", size = 5124032, upload-time = "2026-03-23T08:23:26.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/01/6d544009bb655e709993411796af77339f439526db4f3b3509c583ad8eb9/prek-0.3.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de528b82935e33074815acff3c7c86026754d1212136295bc88fe9c43b4231d5", size = 5432245, upload-time = "2026-03-23T08:23:47.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/96/1237ee269e9bfa283ffadbcba1f401f48a47aed2b2563eb1002740d6079d/prek-0.3.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6d660f1c25a126e6d9f682fe61449441226514f412a4469f5d71f8f8cad56db2", size = 5950550, upload-time = "2026-03-23T08:23:43.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/6b/a574411459049bc691047c9912f375deda10c44a707b6ce98df2b658f0b3/prek-0.3.8-py3-none-win32.whl", hash = "sha256:b0c291c577615d9f8450421dff0b32bfd77a6b0d223ee4115a1f820cb636fdf1", size = 4949501, upload-time = "2026-03-23T08:23:16.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b4/46b59fe49f635acd9f6530778ce577f9d8b49452835726a5311ffc902c67/prek-0.3.8-py3-none-win_amd64.whl", hash = "sha256:bc147fdbdd4ec33fc7a987b893ecb69b1413ac100d95c9889a70f3fd58c73d06", size = 5346551, upload-time = "2026-03-23T08:23:34.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/05/9cca1708bb8c65264124eb4b04251e0f65ce5bfc707080bb6b492d5a0df7/prek-0.3.8-py3-none-win_arm64.whl", hash = "sha256:a2614647aeafa817a5802ccb9561e92eedc20dcf840639a1b00826e2c2442515", size = 5190872, upload-time = "2026-03-23T08:23:29.463Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometheus-client"
|
||||
version = "0.24.1"
|
||||
@@ -894,16 +921,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage", extra = ["toml"] },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1013,27 +1040,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.4"
|
||||
version = "0.15.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1177,26 +1204,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.20"
|
||||
version = "0.0.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/95/8de69bb98417227b01f1b1d743c819d6456c9fd140255b6124b05b17dfd6/ty-0.0.20.tar.gz", hash = "sha256:ebba6be7974c14efbb2a9adda6ac59848f880d7259f089dfa72a093039f1dcc6", size = 5262529, upload-time = "2026-03-02T15:51:36.587Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/bf/3c3147c7237277b0e8a911ff89de7183408be96b31fb42b38edb666d287f/ty-0.0.25.tar.gz", hash = "sha256:8ae3891be17dfb6acab51a2df3a8f8f6c551eb60ea674c10946dc92aae8d4401", size = 5375500, upload-time = "2026-03-24T22:32:34.608Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/2c/718abe48393e521bf852cd6b0f984766869b09c258d6e38a118768a91731/ty-0.0.20-py3-none-linux_armv6l.whl", hash = "sha256:7cc12769c169c9709a829c2248ee2826b7aae82e92caeac813d856f07c021eae", size = 10333656, upload-time = "2026-03-02T15:51:56.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/0e/eb1c4cc4a12862e2327b72657bcebb10b7d9f17046f1bdcd6457a0211615/ty-0.0.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b777c1bf13bc0a95985ebb8a324b8668a4a9b2e514dde5ccf09e4d55d2ff232", size = 10168505, upload-time = "2026-03-02T15:51:51.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/7f/10230798e673f0dd3094dfd16e43bfd90e9494e7af6e8e7db516fb431ddf/ty-0.0.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b2a4a7db48bf8cba30365001bc2cad7fd13c1a5aacdd704cc4b7925de8ca5eb3", size = 9678510, upload-time = "2026-03-02T15:51:48.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/3d/59d9159577494edd1728f7db77b51bb07884bd21384f517963114e3ab5f6/ty-0.0.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6846427b8b353a43483e9c19936dc6a25612573b44c8f7d983dfa317e7f00d4c", size = 10162926, upload-time = "2026-03-02T15:51:40.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/a8/b7273eec3e802f78eb913fbe0ce0c16ef263723173e06a5776a8359b2c66/ty-0.0.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:245ceef5bd88df366869385cf96411cb14696334f8daa75597cf7e41c3012eb8", size = 10171702, upload-time = "2026-03-02T15:51:44.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/32/5f1144f2f04a275109db06e3498450c4721554215b80ae73652ef412eeab/ty-0.0.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4d21d1cdf67a444d3c37583c17291ddba9382a9871021f3f5d5735e09e85efe", size = 10682552, upload-time = "2026-03-02T15:51:33.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/db/9f1f637310792f12bd6ed37d5fc8ab39ba1a9b0c6c55a33865e9f1cad840/ty-0.0.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd4ffd907d1bd70e46af9e9a2f88622f215e1bf44658ea43b32c2c0b357299e4", size = 11242605, upload-time = "2026-03-02T15:51:34.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/68/cc9cae2e732fcfd20ccdffc508407905a023fc8493b8771c392d915528dc/ty-0.0.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6594b58d8b0e9d16a22b3045fc1305db4b132c8d70c17784ab8c7a7cc986807", size = 10974655, upload-time = "2026-03-02T15:51:46.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/c1/b9e3e3f28fe63486331e653f6aeb4184af8b1fe80542fcf74d2dda40a93d/ty-0.0.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3662f890518ce6cf4d7568f57d03906912d2afbf948a01089a28e325b1ef198c", size = 10761325, upload-time = "2026-03-02T15:51:26.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/9e/67db935bdedf219a00fb69ec5437ba24dab66e0f2e706dd54a4eca234b84/ty-0.0.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e3ffbae58f9f0d17cdc4ac6d175ceae560b7ed7d54f9ddfb1c9f31054bcdc2c", size = 10145793, upload-time = "2026-03-02T15:51:38.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/de/b0eb815d4dc5a819c7e4faddc2a79058611169f7eef07ccc006531ce228c/ty-0.0.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:176e52bc8bb00b0e84efd34583962878a447a3a0e34ecc45fd7097a37554261b", size = 10189640, upload-time = "2026-03-02T15:51:50.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/71/63734923965cbb70df1da3e93e4b8875434e326b89e9f850611122f279bf/ty-0.0.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b2bc73025418e976ca4143dde71fb9025a90754a08ac03e6aa9b80d4bed1294b", size = 10370568, upload-time = "2026-03-02T15:51:42.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/a0/a532c2048533347dff48e9ca98bd86d2c224356e101688a8edaf8d6973fb/ty-0.0.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52f7c9ec6e363e094b3c389c344d5a140401f14a77f0625e3f28c21918552f5", size = 10853999, upload-time = "2026-03-02T15:51:58.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/88/36c652c658fe96658043e4abc8ea97801de6fb6e63ab50aaa82807bff1d8/ty-0.0.20-py3-none-win32.whl", hash = "sha256:c7d32bfe93f8fcaa52b6eef3f1b930fd7da410c2c94e96f7412c30cfbabf1d17", size = 9744206, upload-time = "2026-03-02T15:51:54.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/a7/a4a13bed1d7fd9d97aaa3c5bb5e6d3e9a689e6984806cbca2ab4c9233cac/ty-0.0.20-py3-none-win_amd64.whl", hash = "sha256:a5e10f40fc4a0a1cbcb740a4aad5c7ce35d79f030836ea3183b7a28f43170248", size = 10711999, upload-time = "2026-03-02T15:51:29.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/7e/6bfd748a9f4ff9267ed3329b86a0f02cdf6ab49f87bc36c8a164852f99fc/ty-0.0.20-py3-none-win_arm64.whl", hash = "sha256:53f7a5c12c960e71f160b734f328eff9a35d578af4b67a36b0bb5990ac5cdc27", size = 10150143, upload-time = "2026-03-02T15:51:31.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a4/6c289cbd1474285223124a4ffb55c078dbe9ae1d925d0b6a948643c7f115/ty-0.0.25-py3-none-linux_armv6l.whl", hash = "sha256:26d6d5aede5d54fb055779460f896d9c1473c6fb996716bd11cb90f027d8fee7", size = 10452747, upload-time = "2026-03-24T22:32:32.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/13/74cb9de356b9ceb3f281ab048f8c4ac2207122161b0ac0066886ce129abe/ty-0.0.25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aedcfbc7b6b96dbc55b0da78fa02bd049373ff3d8a827f613dadd8bd17d10758", size = 10271349, upload-time = "2026-03-24T22:32:13.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/93/ffc5a20cc9e14fa9b32b0c54884864bede30d144ce2ae013805bce0c86d0/ty-0.0.25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0a8fb3c1e28f73618941811e2568dca195178a1a6314651d4ee97086a4497253", size = 9730308, upload-time = "2026-03-24T22:32:19.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/78/52e05ef32a5f172fce70633a4e19d8e04364271a4322ae12382c7344b0de/ty-0.0.25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814870b7f347b5d0276304cddb98a0958f08de183bf159abc920ebe321247ad4", size = 10247664, upload-time = "2026-03-24T22:32:08.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/64/0d0a47ed0aa1d634c666c2cc15d3b0af4b95d0fd3dbb796032bd493f3433/ty-0.0.25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:781150e23825dc110cd5e1f50ca3d61664f7a5db5b4a55d5dbf7d3b1e246b917", size = 10261961, upload-time = "2026-03-24T22:32:43.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/ba/4666b96f0499465efb97c244554107c541d74a1add393e62276b3de9b54f/ty-0.0.25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc81ff2a0143911321251dc81d1c259fa5cdc56d043019a733c845d55409e2a", size = 10746076, upload-time = "2026-03-24T22:32:26.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ed/aa958ccbcd85cc206600e48fbf0a1c27aef54b4b90112d9a73f69ed0c739/ty-0.0.25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f03c5c5b5c10355ea030cbe3cd93b2e759b9492c66688288ea03a68086069f2e", size = 11287331, upload-time = "2026-03-24T22:32:21.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/e4/f4a004e1952e6042f5bfeeb7d09cffb379270ef009d9f8568471863e86e6/ty-0.0.25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fc1ef49cd6262eb9223ccf6e258ac899aaa53e7dc2151ba65a2c9fa248dfa75", size = 11028804, upload-time = "2026-03-24T22:32:39.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/32/5c15bb8ea20ed54d43c734f253a2a5da95d41474caecf4ef3682df9f68f5/ty-0.0.25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad98da1393161096235a387cc36abecd31861060c68416761eccdb7c1bc326b", size = 10845246, upload-time = "2026-03-24T22:32:41.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/fe/4ddd83e810c8682fcfada0d1c9d38936a34a024d32d7736075c1e53a038e/ty-0.0.25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2d4336aa5381eb4eab107c3dec75fe22943a648ef6646f5a8431ef1c8cdabb66", size = 10233515, upload-time = "2026-03-24T22:32:17.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/db/9fe54f6fb952e5b218f2e661e64ed656512edf2046cfbb9c159558e255db/ty-0.0.25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e10ed39564227de2b7bd89398250b65daaedbef15a25cef8eee70078f5d9e0b2", size = 10275289, upload-time = "2026-03-24T22:32:28.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/e0/090d7b33791b42bc7ec29463ac6a634738e16b289e027608ebe542682773/ty-0.0.25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aca04e9ed9b61c706064a1c0b71a247c3f92f373d0222103f3bc54b649421796", size = 10461195, upload-time = "2026-03-24T22:32:24.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/31/5bf12bce01b80b72a7a4e627380779b41510e730f6000862a1d078e423f7/ty-0.0.25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:18a5443e4ef339c1bd8c57fc13112c22080617ea582bfc22b497d82d65361325", size = 10931471, upload-time = "2026-03-24T22:32:14.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5e/ab60c11f8a6dd2a0ae96daac83458ef2e9be1ae70481d1ad9c59d3eaf20f/ty-0.0.25-py3-none-win32.whl", hash = "sha256:a685b9a611b69195b5a557e05dbb7ebcd12815f6c32fb27fdf15edeb1fa33d8f", size = 9835974, upload-time = "2026-03-24T22:32:36.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/55/625acc2ef34646268bc2baa8fdd6e22fb47cd5965e2acd3be92c687fb6b0/ty-0.0.25-py3-none-win_amd64.whl", hash = "sha256:0d4d37a1f1ab7f2669c941c38c65144ff223eb51ececd7ccfc0d623afbc0f729", size = 10815449, upload-time = "2026-03-24T22:32:11.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/c7/0147bfb543df97740b45b222c54ff79ef20fa57f14b9d2c1dab3cd7d3faa/ty-0.0.25-py3-none-win_arm64.whl", hash = "sha256:d80b8cd965cbacbfd887ac2d985f5b6da09b7aa3569371e2894e0b30b26b89cd", size = 10225494, upload-time = "2026-03-24T22:32:30.611Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1264,7 +1291,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zensical"
|
||||
version = "0.0.24"
|
||||
version = "0.0.29"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1274,18 +1301,18 @@ dependencies = [
|
||||
{ name = "pymdown-extensions" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/96/9c6cbdd7b351d1023cdbbcf7872d4cb118b0334cfe5821b99e0dd18e3f00/zensical-0.0.24.tar.gz", hash = "sha256:b5d99e225329bf4f98c8022bdf0a0ee9588c2fada7b4df1b7b896fcc62b37ec3", size = 3840688, upload-time = "2026-02-26T09:43:44.557Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/bd/5786ab618a60bd7469ab243a7fd2c9eecb0790c85c784abb8b97edb77a54/zensical-0.0.29.tar.gz", hash = "sha256:0d6282be7cb551e12d5806badf5e94c54a5e2f2cf07057a3e36d1eaf97c33ada", size = 3842641, upload-time = "2026-03-24T13:37:27.587Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/aa/b8201af30e376a67566f044a1c56210edac5ae923fd986a836d2cf593c9c/zensical-0.0.24-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d390c5453a5541ca35d4f9e1796df942b6612c546e3153dd928236d3b758409a", size = 12263407, upload-time = "2026-02-26T09:43:14.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/8e/3d910214471ade604fd39b080db3696864acc23678b5b4b8475c7dbfd2ce/zensical-0.0.24-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:81ac072869cf4d280853765b2bfb688653da0dfb9408f3ab15aca96455ab8223", size = 12142610, upload-time = "2026-02-26T09:43:17.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/d7/eb0983640aa0419ddf670298cfbcf8b75629b6484925429b857851e00784/zensical-0.0.24-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5eb1dfa84cae8e960bfa2c6851d2bc8e9710c4c4c683bd3aaf23185f646ae46", size = 12508380, upload-time = "2026-02-26T09:43:20.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/04/4405b9e6f937a75db19f0d875798a7eb70817d6a3bec2a2d289a2d5e8aea/zensical-0.0.24-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57d7c9e589da99c1879a1c703e67c85eaa6be4661cdc6ce6534f7bb3575983f4", size = 12440807, upload-time = "2026-02-26T09:43:22.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/dc/a7ca2a4224b3072a2c2998b6611ad7fd4f8f131ceae7aa23238d97d26e22/zensical-0.0.24-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42fcc121c3095734b078a95a0dae4d4924fb8fbf16bf730456146ad6cab48ad0", size = 12782727, upload-time = "2026-02-26T09:43:25.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/37/22f1727da356ed3fcbd31f68d4a477f15c232997c87e270cfffb927459ac/zensical-0.0.24-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4a2a051b9f49561031a2986ace502326f82d9a401ddf125530d30025fdd4", size = 12547616, upload-time = "2026-02-26T09:43:28.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/ff/c75ff111b8e12157901d00752beef9d691dbb5a034b6a77359972262416a/zensical-0.0.24-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e5fea3bb61238dba9f930f52669db67b0c26be98e1c8386a05eb2b1e3cb875dc", size = 12684883, upload-time = "2026-02-26T09:43:30.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/92/4f6ea066382e3d068d3cadbed99e9a71af25e46c84a403e0f747960472a2/zensical-0.0.24-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:75eef0428eec2958590633fdc82dc2a58af124879e29573aa7e153b662978073", size = 12713825, upload-time = "2026-02-26T09:43:33.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/fb/bf735b19bce0034b1f3b8e1c50b2896ebbd0c5d92d462777e759e78bb083/zensical-0.0.24-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3c6b39659156394ff805b4831dac108c839483d9efa4c9b901eaa913efee1ac7", size = 12854318, upload-time = "2026-02-26T09:43:35.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/28/0ddab6c1237e3625e7763ff666806f31e5760bb36d18624135a6bb6e8643/zensical-0.0.24-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9eef82865a18b3ca4c3cd13e245dff09a865d1da3c861e2fc86eaa9253a90f02", size = 12818270, upload-time = "2026-02-26T09:43:37.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/93/d2cef3705d4434896feadffb5b3e44744ef9f1204bc41202c1b84a4eeef6/zensical-0.0.24-cp310-abi3-win32.whl", hash = "sha256:f4d0ff47d505c786a26c9332317aa3e9ad58d1382f55212a10dc5bafcca97864", size = 11857695, upload-time = "2026-02-26T09:43:39.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/26/9707587c0f6044dd1e1cc5bc3b9fa5fed81ce6c7bcdb09c21a9795e802d9/zensical-0.0.24-cp310-abi3-win_amd64.whl", hash = "sha256:e00a62cf04526dbed665e989b8f448eb976247f077a76dfdd84699ace4aa3ac3", size = 12057762, upload-time = "2026-02-26T09:43:42.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/9c/8b681daa024abca9763017bec09ecee8008e110cae1254217c8dd22cc339/zensical-0.0.29-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:20ae0709ea14fce25ab33d0a82acdaf454a7a2e232a9ee20c019942205174476", size = 12311399, upload-time = "2026-03-24T13:36:53.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/ae/4ebb4d8bb2ef0164d473698b92f11caf431fc436e1625524acd5641102ca/zensical-0.0.29-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:599af3ba66fcd0146d7019f3493ed3c316051fae6c4d5599bc59f3a8f4b8a6f0", size = 12191845, upload-time = "2026-03-24T13:36:56.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/35/67f89db06571a52283b3ecbe3bcf32fd3115ca50436b3ae177a948b83ea7/zensical-0.0.29-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eea7e48a00a71c0586e875079b5f83a070c33a147e52ad4383e4b63ab524332b", size = 12554105, upload-time = "2026-03-24T13:36:59.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f6/ac79e5d9c18b28557c9ff1c7c23d695fbdd82645d69bfe02292f46d935e7/zensical-0.0.29-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59a57db35542e98d2896b833de07d199320f8ada3b4e7ddccb7fe892292d8b74", size = 12498643, upload-time = "2026-03-24T13:37:02.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/70/5c22a96a69e0e91e569c26236918bb9bab1170f59b29ad04105ead64f199/zensical-0.0.29-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d42c2b2a96a80cf64c98ba7242f59ef95109914bd4c9499d7ebc12544663852c", size = 12854531, upload-time = "2026-03-24T13:37:04.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/25/e32237a8fcb0ceae1ef8e192e7f8db53b38f1e48f1c7cdbacd0a7b713892/zensical-0.0.29-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b2fca39c5f6b1782c77cf6591cf346357cabee85ebdb956c5ddc0fd5169f3d9", size = 12596828, upload-time = "2026-03-24T13:37:07.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/74/89ac909cbb258903ea53802c184e4986c17ce0ba79b1c7f77b7e78a2dce3/zensical-0.0.29-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dfc23a74ef672aa51088c080286319da1dc0b989cd5051e9e5e6d7d4abbc2fc1", size = 12732059, upload-time = "2026-03-24T13:37:11.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/31/2429de6a9328eed4acc7e9a3789f160294a15115be15f9870a0d02649302/zensical-0.0.29-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c9336d4e4b232e3c9a70e30258e916dd7e60c0a2a08c8690065e60350c302028", size = 12768542, upload-time = "2026-03-24T13:37:14.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8a/55588b2a1dcbe86dad0404506c9ba367a06c663b1ff47147c84d26f7510e/zensical-0.0.29-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:30661148f0681199f3b598cbeb1d54f5cba773e54ae840bac639250d85907b84", size = 12917991, upload-time = "2026-03-24T13:37:16.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/5d/653901f0d3a3ca72daebc62746a148797f4e422cc3a2b66a4e6718e4398f/zensical-0.0.29-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6a566ac1fd4bfac5d711a7bd1ae06666712127c2718daa5083c7bf3f107e8578", size = 12868392, upload-time = "2026-03-24T13:37:19.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/58/d7449bc88a174b98daa3f2fbdfbdac3493768a557d8987e88bdaa6c78b1a/zensical-0.0.29-cp310-abi3-win32.whl", hash = "sha256:a231a3a02a3851741dc4d2de8910b5c39fe81e55bf026d8edf4d803e91a922fb", size = 11905486, upload-time = "2026-03-24T13:37:22.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/09/3fd082d016497c4d26ff20f42a8be2cc91e27191c0c5f3cd6507827f666f/zensical-0.0.29-cp310-abi3-win_amd64.whl", hash = "sha256:7145c5504380a344b8cd4586da815cdde77ef4a42319fa4f35e78250f01985af", size = 12101510, upload-time = "2026-03-24T13:37:24.77Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user