Compare commits

..

42 Commits

Author SHA1 Message Date
d3vyce
f4698bea8a fix: normalize batch insert rows to prevent silent data loss for nullable columns (#192) 2026-03-27 19:20:41 +01:00
d3vyce
5215b921ae fix: facet keys always use full relation chain (#190) 2026-03-27 18:56:58 +01:00
9dad59e25d tests: close generator instead of session in test_get_db_yields_async_session 2026-03-26 16:09:44 -04:00
d3vyce
29326ab532 perf: batch insert fixtures (#188) 2026-03-26 20:29:25 +01:00
d3vyce
04afef7e33 feat(fixtures): fixtures multi-variant contexts, custom Enum support, and context-filtered loading (#187) 2026-03-26 20:19:41 +01:00
d3vyce
666c621fda fix: create_db_session commits via real transaction, not savepoint (#184) 2026-03-26 19:57:40 +01:00
460b760fa4 Version 2.4.3 2026-03-26 07:58:32 -04:00
dependabot[bot]
65d0b0e0b1 ⬆ Bump ty from 0.0.23 to 0.0.25 (#178)
* ⬆ Bump ty from 0.0.23 to 0.0.25

Bumps [ty](https://github.com/astral-sh/ty) from 0.0.23 to 0.0.25.
- [Release notes](https://github.com/astral-sh/ty/releases)
- [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ty/compare/0.0.23...0.0.25)

---
updated-dependencies:
- dependency-name: ty
  dependency-version: 0.0.25
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: ty warnings

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d3vyce <nicolas.sudres@proton.me>
2026-03-26 12:57:29 +01:00
dependabot[bot]
2d49cd32db ⬆ Update uv-build requirement from <0.11.0,>=0.10 to >=0.10,<0.12.0 (#181) 2026-03-26 09:43:52 +01:00
dependabot[bot]
a5dd756d87 ⬆ Bump actions/deploy-pages from 4 to 5 (#177) 2026-03-26 09:43:40 +01:00
dependabot[bot]
781cfb66c9 ⬆ Bump zensical from 0.0.27 to 0.0.29 (#179) 2026-03-26 09:42:53 +01:00
dependabot[bot]
91b84f8146 ⬆ Bump ruff from 0.15.6 to 0.15.7 (#180) 2026-03-26 09:42:38 +01:00
dependabot[bot]
396e381ac3 ⬆ Bump pytest-cov from 7.0.0 to 7.1.0 (#182) 2026-03-26 09:41:59 +01:00
d3vyce
b4eb4c1ca9 fix: force auto-begin in create_db_dependency so lock_tables always uses savepoints (#176) 2026-03-25 19:26:28 +01:00
c90717754f chore: add prek (pre-commit alternative) 2026-03-25 14:24:30 -04:00
337985ef38 Version 2.4.2 2026-03-24 15:39:14 -04:00
d3vyce
b5e6dfe6fe refactor: test suite cleanup and simplification (#174) 2026-03-24 20:38:35 +01:00
d3vyce
6681b7ade7 fix: defer on_create/on_update/on_delete dispatch until outermost transaction commits (#172) 2026-03-24 19:56:03 +01:00
d3vyce
6981c33dc8 fix: inherit @watch field filter from parent classes via MRO traversal (#170) 2026-03-23 19:08:17 +01:00
d3vyce
0c7a99039c fix: await any awaitable callback return value, not only coroutines (#168) 2026-03-23 18:58:48 +01:00
d3vyce
bcb5b0bfda fix: suppress on_create/on_delete for objects created and deleted within the same transaction (#166) 2026-03-23 18:51:28 +01:00
100e1c1aa9 Version 2.4.1 2026-03-21 11:48:17 -04:00
d3vyce
db6c7a565f feat: add offset_params, cursor_params and paginate_params FastAPI dependency factories (#162) 2026-03-21 16:44:11 +01:00
d3vyce
768e405554 fix: use URL-safe base64 encoding for cursor tokens (#160) 2026-03-21 15:33:17 +01:00
d3vyce
f0223ebde4 feat: add pages computed field to OffsetPagination schema (#159) 2026-03-21 15:24:11 +01:00
d3vyce
f8c9bf69fe feat: add include_total flag to offset pagination to skip COUNT query (#158) 2026-03-21 15:16:22 +01:00
6d6fae5538 Version 2.4.0 2026-03-20 15:54:14 -04:00
d3vyce
fc9cd1f034 fix: resolve MissingGreenlet error when accessing self attributes in WatchedFieldsMixin callbacks (#154) 2026-03-20 20:52:11 +01:00
d3vyce
f82225f995 feat: add WatchedFieldsMixin (#148)
* feat/add WatchedFieldsMixin and watch_fields decorator for field-change monitoring

* docs: add WatchedFieldsMixin

* feat: add on_event, on_create and on_delete

* docs: update README
2026-03-19 19:19:33 +01:00
dependabot[bot]
e62612a93a ⬆ Bump coverage from 7.13.4 to 7.13.5 (#149)
Bumps [coverage](https://github.com/coveragepy/coveragepy) from 7.13.4 to 7.13.5.
- [Release notes](https://github.com/coveragepy/coveragepy/releases)
- [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst)
- [Commits](https://github.com/coveragepy/coveragepy/compare/7.13.4...7.13.5)

---
updated-dependencies:
- dependency-name: coverage
  dependency-version: 7.13.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 19:11:03 +01:00
dependabot[bot]
56f0ea291e ⬆ Bump zensical from 0.0.26 to 0.0.27 (#150)
Bumps [zensical](https://github.com/zensical/zensical) from 0.0.26 to 0.0.27.
- [Release notes](https://github.com/zensical/zensical/releases)
- [Commits](https://github.com/zensical/zensical/compare/v0.0.26...v0.0.27)

---
updated-dependencies:
- dependency-name: zensical
  dependency-version: 0.0.27
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 19:10:52 +01:00
dependabot[bot]
ee896009ee ⬆ Bump ty from 0.0.21 to 0.0.23 (#151)
Bumps [ty](https://github.com/astral-sh/ty) from 0.0.21 to 0.0.23.
- [Release notes](https://github.com/astral-sh/ty/releases)
- [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ty/compare/0.0.21...0.0.23)

---
updated-dependencies:
- dependency-name: ty
  dependency-version: 0.0.23
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 19:10:41 +01:00
dependabot[bot]
65bf928e12 ⬆ Bump ruff from 0.15.5 to 0.15.6 (#152)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.5 to 0.15.6.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.5...0.15.6)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 19:10:28 +01:00
d3vyce
2e9c6c0c90 feat: support Annotated[AsyncSession, Depends(...)] in PathDependency and BodyDependency (#146) 2026-03-18 20:10:58 +01:00
2c494fcd17 Version 2.3.0 2026-03-15 12:52:44 -04:00
d3vyce
fd7269a372 refactor: CursorDirection enum, cursor value parsing and __class_getitem__ caching (#144) 2026-03-15 17:48:50 +01:00
d3vyce
c863744012 feat: PaginatedResponse[T] as discriminated union annotation (#142) 2026-03-15 17:41:33 +01:00
d3vyce
aedcbf4e04 feat: add UUIDv7Mixin (#140) 2026-03-14 20:41:46 +01:00
d3vyce
19c013bdec feat: unified paginate() endpoint with typed pagination responses (#134)
* feat: unified paginate() endpoint with typed pagination responses

* docs: unified paginate() endpoint

* fix: add tests
2026-03-14 20:23:20 +01:00
d3vyce
81407c3038 fix: use clock_timestamp() instead of now() for Mixin to ensure unique values (#138) 2026-03-14 17:30:11 +01:00
d3vyce
0fb00d44da fix: prev_cursor does not navigate backward (#136) 2026-03-14 17:18:53 +01:00
d3vyce
19232d3436 feat: add AsyncCrud subclass style and base_class param to CrudFactory (#132) 2026-03-12 22:46:51 +01:00
44 changed files with 4618 additions and 627 deletions

View File

@@ -34,5 +34,5 @@ jobs:
with: with:
path: site path: site
- uses: actions/deploy-pages@v4 - uses: actions/deploy-pages@v5
id: deployment id: deployment

34
.pre-commit-config.yaml Normal file
View 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

View File

@@ -48,8 +48,8 @@ uv add "fastapi-toolsets[all]"
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection - **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 - **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 - **Fixtures**: Fixture system with dependency management, context support, and pytest integration
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`) - **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`, `PaginatedResponse`, and `PydanticBase` - **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation - **Exception Handling**: Structured error responses with automatic OpenAPI documentation
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger` - **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`

View File

@@ -42,12 +42,17 @@ Declare `searchable_fields`, `facet_fields`, and `order_fields` once on [`CrudFa
## Routes ## Routes
```python title="routes.py:1:17"
--8<-- "docs_src/examples/pagination_search/routes.py:1:17"
```
### Offset pagination ### Offset pagination
Best for admin panels or any UI that needs a total item count and numbered pages. Best for admin panels or any UI that needs a total item count and numbered pages.
```python title="routes.py:1:36" ```python title="routes.py:20:40"
--8<-- "docs_src/examples/pagination_search/routes.py:1:36" --8<-- "docs_src/examples/pagination_search/routes.py:20:40"
``` ```
**Example request** **Example request**
@@ -61,11 +66,13 @@ GET /articles/offset?page=2&items_per_page=10&search=fastapi&status=published&or
```json ```json
{ {
"status": "SUCCESS", "status": "SUCCESS",
"pagination_type": "offset",
"data": [ "data": [
{ "id": "3f47ac69-...", "title": "FastAPI tips", "status": "published", ... } { "id": "3f47ac69-...", "title": "FastAPI tips", "status": "published", ... }
], ],
"pagination": { "pagination": {
"total_count": 42, "total_count": 42,
"pages": 5,
"page": 2, "page": 2,
"items_per_page": 10, "items_per_page": 10,
"has_more": true "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. `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 ### Cursor pagination
Best for feeds, infinite scroll, or any high-throughput API where offset performance degrades. Best for feeds, infinite scroll, or any high-throughput API where offset performance degrades.
```python title="routes.py:39:59" ```python title="routes.py:43:63"
--8<-- "docs_src/examples/pagination_search/routes.py:39:59" --8<-- "docs_src/examples/pagination_search/routes.py:43:63"
``` ```
**Example request** **Example request**
@@ -98,6 +107,7 @@ GET /articles/cursor?items_per_page=10&status=published&order_by=created_at&orde
```json ```json
{ {
"status": "SUCCESS", "status": "SUCCESS",
"pagination_type": "cursor",
"data": [ "data": [
{ "id": "3f47ac69-...", "title": "FastAPI tips", "status": "published", ... } { "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. 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 ## Search behaviour
Both endpoints inherit the same `searchable_fields` declared on `ArticleCrud`: Both endpoints inherit the same `searchable_fields` declared on `ArticleCrud`:

View File

@@ -48,8 +48,8 @@ uv add "fastapi-toolsets[all]"
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection - **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 - **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 - **Fixtures**: Fixture system with dependency management, context support, and pytest integration
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`) - **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`, `PaginatedResponse`, and `PydanticBase` - **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation - **Exception Handling**: Structured error responses with automatic OpenAPI documentation
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger` - **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`

View File

@@ -7,10 +7,12 @@ Generic async CRUD operations for SQLAlchemy models with search, pagination, and
## Overview ## 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 ## Creating a CRUD class
### Factory style
```python ```python
from fastapi_toolsets.crud import CrudFactory from fastapi_toolsets.crud import CrudFactory
from myapp.models import User from myapp.models import User
@@ -18,7 +20,65 @@ from myapp.models import User
UserCrud = CrudFactory(model=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 ## 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`)" !!! 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` | | | `offset_paginate` | `cursor_paginate` | `paginate` |
|---|---|---| |---|---|---|---|
| Total count | Yes | No | | Return type | `OffsetPaginatedResponse` | `CursorPaginatedResponse` | either, based on `pagination_type` param |
| Jump to arbitrary page | Yes | No | | Total count | Yes | No | / |
| Performance on deep pages | Degrades | Constant | | Jump to arbitrary page | Yes | No | / |
| Stable under concurrent inserts | No | Yes | | Performance on deep pages | Degrades | Constant | / |
| Search compatible | Yes | Yes | | Stable under concurrent inserts | No | Yes | / |
| Use case | Admin panels, numbered pagination | Feeds, APIs, infinite scroll | | Use case | Admin panels, numbered pagination | Feeds, APIs, infinite scroll | single endpoint, both strategies |
### Offset pagination ### Offset pagination
```python ```python
@router.get( @router.get("")
"",
response_model=PaginatedResponse[User],
)
async def get_users( async def get_users(
session: SessionDep, session: SessionDep,
items_per_page: int = 50, items_per_page: int = 50,
page: int = 1, page: int = 1,
): ) -> OffsetPaginatedResponse[UserRead]:
return await crud.UserCrud.offset_paginate( return await UserCrud.offset_paginate(
session=session, session=session,
items_per_page=items_per_page, items_per_page=items_per_page,
page=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 ```json
{ {
"status": "SUCCESS", "status": "SUCCESS",
"pagination_type": "offset",
"data": ["..."], "data": ["..."],
"pagination": { "pagination": {
"total_count": 100, "total_count": 100,
"pages": 5,
"page": 1, "page": 1,
"items_per_page": 20, "items_per_page": 20,
"has_more": true "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 ### Cursor pagination
```python ```python
@router.get( @router.get("")
"",
response_model=PaginatedResponse[UserRead],
)
async def list_users( async def list_users(
session: SessionDep, session: SessionDep,
cursor: str | None = None, cursor: str | None = None,
items_per_page: int = 20, items_per_page: int = 20,
): ) -> CursorPaginatedResponse[UserRead]:
return await UserCrud.cursor_paginate( return await UserCrud.cursor_paginate(
session=session, session=session,
cursor=cursor, cursor=cursor,
items_per_page=items_per_page, 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 ```json
{ {
"status": "SUCCESS", "status": "SUCCESS",
"pagination_type": "cursor",
"data": ["..."], "data": ["..."],
"pagination": { "pagination": {
"next_cursor": "eyJ2YWx1ZSI6ICIzZjQ3YWM2OS0uLi4ifQ==", "next_cursor": "eyJ2YWx1ZSI6ICIzZjQ3YWM2OS0uLi4ifQ==",
@@ -180,7 +273,7 @@ The cursor column is set once on [`CrudFactory`](../reference/crud.md#fastapi_to
!!! note !!! 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`. `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 | | 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) 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 ## 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). 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).
@@ -240,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): 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 ```python
@router.get( @router.get("")
"",
response_model=PaginatedResponse[User],
)
async def get_users( async def get_users(
session: SessionDep, session: SessionDep,
items_per_page: int = 50, items_per_page: int = 50,
page: int = 1, page: int = 1,
search: str | None = None, search: str | None = None,
): ) -> OffsetPaginatedResponse[UserRead]:
return await crud.UserCrud.offset_paginate( return await UserCrud.offset_paginate(
session=session, session=session,
items_per_page=items_per_page, items_per_page=items_per_page,
page=page, page=page,
search=search, search=search,
schema=UserRead,
) )
``` ```
```python ```python
@router.get( @router.get("")
"",
response_model=PaginatedResponse[User],
)
async def get_users( async def get_users(
session: SessionDep, session: SessionDep,
cursor: str | None = None, cursor: str | None = None,
items_per_page: int = 50, items_per_page: int = 50,
search: str | None = None, search: str | None = None,
): ) -> CursorPaginatedResponse[UserRead]:
return await crud.UserCrud.cursor_paginate( return await UserCrud.cursor_paginate(
session=session, session=session,
items_per_page=items_per_page, items_per_page=items_per_page,
cursor=cursor, cursor=cursor,
search=search, search=search,
schema=UserRead,
) )
``` ```
@@ -315,7 +474,7 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated
"filter_attributes": { "filter_attributes": {
"status": ["active", "inactive"], "status": ["active", "inactive"],
"country": ["DE", "FR", "US"], "country": ["DE", "FR", "US"],
"name": ["admin", "editor", "viewer"] "role__name": ["admin", "editor", "viewer"]
} }
} }
``` ```
@@ -323,7 +482,7 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated
Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError). Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError).
!!! info "The keys in `filter_by` are the same keys the client received in `filter_attributes`." !!! info "The keys in `filter_by` are the same keys the client received in `filter_attributes`."
Keys are normally the terminal `column.key` (e.g. `"name"` for `Role.name`). When two facet fields share the same column key (e.g. `(Build.project, Project.name)` and `(Build.os, Os.name)`), the relationship name is prepended automatically: `"project__name"` and `"os__name"`. Keys use `__` as a separator for the full relationship chain. A direct column `User.status` produces `"status"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`. A deeper chain `(User.role, Role.permission, Permission.name)` produces `"role__permission__name"`.
`filter_by` and `filters` can be combined — both are applied with AND logic. `filter_by` and `filters` can be combined — both are applied with AND logic.
@@ -344,20 +503,21 @@ async def list_users(
session: SessionDep, session: SessionDep,
page: int = 1, page: int = 1,
filter_by: Annotated[dict[str, list[str]], Depends(UserCrud.filter_params())], filter_by: Annotated[dict[str, list[str]], Depends(UserCrud.filter_params())],
) -> PaginatedResponse[UserRead]: ) -> OffsetPaginatedResponse[UserRead]:
return await UserCrud.offset_paginate( return await UserCrud.offset_paginate(
session=session, session=session,
page=page, page=page,
filter_by=filter_by, filter_by=filter_by,
schema=UserRead,
) )
``` ```
Both single-value and multi-value query parameters work: Both single-value and multi-value query parameters work:
``` ```
GET /users?status=active → filter_by={"status": ["active"]} GET /users?status=active → filter_by={"status": ["active"]}
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]} GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
GET /users?role=admin&role=editor → filter_by={"role": ["admin", "editor"]} (IN clause) GET /users?role__name=admin&role__name=editor → filter_by={"role__name": ["admin", "editor"]} (IN clause)
``` ```
## Sorting ## Sorting
@@ -388,8 +548,8 @@ from fastapi_toolsets.crud import OrderByClause
async def list_users( async def list_users(
session: SessionDep, session: SessionDep,
order_by: Annotated[OrderByClause | None, Depends(UserCrud.order_params())], order_by: Annotated[OrderByClause | None, Depends(UserCrud.order_params())],
) -> PaginatedResponse[UserRead]: ) -> OffsetPaginatedResponse[UserRead]:
return await UserCrud.offset_paginate(session=session, order_by=order_by) return await UserCrud.offset_paginate(session=session, order_by=order_by, schema=UserRead)
``` ```
The dependency adds two query parameters to the endpoint: The dependency adds two query parameters to the endpoint:
@@ -496,7 +656,7 @@ async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
) )
@router.get("") @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( return await crud.UserCrud.offset_paginate(
session=session, session=session,
page=page, page=page,

View File

@@ -13,8 +13,13 @@ The `dependencies` module provides two factory functions that create FastAPI dep
```python ```python
from fastapi_toolsets.dependencies import PathDependency from fastapi_toolsets.dependencies import PathDependency
# Plain callable
UserDep = PathDependency(model=User, field=User.id, session_dep=get_db) 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}") @router.get("/users/{user_id}")
async def get_user(user: User = UserDep): async def get_user(user: User = UserDep):
return user return user
@@ -37,8 +42,14 @@ async def get_user(user: User = UserDep):
```python ```python
from fastapi_toolsets.dependencies import BodyDependency from fastapi_toolsets.dependencies import BodyDependency
# Plain callable
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=get_db, body_field="role_id") 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") @router.post("/users")
async def create_user(body: UserCreateSchema, role: Role = RoleDep): async def create_user(body: UserCreateSchema, role: Role = RoleDep):
user = User(username=body.username, role=role) user = User(username=body.username, role=role)

View File

@@ -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 from fastapi_toolsets.fixtures import load_fixtures_by_context
async with db_context() as session: 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 ```python
from fastapi_toolsets.fixtures import load_fixtures from fastapi_toolsets.fixtures import load_fixtures
async with db_context() as session: 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 ## Contexts
[`Context`](../reference/fixtures.md#fastapi_toolsets.fixtures.enum.Context) is an enum with predefined values: [`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.BASE` | Core data required in all environments |
| `Context.TESTING` | Data only loaded during tests | | `Context.TESTING` | Data only loaded during tests |
| `Context.DEVELOPMENT` | Data only loaded in development |
| `Context.PRODUCTION` | Data only loaded in production | | `Context.PRODUCTION` | Data only loaded in production |
A fixture with no `contexts` defined takes `Context.BASE` by default. 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 ## Load strategies
[`LoadStrategy`](../reference/fixtures.md#fastapi_toolsets.fixtures.enum.LoadStrategy) controls how the fixture loader handles rows that already exist: [`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 | | Strategy | Description |
|----------|-------------| |----------|-------------|
| `LoadStrategy.INSERT` | Insert only, fail on duplicates | | `LoadStrategy.INSERT` | Insert only, fail on duplicates |
| `LoadStrategy.UPSERT` | Insert or update on conflict | | `LoadStrategy.MERGE` | Insert or update on conflict (default) |
| `LoadStrategy.SKIP` | Skip rows that already exist | | `LoadStrategy.SKIP_EXISTING` | Skip rows that already exist |
```python
await load_fixtures_by_context(
session, fixtures, Context.BASE, strategy=LoadStrategy.SKIP_EXISTING
)
```
## Merging registries ## Merging registries
Split fixtures definitions across modules and merge them: Split fixture definitions across modules and merge them:
```python ```python
from myapp.fixtures.dev import dev_fixtures from myapp.fixtures.dev import dev_fixtures
from myapp.fixtures.prod import prod_fixtures from myapp.fixtures.prod import prod_fixtures
fixtures = fixturesRegistry() fixtures = FixtureRegistry()
fixtures.include_registry(registry=dev_fixtures) fixtures.include_registry(registry=dev_fixtures)
fixtures.include_registry(registry=prod_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 ## 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. 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 ## CLI integration

View File

@@ -18,13 +18,15 @@ class Article(Base, UUIDMixin, TimestampMixin):
content: Mapped[str] 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 ## Mixins
### [`UUIDMixin`](../reference/models.md#fastapi_toolsets.models.UUIDMixin) ### [`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 ```python
from fastapi_toolsets.models import UUIDMixin from fastapi_toolsets.models import UUIDMixin
@@ -36,13 +38,37 @@ class User(Base, UUIDMixin):
# id is None before flush # id is None before flush
user = User(username="alice") user = User(username="alice")
session.add(user)
await session.flush() await session.flush()
print(user.id) # UUID('...') 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) ### [`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 ```python
from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin
@@ -55,7 +81,7 @@ class Order(Base, UUIDMixin, CreatedAtMixin):
### [`UpdatedAtMixin`](../reference/models.md#fastapi_toolsets.models.UpdatedAtMixin) ### [`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 ```python
from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin
@@ -91,6 +117,122 @@ class Article(Base, UUIDMixin, TimestampMixin):
title: Mapped[str] 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 ## 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. 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.

View File

@@ -20,50 +20,115 @@ async def get_user(user: User = UserDep) -> Response[UserSchema]:
return Response(data=user, message="User retrieved") 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 ```python
from fastapi_toolsets.schemas import PaginatedResponse, OffsetPagination from fastapi_toolsets.schemas import OffsetPaginatedResponse
@router.get("/users") @router.get("/users")
async def list_users() -> PaginatedResponse[UserSchema]: async def list_users(
return PaginatedResponse( page: int = 1,
data=users, items_per_page: int = 20,
pagination=OffsetPagination( ) -> OffsetPaginatedResponse[UserSchema]:
total_count=100, return await UserCrud.offset_paginate(
items_per_page=10, session, page=page, items_per_page=items_per_page, schema=UserSchema
page=1,
has_more=True,
),
) )
``` ```
#### [`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 ```python
from fastapi_toolsets.schemas import PaginatedResponse, CursorPagination from fastapi_toolsets.schemas import CursorPaginatedResponse
@router.get("/events") @router.get("/events")
async def list_events() -> PaginatedResponse[EventSchema]: async def list_events(
return PaginatedResponse( cursor: str | None = None,
data=events, items_per_page: int = 20,
pagination=CursorPagination( ) -> CursorPaginatedResponse[EventSchema]:
next_cursor="eyJpZCI6IDQyfQ==", return await EventCrud.cursor_paginate(
prev_cursor=None, session, cursor=cursor, items_per_page=items_per_page, schema=EventSchema
items_per_page=20,
has_more=True,
),
) )
``` ```
**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`. 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) ### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse)

View File

@@ -6,17 +6,29 @@ You can import them directly from `fastapi_toolsets.models`:
```python ```python
from fastapi_toolsets.models import ( from fastapi_toolsets.models import (
ModelEvent,
UUIDMixin, UUIDMixin,
UUIDv7Mixin,
CreatedAtMixin, CreatedAtMixin,
UpdatedAtMixin, UpdatedAtMixin,
TimestampMixin, TimestampMixin,
WatchedFieldsMixin,
watch,
) )
``` ```
## ::: fastapi_toolsets.models.ModelEvent
## ::: fastapi_toolsets.models.UUIDMixin ## ::: fastapi_toolsets.models.UUIDMixin
## ::: fastapi_toolsets.models.UUIDv7Mixin
## ::: fastapi_toolsets.models.CreatedAtMixin ## ::: fastapi_toolsets.models.CreatedAtMixin
## ::: fastapi_toolsets.models.UpdatedAtMixin ## ::: fastapi_toolsets.models.UpdatedAtMixin
## ::: fastapi_toolsets.models.TimestampMixin ## ::: fastapi_toolsets.models.TimestampMixin
## ::: fastapi_toolsets.models.WatchedFieldsMixin
## ::: fastapi_toolsets.models.watch

View File

@@ -14,7 +14,10 @@ from fastapi_toolsets.schemas import (
ErrorResponse, ErrorResponse,
OffsetPagination, OffsetPagination,
CursorPagination, CursorPagination,
PaginationType,
PaginatedResponse, PaginatedResponse,
OffsetPaginatedResponse,
CursorPaginatedResponse,
) )
``` ```
@@ -34,4 +37,10 @@ from fastapi_toolsets.schemas import (
## ::: fastapi_toolsets.schemas.CursorPagination ## ::: fastapi_toolsets.schemas.CursorPagination
## ::: fastapi_toolsets.schemas.PaginationType
## ::: fastapi_toolsets.schemas.PaginatedResponse ## ::: fastapi_toolsets.schemas.PaginatedResponse
## ::: fastapi_toolsets.schemas.OffsetPaginatedResponse
## ::: fastapi_toolsets.schemas.CursorPaginatedResponse

View File

@@ -1,9 +1,10 @@
import datetime
import uuid 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 sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from fastapi_toolsets.models import CreatedAtMixin
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
@@ -18,13 +19,10 @@ class Category(Base):
articles: Mapped[list["Article"]] = relationship(back_populates="category") articles: Mapped[list["Article"]] = relationship(back_populates="category")
class Article(Base): class Article(Base, CreatedAtMixin):
__tablename__ = "articles" __tablename__ = "articles"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) 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)) title: Mapped[str] = mapped_column(String(256))
body: Mapped[str] = mapped_column(Text) body: Mapped[str] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(32)) status: Mapped[str] = mapped_column(String(32))

View File

@@ -1,9 +1,13 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends
from fastapi_toolsets.crud import OrderByClause 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 .crud import ArticleCrud
from .db import SessionDep from .db import SessionDep
@@ -16,19 +20,20 @@ router = APIRouter(prefix="/articles")
@router.get("/offset") @router.get("/offset")
async def list_articles_offset( async def list_articles_offset(
session: SessionDep, 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())], filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
order_by: Annotated[ order_by: Annotated[
OrderByClause | None, OrderByClause | None,
Depends(ArticleCrud.order_params(default_field=Article.created_at)), 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, search: str | None = None,
) -> PaginatedResponse[ArticleRead]: ) -> OffsetPaginatedResponse[ArticleRead]:
return await ArticleCrud.offset_paginate( return await ArticleCrud.offset_paginate(
session=session, session=session,
page=page, **params,
items_per_page=items_per_page,
search=search, search=search,
filter_by=filter_by or None, filter_by=filter_by or None,
order_by=order_by, order_by=order_by,
@@ -39,19 +44,44 @@ async def list_articles_offset(
@router.get("/cursor") @router.get("/cursor")
async def list_articles_cursor( async def list_articles_cursor(
session: SessionDep, 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())], filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
order_by: Annotated[ order_by: Annotated[
OrderByClause | None, OrderByClause | None,
Depends(ArticleCrud.order_params(default_field=Article.created_at)), 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, search: str | None = None,
) -> PaginatedResponse[ArticleRead]: ) -> CursorPaginatedResponse[ArticleRead]:
return await ArticleCrud.cursor_paginate( return await ArticleCrud.cursor_paginate(
session=session, session=session,
cursor=cursor, **params,
items_per_page=items_per_page, 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, search=search,
filter_by=filter_by or None, filter_by=filter_by or None,
order_by=order_by, order_by=order_by,

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "2.2.1" version = "2.4.3"
description = "Production-ready utilities for FastAPI applications" description = "Production-ready utilities for FastAPI applications"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
@@ -67,6 +67,7 @@ dev = [
{include-group = "tests"}, {include-group = "tests"},
{include-group = "docs"}, {include-group = "docs"},
"fastapi-toolsets[all]", "fastapi-toolsets[all]",
"prek>=0.3.8",
"ruff>=0.1.0", "ruff>=0.1.0",
"ty>=0.0.1a0", "ty>=0.0.1a0",
] ]
@@ -84,7 +85,7 @@ docs = [
] ]
[build-system] [build-system]
requires = ["uv_build>=0.10,<0.11.0"] requires = ["uv_build>=0.10,<0.12.0"]
build-backend = "uv_build" build-backend = "uv_build"
[tool.pytest.ini_options] [tool.pytest.ini_options]

View File

@@ -21,4 +21,4 @@ Example usage:
return Response(data={"user": user.username}, message="Success") return Response(data={"user": user.username}, message="Success")
""" """
__version__ = "2.2.1" __version__ = "2.4.3"

View File

@@ -1,6 +1,7 @@
"""Generic async CRUD operations for SQLAlchemy models.""" """Generic async CRUD operations for SQLAlchemy models."""
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
from ..schemas import PaginationType
from ..types import ( from ..types import (
FacetFieldType, FacetFieldType,
JoinType, JoinType,
@@ -8,10 +9,11 @@ from ..types import (
OrderByClause, OrderByClause,
SearchFieldType, SearchFieldType,
) )
from .factory import CrudFactory from .factory import AsyncCrud, CrudFactory
from .search import SearchConfig, get_searchable_fields from .search import SearchConfig, get_searchable_fields
__all__ = [ __all__ = [
"AsyncCrud",
"CrudFactory", "CrudFactory",
"FacetFieldType", "FacetFieldType",
"get_searchable_fields", "get_searchable_fields",
@@ -20,6 +22,7 @@ __all__ = [
"M2MFieldType", "M2MFieldType",
"NoSearchableFieldsError", "NoSearchableFieldsError",
"OrderByClause", "OrderByClause",
"PaginationType",
"SearchConfig", "SearchConfig",
"SearchFieldType", "SearchFieldType",
] ]

View File

@@ -9,6 +9,7 @@ import uuid as uuid_module
from collections.abc import Awaitable, Callable, Sequence from collections.abc import Awaitable, Callable, Sequence
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from enum import Enum
from typing import Any, ClassVar, Generic, Literal, Self, cast, overload from typing import Any, ClassVar, Generic, Literal, Self, cast, overload
from fastapi import Query from fastapi import Query
@@ -23,7 +24,14 @@ from sqlalchemy.sql.roles import WhereHavingRole
from ..db import get_transaction from ..db import get_transaction
from ..exceptions import InvalidOrderFieldError, NotFoundError from ..exceptions import InvalidOrderFieldError, NotFoundError
from ..schemas import CursorPagination, OffsetPagination, PaginatedResponse, Response from ..schemas import (
CursorPaginatedResponse,
CursorPagination,
OffsetPaginatedResponse,
OffsetPagination,
PaginationType,
Response,
)
from ..types import ( from ..types import (
FacetFieldType, FacetFieldType,
JoinType, JoinType,
@@ -42,14 +50,58 @@ from .search import (
) )
def _encode_cursor(value: Any) -> str: class _CursorDirection(str, Enum):
"""Encode cursor column value as an base64 string.""" NEXT = "next"
return base64.b64encode(json.dumps(str(value)).encode()).decode() PREV = "prev"
def _decode_cursor(cursor: str) -> str: def _encode_cursor(
"""Decode cursor base64 string.""" value: Any, *, direction: _CursorDirection = _CursorDirection.NEXT
return json.loads(base64.b64decode(cursor.encode()).decode()) ) -> 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: def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
@@ -82,6 +134,27 @@ class AsyncCrud(Generic[ModelType]):
default_load_options: ClassVar[Sequence[ExecutableOption] | None] = None default_load_options: ClassVar[Sequence[ExecutableOption] | None] = None
cursor_column: ClassVar[Any | 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 @classmethod
def _resolve_load_options( def _resolve_load_options(
cls, load_options: Sequence[ExecutableOption] | None cls, load_options: Sequence[ExecutableOption] | None
@@ -196,6 +269,7 @@ class AsyncCrud(Generic[ModelType]):
facet_fields: Sequence[FacetFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None,
) -> Callable[..., Awaitable[dict[str, list[str]]]]: ) -> Callable[..., Awaitable[dict[str, list[str]]]]:
"""Return a FastAPI dependency that collects facet filter values from query parameters. """Return a FastAPI dependency that collects facet filter values from query parameters.
Args: Args:
facet_fields: Override the facet fields for this dependency. Falls back to the facet_fields: Override the facet fields for this dependency. Falls back to the
class-level ``facet_fields`` if not provided. 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} return {k: v for k, v in kwargs.items() if v is not None}
dependency.__name__ = f"{cls.model.__name__}FilterParams" 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=[ parameters=[
inspect.Parameter( inspect.Parameter(
k, k,
@@ -235,6 +309,121 @@ class AsyncCrud(Generic[ModelType]):
return dependency 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 @classmethod
def order_params( def order_params(
cls: type[Self], cls: type[Self],
@@ -864,12 +1053,13 @@ class AsyncCrud(Generic[ModelType]):
order_by: OrderByClause | None = None, order_by: OrderByClause | None = None,
page: int = 1, page: int = 1,
items_per_page: int = 20, items_per_page: int = 20,
include_total: bool = True,
search: str | SearchConfig | None = None, search: str | SearchConfig | None = None,
search_fields: Sequence[SearchFieldType] | None = None, search_fields: Sequence[SearchFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None,
filter_by: dict[str, Any] | BaseModel | None = None, filter_by: dict[str, Any] | BaseModel | None = None,
schema: type[BaseModel], schema: type[BaseModel],
) -> PaginatedResponse[Any]: ) -> OffsetPaginatedResponse[Any]:
"""Get paginated results using offset-based pagination. """Get paginated results using offset-based pagination.
Args: Args:
@@ -881,6 +1071,8 @@ class AsyncCrud(Generic[ModelType]):
order_by: Column or list of columns to order by order_by: Column or list of columns to order by
page: Page number (1-indexed) page: Page number (1-indexed)
items_per_page: Number of items per page 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: Search query string or SearchConfig object
search_fields: Fields to search in (overrides class default) search_fields: Fields to search in (overrides class default)
facet_fields: Columns to compute distinct values for (overrides class default) facet_fields: Columns to compute distinct values for (overrides class default)
@@ -925,39 +1117,50 @@ class AsyncCrud(Generic[ModelType]):
if order_by is not None: if order_by is not None:
q = q.order_by(order_by) q = q.order_by(order_by)
q = q.offset(offset).limit(items_per_page) if include_total:
result = await session.execute(q) q = q.offset(offset).limit(items_per_page)
raw_items = cast(list[ModelType], result.unique().scalars().all()) result = await session.execute(q)
raw_items = cast(list[ModelType], result.unique().scalars().all())
# Count query (with same joins and filters)
pk_col = cls.model.__mapper__.primary_key[0]
count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
count_q = count_q.select_from(cls.model)
# Apply explicit joins to count query
count_q = _apply_joins(count_q, joins, outer_join)
# Apply search joins to count query
count_q = _apply_search_joins(count_q, search_joins)
if filters:
count_q = count_q.where(and_(*filters))
count_result = await session.execute(count_q)
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] 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]
count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
count_q = count_q.select_from(cls.model)
# Apply explicit joins to count query
count_q = _apply_joins(count_q, joins, outer_join)
# Apply search joins to count query
count_q = _apply_search_joins(count_q, search_joins)
if filters:
count_q = count_q.where(and_(*filters))
count_result = await session.execute(count_q)
total_count = count_result.scalar_one()
filter_attributes = await cls._build_filter_attributes( filter_attributes = await cls._build_filter_attributes(
session, facet_fields, filters, search_joins session, facet_fields, filters, search_joins
) )
return PaginatedResponse( return OffsetPaginatedResponse(
data=items, data=items,
pagination=OffsetPagination( pagination=OffsetPagination(
total_count=total_count, total_count=total_count,
items_per_page=items_per_page, items_per_page=items_per_page,
page=page, page=page,
has_more=page * items_per_page < total_count, has_more=has_more,
), ),
filter_attributes=filter_attributes, filter_attributes=filter_attributes,
) )
@@ -979,7 +1182,7 @@ class AsyncCrud(Generic[ModelType]):
facet_fields: Sequence[FacetFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None,
filter_by: dict[str, Any] | BaseModel | None = None, filter_by: dict[str, Any] | BaseModel | None = None,
schema: type[BaseModel], schema: type[BaseModel],
) -> PaginatedResponse[Any]: ) -> CursorPaginatedResponse[Any]:
"""Get paginated results using cursor-based pagination. """Get paginated results using cursor-based pagination.
Args: Args:
@@ -1018,26 +1221,15 @@ class AsyncCrud(Generic[ModelType]):
cursor_column: Any = cls.cursor_column cursor_column: Any = cls.cursor_column
cursor_col_name: str = cursor_column.key cursor_col_name: str = cursor_column.key
direction = _CursorDirection.NEXT
if cursor is not None: if cursor is not None:
raw_val = _decode_cursor(cursor) raw_val, direction = _decode_cursor(cursor)
col_type = cursor_column.property.columns[0].type col_type = cursor_column.property.columns[0].type
if isinstance(col_type, Integer): cursor_val: Any = _parse_cursor_value(raw_val, col_type)
cursor_val: Any = int(raw_val) if direction is _CursorDirection.PREV:
elif isinstance(col_type, Uuid): filters.append(cursor_column < cursor_val)
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)
else: else:
raise ValueError( filters.append(cursor_column > cursor_val)
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 # Build search filters
if search: if search:
@@ -1064,12 +1256,15 @@ class AsyncCrud(Generic[ModelType]):
if resolved := cls._resolve_load_options(load_options): if resolved := cls._resolve_load_options(load_options):
q = q.options(*resolved) q = q.options(*resolved)
# Cursor column is always the primary sort # Cursor column is always the primary sort; reverse direction for prev traversal
q = q.order_by(cursor_column) 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: if order_by is not None:
q = q.order_by(order_by) 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) q = q.limit(items_per_page + 1)
result = await session.execute(q) result = await session.execute(q)
raw_items = cast(list[ModelType], result.unique().scalars().all()) 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 has_more = len(raw_items) > items_per_page
items_page = raw_items[:items_per_page] items_page = raw_items[:items_per_page]
# next_cursor points past the last item on this page # Restore ascending order when traversing backward
next_cursor: str | None = None if direction is _CursorDirection.PREV:
if has_more and items_page: items_page = list(reversed(items_page))
next_cursor = _encode_cursor(getattr(items_page[-1], cursor_col_name))
# 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 prev_cursor: str | None = None
if cursor is not None and items_page: if direction is _CursorDirection.NEXT and cursor is not None and items_page:
prev_cursor = _encode_cursor(getattr(items_page[0], cursor_col_name)) 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] 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 session, facet_fields, filters, search_joins
) )
return PaginatedResponse( return CursorPaginatedResponse(
data=items, data=items,
pagination=CursorPagination( pagination=CursorPagination(
next_cursor=next_cursor, next_cursor=next_cursor,
@@ -1104,10 +1320,155 @@ class AsyncCrud(Generic[ModelType]):
filter_attributes=filter_attributes, 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( def CrudFactory(
model: type[ModelType], model: type[ModelType],
*, *,
base_class: type[AsyncCrud[Any]] = AsyncCrud,
searchable_fields: Sequence[SearchFieldType] | None = None, searchable_fields: Sequence[SearchFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None,
order_fields: Sequence[QueryableAttribute[Any]] | None = None, order_fields: Sequence[QueryableAttribute[Any]] | None = None,
@@ -1119,6 +1480,9 @@ def CrudFactory(
Args: Args:
model: SQLAlchemy model class 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 searchable_fields: Optional list of searchable fields
facet_fields: Optional list of columns to compute distinct values for in paginated facet_fields: Optional list of columns to compute distinct values for in paginated
responses. Supports direct columns (``User.status``) and relationship tuples responses. Supports direct columns (``User.status``) and relationship tuples
@@ -1209,28 +1573,27 @@ def CrudFactory(
joins=[(Post, Post.user_id == User.id)], joins=[(Post, Post.user_id == User.id)],
outer_join=True, 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)
``` ```
""" """
pk_key = model.__mapper__.primary_key[0].key
assert pk_key is not None
pk_col = getattr(model, pk_key)
if searchable_fields is None:
effective_searchable = [pk_col]
else:
existing_keys = {f.key for f in searchable_fields if not isinstance(f, tuple)}
effective_searchable = (
[pk_col, *searchable_fields]
if pk_key not in existing_keys
else list(searchable_fields)
)
cls = type( cls = type(
f"Async{model.__name__}Crud", f"Async{model.__name__}Crud",
(AsyncCrud,), (base_class,),
{ {
"model": model, "model": model,
"searchable_fields": effective_searchable, "searchable_fields": searchable_fields,
"facet_fields": facet_fields, "facet_fields": facet_fields,
"order_fields": order_fields, "order_fields": order_fields,
"m2m_fields": m2m_fields, "m2m_fields": m2m_fields,

View File

@@ -2,7 +2,6 @@
import asyncio import asyncio
import functools import functools
from collections import Counter
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any, Literal from typing import TYPE_CHECKING, Any, Literal
@@ -151,7 +150,7 @@ def build_search_filters(
def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]: def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
"""Return a key for each facet field, disambiguating duplicate column keys. """Return a key for each facet field.
Args: Args:
facet_fields: Sequence of facet fields — either direct columns or facet_fields: Sequence of facet fields — either direct columns or
@@ -160,22 +159,12 @@ def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
Returns: Returns:
A list of string keys, one per facet field, in the same order. A list of string keys, one per facet field, in the same order.
""" """
raw: list[tuple[str, str | None]] = [] keys: list[str] = []
for field in facet_fields: for field in facet_fields:
if isinstance(field, tuple): if isinstance(field, tuple):
rel = field[-2] keys.append("__".join(el.key for el in field))
column = field[-1]
raw.append((column.key, rel.key))
else: else:
raw.append((field.key, None)) keys.append(field.key)
counts = Counter(col_key for col_key, _ in raw)
keys: list[str] = []
for col_key, rel_key in raw:
if counts[col_key] > 1 and rel_key is not None:
keys.append(f"{rel_key}__{col_key}")
else:
keys.append(col_key)
return keys return keys

View File

@@ -56,6 +56,7 @@ def create_db_dependency(
async def get_db() -> AsyncGenerator[AsyncSession, None]: async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with session_maker() as session: async with session_maker() as session:
await session.connection()
yield session yield session
if session.in_transaction(): if session.in_transaction():
await session.commit() await session.commit()

View File

@@ -1,10 +1,12 @@
"""Dependency factories for FastAPI routes.""" """Dependency factories for FastAPI routes."""
import inspect import inspect
import typing
from collections.abc import Callable from collections.abc import Callable
from typing import Any, cast from typing import Any, cast
from fastapi import Depends from fastapi import Depends
from fastapi.params import Depends as DependsClass
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from .crud import CrudFactory from .crud import CrudFactory
@@ -13,6 +15,15 @@ from .types import ModelType, SessionDependency
__all__ = ["BodyDependency", "PathDependency"] __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( def PathDependency(
model: type[ModelType], model: type[ModelType],
field: Any, field: Any,
@@ -44,6 +55,7 @@ def PathDependency(
): ... ): ...
``` ```
""" """
session_callable = _unwrap_session_dep(session_dep)
crud = CrudFactory(model) crud = CrudFactory(model)
name = ( name = (
param_name param_name
@@ -53,7 +65,7 @@ def PathDependency(
python_type = field.type.python_type python_type = field.type.python_type
async def dependency( async def dependency(
session: AsyncSession = Depends(session_dep), **kwargs: Any session: AsyncSession = Depends(session_callable), **kwargs: Any
) -> ModelType: ) -> ModelType:
value = kwargs[name] value = kwargs[name]
return await crud.get(session, filters=[field == value]) return await crud.get(session, filters=[field == value])
@@ -70,7 +82,7 @@ def PathDependency(
"session", "session",
inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.KEYWORD_ONLY,
annotation=AsyncSession, 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) crud = CrudFactory(model)
python_type = field.type.python_type python_type = field.type.python_type
async def dependency( async def dependency(
session: AsyncSession = Depends(session_dep), **kwargs: Any session: AsyncSession = Depends(session_callable), **kwargs: Any
) -> ModelType: ) -> ModelType:
value = kwargs[body_field] value = kwargs[body_field]
return await crud.get(session, filters=[field == value]) return await crud.get(session, filters=[field == value])
@@ -133,7 +146,7 @@ def BodyDependency(
"session", "session",
inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.KEYWORD_ONLY,
annotation=AsyncSession, annotation=AsyncSession,
default=Depends(session_dep), default=Depends(session_callable),
), ),
] ]
), ),

View File

@@ -30,7 +30,7 @@ def init_exceptions_handlers(app: FastAPI) -> FastAPI:
""" """
_register_exception_handlers(app) _register_exception_handlers(app)
_original_openapi = app.openapi _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 return app

View File

@@ -2,6 +2,7 @@
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum
from typing import Any, cast from typing import Any, cast
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
@@ -12,6 +13,13 @@ from .enum import Context
logger = get_logger() 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 @dataclass
class Fixture: class Fixture:
"""A fixture definition with metadata.""" """A fixture definition with metadata."""
@@ -50,26 +58,51 @@ class FixtureRegistry:
Post(id=1, title="Test", user_id=1), 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__( def __init__(
self, self,
contexts: list[str | Context] | None = None, contexts: list[str | Enum] | None = None,
) -> None: ) -> None:
self._fixtures: dict[str, Fixture] = {} self._fixtures: dict[str, list[Fixture]] = {}
self._default_contexts: list[str] | None = ( self._default_contexts: list[str] | None = (
[c.value if isinstance(c, Context) else c for c in contexts] _normalize_contexts(contexts) if contexts else None
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( def register(
self, self,
func: Callable[[], Sequence[DeclarativeBase]] | None = None, func: Callable[[], Sequence[DeclarativeBase]] | None = None,
*, *,
name: str | None = None, name: str | None = None,
depends_on: list[str] | None = None, depends_on: list[str] | None = None,
contexts: list[str | Context] | None = None, contexts: list[str | Enum] | None = None,
) -> Callable[..., Any]: ) -> Callable[..., Any]:
"""Register a fixture function. """Register a fixture function.
@@ -79,7 +112,8 @@ class FixtureRegistry:
func: Fixture function returning list of model instances func: Fixture function returning list of model instances
name: Fixture name (defaults to function name) name: Fixture name (defaults to function name)
depends_on: List of fixture names this depends on 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: Example:
```python ```python
@@ -90,7 +124,6 @@ class FixtureRegistry:
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING]) @fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
def test_users(): def test_users():
return [User(id=1, username="test", role_id=1)] return [User(id=1, username="test", role_id=1)]
```
""" """
def decorator( def decorator(
@@ -98,19 +131,20 @@ class FixtureRegistry:
) -> Callable[[], Sequence[DeclarativeBase]]: ) -> Callable[[], Sequence[DeclarativeBase]]:
fixture_name = name or cast(Any, fn).__name__ fixture_name = name or cast(Any, fn).__name__
if contexts is not None: if contexts is not None:
fixture_contexts = [ fixture_contexts = _normalize_contexts(contexts)
c.value if isinstance(c, Context) else c for c in contexts
]
elif self._default_contexts is not None: elif self._default_contexts is not None:
fixture_contexts = self._default_contexts fixture_contexts = self._default_contexts
else: else:
fixture_contexts = [Context.BASE.value] fixture_contexts = [Context.BASE.value]
self._fixtures[fixture_name] = Fixture( self._validate_no_context_overlap(fixture_name, fixture_contexts)
name=fixture_name, self._fixtures.setdefault(fixture_name, []).append(
func=fn, Fixture(
depends_on=depends_on or [], name=fixture_name,
contexts=fixture_contexts, func=fn,
depends_on=depends_on or [],
contexts=fixture_contexts,
)
) )
return fn return fn
@@ -121,11 +155,14 @@ class FixtureRegistry:
def include_registry(self, registry: "FixtureRegistry") -> None: def include_registry(self, registry: "FixtureRegistry") -> None:
"""Include another `FixtureRegistry` in the same current `FixtureRegistry`. """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: Args:
registry: The `FixtureRegistry` to include registry: The `FixtureRegistry` to include
Raises: Raises:
ValueError: If a fixture name already exists in the current registry ValueError: If a fixture name already exists with overlapping contexts
Example: Example:
```python ```python
@@ -139,31 +176,73 @@ class FixtureRegistry:
registry.include_registry(registry=dev_registry) registry.include_registry(registry=dev_registry)
``` ```
""" """
for name, fixture in registry._fixtures.items(): for name, variants in registry._fixtures.items():
if name in self._fixtures: for fixture in variants:
raise ValueError( self._validate_no_context_overlap(name, fixture.contexts)
f"Fixture '{name}' already exists in the current registry" self._fixtures.setdefault(name, []).append(fixture)
)
self._fixtures[name] = fixture
def get(self, name: str) -> 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: if name not in self._fixtures:
raise KeyError(f"Fixture '{name}' not found") 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]: def get_all(self) -> list[Fixture]:
"""Get all registered fixtures.""" """Get all registered fixtures (all variants of all names)."""
return list(self._fixtures.values()) 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.""" """Get fixtures for specific contexts."""
context_values = {c.value if isinstance(c, Context) else c for c in contexts} context_values = set(_normalize_contexts(contexts))
return [f for f in self._fixtures.values() if set(f.contexts) & context_values] 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]: def resolve_dependencies(self, *names: str) -> list[str]:
"""Resolve fixture dependencies in topological order. """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: Args:
*names: Fixture names to resolve *names: Fixture names to resolve
@@ -185,9 +264,20 @@ class FixtureRegistry:
raise ValueError(f"Circular dependency detected: {name}") raise ValueError(f"Circular dependency detected: {name}")
visiting.add(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) visit(dep)
visiting.remove(name) visiting.remove(name)
@@ -199,7 +289,7 @@ class FixtureRegistry:
return resolved 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. """Resolve all fixtures for contexts with dependencies.
Args: Args:
@@ -209,7 +299,9 @@ class FixtureRegistry:
List of fixture names in load order List of fixture names in load order
""" """
context_fixtures = self.get_by_context(*contexts) 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() all_deps: set[str] = set()
for name in names: for name in names:

View File

@@ -1,8 +1,11 @@
"""Fixture loading utilities for database seeding.""" """Fixture loading utilities for database seeding."""
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from enum import Enum
from typing import Any from typing import Any
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
@@ -10,23 +13,163 @@ from ..db import get_transaction
from ..logger import get_logger from ..logger import get_logger
from ..types import ModelType from ..types import ModelType
from .enum import LoadStrategy from .enum import LoadStrategy
from .registry import Context, FixtureRegistry from .registry import FixtureRegistry, _normalize_contexts
logger = get_logger() logger = get_logger()
def _instance_to_dict(instance: DeclarativeBase) -> dict[str, Any]:
"""Extract column values from a model instance, skipping unset server-default columns."""
state = sa_inspect(instance)
state_dict = state.dict
result: dict[str, Any] = {}
for prop in state.mapper.column_attrs:
if prop.key not in state_dict:
continue
val = state_dict[prop.key]
if val is None:
col = prop.columns[0]
if col.server_default is not None or (
col.default is not None and col.default.is_callable
):
continue
result[prop.key] = val
return result
def _normalize_rows(dicts: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Ensure all row dicts share the same key set."""
all_keys: set[str] = set().union(*dicts)
return [{k: d.get(k) for k in all_keys} for d in dicts]
def _group_by_type(
instances: list[DeclarativeBase],
) -> list[tuple[type[DeclarativeBase], list[DeclarativeBase]]]:
"""Group instances by their concrete model class, preserving insertion order."""
groups: dict[type[DeclarativeBase], list[DeclarativeBase]] = {}
for instance in instances:
groups.setdefault(type(instance), []).append(instance)
return list(groups.items())
async def _batch_insert(
session: AsyncSession,
model_cls: type[DeclarativeBase],
instances: list[DeclarativeBase],
) -> None:
"""INSERT all instances — raises on conflict (no duplicate handling)."""
dicts = _normalize_rows([_instance_to_dict(i) for i in instances])
await session.execute(pg_insert(model_cls).values(dicts))
async def _batch_merge(
session: AsyncSession,
model_cls: type[DeclarativeBase],
instances: list[DeclarativeBase],
) -> None:
"""UPSERT: insert new rows, update existing ones with the provided values."""
mapper = model_cls.__mapper__
pk_names = [col.name for col in mapper.primary_key]
pk_names_set = set(pk_names)
non_pk_cols = [
prop.key
for prop in mapper.column_attrs
if not any(col.name in pk_names_set for col in prop.columns)
]
dicts = _normalize_rows([_instance_to_dict(i) for i in instances])
stmt = pg_insert(model_cls).values(dicts)
inserted_keys = set(dicts[0]) if dicts else set()
update_cols = [col for col in non_pk_cols if col in inserted_keys]
if update_cols:
stmt = stmt.on_conflict_do_update(
index_elements=pk_names,
set_={col: stmt.excluded[col] for col in update_cols},
)
else:
stmt = stmt.on_conflict_do_nothing(index_elements=pk_names)
await session.execute(stmt)
async def _batch_skip_existing(
session: AsyncSession,
model_cls: type[DeclarativeBase],
instances: list[DeclarativeBase],
) -> list[DeclarativeBase]:
"""INSERT only rows that do not already exist; return the inserted ones."""
mapper = model_cls.__mapper__
pk_names = [col.name for col in mapper.primary_key]
no_pk: list[DeclarativeBase] = []
with_pk_pairs: list[tuple[DeclarativeBase, Any]] = []
for inst in instances:
pk = _get_primary_key(inst)
if pk is None:
no_pk.append(inst)
else:
with_pk_pairs.append((inst, pk))
loaded: list[DeclarativeBase] = list(no_pk)
if no_pk:
await session.execute(
pg_insert(model_cls).values(
_normalize_rows([_instance_to_dict(i) for i in no_pk])
)
)
if with_pk_pairs:
with_pk = [i for i, _ in with_pk_pairs]
stmt = (
pg_insert(model_cls)
.values(_normalize_rows([_instance_to_dict(i) for i in with_pk]))
.on_conflict_do_nothing(index_elements=pk_names)
)
result = await session.execute(stmt.returning(*mapper.primary_key))
inserted_pks = {row[0] if len(pk_names) == 1 else tuple(row) for row in result}
loaded.extend(inst for inst, pk in with_pk_pairs if pk in inserted_pks)
return loaded
async def _load_ordered( async def _load_ordered(
session: AsyncSession, session: AsyncSession,
registry: FixtureRegistry, registry: FixtureRegistry,
ordered_names: list[str], ordered_names: list[str],
strategy: LoadStrategy, strategy: LoadStrategy,
contexts: tuple[str, ...] | None = None,
) -> dict[str, list[DeclarativeBase]]: ) -> dict[str, list[DeclarativeBase]]:
"""Load fixtures in order.""" """Load fixtures in order using batch Core INSERT statements.
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]] = {} results: dict[str, list[DeclarativeBase]] = {}
for name in ordered_names: for name in ordered_names:
fixture = registry.get(name) variants = (
instances = list(fixture.func()) 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: if not instances:
results[name] = [] results[name] = []
@@ -36,25 +179,17 @@ async def _load_ordered(
loaded: list[DeclarativeBase] = [] loaded: list[DeclarativeBase] = []
async with get_transaction(session): async with get_transaction(session):
for instance in instances: for model_cls, group in _group_by_type(instances):
if strategy == LoadStrategy.INSERT: match strategy:
session.add(instance) case LoadStrategy.INSERT:
loaded.append(instance) await _batch_insert(session, model_cls, group)
loaded.extend(group)
elif strategy == LoadStrategy.MERGE: case LoadStrategy.MERGE:
merged = await session.merge(instance) await _batch_merge(session, model_cls, group)
loaded.append(merged) loaded.extend(group)
case LoadStrategy.SKIP_EXISTING:
else: # LoadStrategy.SKIP_EXISTING inserted = await _batch_skip_existing(session, model_cls, group)
pk = _get_primary_key(instance) loaded.extend(inserted)
if pk is not None:
existing = await session.get(type(instance), pk)
if existing is None:
session.add(instance)
loaded.append(instance)
else:
session.add(instance)
loaded.append(instance)
results[name] = loaded results[name] = loaded
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)") logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
@@ -109,6 +244,8 @@ async def load_fixtures(
) -> dict[str, list[DeclarativeBase]]: ) -> dict[str, list[DeclarativeBase]]:
"""Load specific fixtures by name with dependencies. """Load specific fixtures by name with dependencies.
All context variants of each requested fixture are loaded and merged.
Args: Args:
session: Database session session: Database session
registry: Fixture registry registry: Fixture registry
@@ -125,19 +262,27 @@ async def load_fixtures(
async def load_fixtures_by_context( async def load_fixtures_by_context(
session: AsyncSession, session: AsyncSession,
registry: FixtureRegistry, registry: FixtureRegistry,
*contexts: str | Context, *contexts: str | Enum,
strategy: LoadStrategy = LoadStrategy.MERGE, strategy: LoadStrategy = LoadStrategy.MERGE,
) -> dict[str, list[DeclarativeBase]]: ) -> dict[str, list[DeclarativeBase]]:
"""Load all fixtures for specific contexts. """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: Args:
session: Database session session: Database session
registry: Fixture registry 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 strategy: How to handle existing records
Returns: Returns:
Dict mapping fixture names to loaded instances Dict mapping fixture names to loaded instances
""" """
context_strings = tuple(_normalize_contexts(contexts))
ordered = registry.resolve_context_dependencies(*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
)

View File

@@ -66,7 +66,7 @@ def configure_logging(
_SENTINEL = object() _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*. """Return a logger with the given *name*.
A thin convenience wrapper around :func:`logging.getLogger` that keeps A thin convenience wrapper around :func:`logging.getLogger` that keeps

View 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",
]

View File

@@ -1,13 +1,14 @@
"""SQLAlchemy model mixins for common column patterns.""" """SQLAlchemy column mixins for common column patterns."""
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Uuid, func, text from sqlalchemy import DateTime, Uuid, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
__all__ = [ __all__ = [
"UUIDMixin", "UUIDMixin",
"UUIDv7Mixin",
"CreatedAtMixin", "CreatedAtMixin",
"UpdatedAtMixin", "UpdatedAtMixin",
"TimestampMixin", "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: class CreatedAtMixin:
"""Mixin that adds a ``created_at`` timestamp column.""" """Mixin that adds a ``created_at`` timestamp column."""
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
server_default=func.now(), server_default=text("clock_timestamp()"),
) )
@@ -38,8 +49,8 @@ class UpdatedAtMixin:
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
server_default=func.now(), server_default=text("clock_timestamp()"),
onupdate=func.now(), onupdate=text("clock_timestamp()"),
) )

View 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)

View File

@@ -7,6 +7,7 @@ from contextlib import asynccontextmanager
from typing import Any from typing import Any
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.engine import make_url from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
AsyncSession, AsyncSession,
@@ -15,13 +16,8 @@ from sqlalchemy.ext.asyncio import (
) )
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import text from ..db import cleanup_tables as _cleanup_tables
from ..db import create_database
from ..db import (
cleanup_tables as _cleanup_tables,
create_database,
create_db_context,
)
async def cleanup_tables( async def cleanup_tables(
@@ -129,7 +125,8 @@ async def create_worker_database(
worker_url = worker_database_url( worker_url = worker_database_url(
database_url=database_url, default_test_db=default_test_db 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") engine = create_async_engine(database_url, isolation_level="AUTOCOMMIT")
try: try:
@@ -268,15 +265,12 @@ async def create_db_session(
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(base.metadata.create_all) 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) session_maker = async_sessionmaker(engine, expire_on_commit=expire_on_commit)
get_session = create_db_context(session_maker) async with session_maker() as session:
async with get_session() as session:
yield session yield session
if cleanup: if cleanup:
await cleanup_tables(session, base) await _cleanup_tables(session=session, base=base)
if drop_tables: if drop_tables:
async with engine.begin() as conn: async with engine.begin() as conn:

View File

@@ -1,18 +1,22 @@
"""Base Pydantic schemas for API responses.""" """Base Pydantic schemas for API responses."""
import math
from enum import Enum 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 from .types import DataT
__all__ = [ __all__ = [
"ApiError", "ApiError",
"CursorPagination", "CursorPagination",
"CursorPaginatedResponse",
"ErrorResponse", "ErrorResponse",
"OffsetPagination", "OffsetPagination",
"OffsetPaginatedResponse",
"PaginatedResponse", "PaginatedResponse",
"PaginationType",
"PydanticBase", "PydanticBase",
"Response", "Response",
"ResponseStatus", "ResponseStatus",
@@ -95,17 +99,29 @@ class OffsetPagination(PydanticBase):
"""Pagination metadata for offset-based list responses. """Pagination metadata for offset-based list responses.
Attributes: 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 items_per_page: Number of items per page
page: Current page number (1-indexed) page: Current page number (1-indexed)
has_more: Whether there are more pages has_more: Whether there are more pages
pages: Total number of pages
""" """
total_count: int total_count: int | None
items_per_page: int items_per_page: int
page: int page: int
has_more: bool 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): class CursorPagination(PydanticBase):
"""Pagination metadata for cursor-based list responses. """Pagination metadata for cursor-based list responses.
@@ -123,9 +139,66 @@ class CursorPagination(PydanticBase):
has_more: bool has_more: bool
class PaginationType(str, Enum):
"""Pagination strategy selector for :meth:`.AsyncCrud.paginate`."""
OFFSET = "offset"
CURSOR = "cursor"
class PaginatedResponse(BaseResponse, Generic[DataT]): 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] data: list[DataT]
pagination: OffsetPagination | CursorPagination pagination: OffsetPagination | CursorPagination
pagination_type: PaginationType | None = None
filter_attributes: dict[str, list[Any]] | 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

View File

@@ -24,4 +24,4 @@ SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any],
FacetFieldType = SearchFieldType FacetFieldType = SearchFieldType
# Dependency type aliases # Dependency type aliases
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] | Any

View File

@@ -57,6 +57,7 @@ class User(Base):
username: Mapped[str] = mapped_column(String(50), unique=True) username: Mapped[str] = mapped_column(String(50), unique=True)
email: Mapped[str] = mapped_column(String(100), unique=True) email: Mapped[str] = mapped_column(String(100), unique=True)
is_active: Mapped[bool] = mapped_column(default=True) is_active: Mapped[bool] = mapped_column(default=True)
notes: Mapped[str | None]
role_id: Mapped[uuid.UUID | None] = mapped_column( role_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("roles.id"), nullable=True ForeignKey("roles.id"), nullable=True
) )
@@ -321,30 +322,3 @@ async def db_session(engine):
# Drop tables after test # Drop tables after test
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all) 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(),
)

View File

@@ -6,8 +6,8 @@ import pytest
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from fastapi_toolsets.crud import CrudFactory from fastapi_toolsets.crud import CrudFactory, PaginationType
from fastapi_toolsets.crud.factory import AsyncCrud from fastapi_toolsets.crud.factory import AsyncCrud, _CursorDirection
from fastapi_toolsets.exceptions import NotFoundError from fastapi_toolsets.exceptions import NotFoundError
from .conftest import ( from .conftest import (
@@ -86,6 +86,101 @@ class TestCrudFactory:
assert crud_with.default_load_options == options assert crud_with.default_load_options == options
assert crud_without.default_load_options is None 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: class TestResolveLoadOptions:
"""Tests for _resolve_load_options logic.""" """Tests for _resolve_load_options logic."""
@@ -1664,6 +1759,52 @@ class TestSchemaResponse:
assert result.data[0].username == "pg_user" assert result.data[0].username == "pg_user"
assert not hasattr(result.data[0], "email") 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: class TestCursorPaginate:
"""Tests for cursor-based pagination via cursor_paginate().""" """Tests for cursor-based pagination via cursor_paginate()."""
@@ -1874,11 +2015,8 @@ class TestCursorPaginatePrevCursor:
assert page2.pagination.prev_cursor is not None assert page2.pagination.prev_cursor is not None
@pytest.mark.anyio @pytest.mark.anyio
async def test_prev_cursor_points_to_first_item(self, db_session: AsyncSession): async def test_prev_cursor_navigates_back(self, db_session: AsyncSession):
"""prev_cursor encodes the value of the first item on the current page.""" """prev_cursor on page 2 navigates back to the same items as page 1."""
import base64
import json
for i in range(10): for i in range(10):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}")) await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
@@ -1897,12 +2035,83 @@ class TestCursorPaginatePrevCursor:
assert isinstance(page2.pagination, CursorPagination) assert isinstance(page2.pagination, CursorPagination)
assert page2.pagination.prev_cursor is not None assert page2.pagination.prev_cursor is not None
# Decode prev_cursor and compare to first item's id # Using prev_cursor should return the same items as page 1
decoded = json.loads( back_to_page1 = await RoleCursorCrud.cursor_paginate(
base64.b64decode(page2.pagination.prev_cursor.encode()).decode() db_session,
cursor=page2.pagination.prev_cursor,
items_per_page=5,
schema=RoleRead,
) )
first_item_id = str(page2.data[0].id) assert isinstance(back_to_page1.pagination, CursorPagination)
assert decoded == first_item_id 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: class TestCursorPaginateWithSearch:
@@ -2289,3 +2498,89 @@ class TestCursorPaginateColumnTypes:
page1_ids = {p.id for p in page1.data} page1_ids = {p.id for p in page1.data}
page2_ids = {p.id for p in page2.data} page2_ids = {p.id for p in page2.data}
assert page1_ids.isdisjoint(page2_ids) 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

View File

@@ -14,12 +14,14 @@ from fastapi_toolsets.crud import (
get_searchable_fields, get_searchable_fields,
) )
from fastapi_toolsets.exceptions import InvalidOrderFieldError from fastapi_toolsets.exceptions import InvalidOrderFieldError
from fastapi_toolsets.schemas import OffsetPagination from fastapi_toolsets.schemas import OffsetPagination, PaginationType
from .conftest import ( from .conftest import (
Role, Role,
RoleCreate, RoleCreate,
RoleCrud, RoleCrud,
RoleCursorCrud,
RoleRead,
User, User,
UserCreate, UserCreate,
UserCrud, UserCrud,
@@ -644,7 +646,7 @@ class TestFacetsRelationship:
result = await UserRelFacetCrud.offset_paginate(db_session, schema=UserRead) result = await UserRelFacetCrud.offset_paginate(db_session, schema=UserRead)
assert result.filter_attributes is not None assert result.filter_attributes is not None
assert set(result.filter_attributes["name"]) == {"admin", "editor"} assert set(result.filter_attributes["role__name"]) == {"admin", "editor"}
@pytest.mark.anyio @pytest.mark.anyio
async def test_relationship_facet_none_excluded(self, db_session: AsyncSession): async def test_relationship_facet_none_excluded(self, db_session: AsyncSession):
@@ -659,7 +661,7 @@ class TestFacetsRelationship:
result = await UserRelFacetCrud.offset_paginate(db_session, schema=UserRead) result = await UserRelFacetCrud.offset_paginate(db_session, schema=UserRead)
assert result.filter_attributes is not None assert result.filter_attributes is not None
assert result.filter_attributes["name"] == [] assert result.filter_attributes["role__name"] == []
@pytest.mark.anyio @pytest.mark.anyio
async def test_relationship_facet_deduplicates_join_with_search( async def test_relationship_facet_deduplicates_join_with_search(
@@ -687,7 +689,7 @@ class TestFacetsRelationship:
) )
assert result.filter_attributes is not None assert result.filter_attributes is not None
assert result.filter_attributes["name"] == ["admin"] assert result.filter_attributes["role__name"] == ["admin"]
class TestFilterBy: class TestFilterBy:
@@ -753,7 +755,7 @@ class TestFilterBy:
) )
result = await UserRelFacetCrud.offset_paginate( result = await UserRelFacetCrud.offset_paginate(
db_session, filter_by={"name": "admin"}, schema=UserRead db_session, filter_by={"role__name": "admin"}, schema=UserRead
) )
assert isinstance(result.pagination, OffsetPagination) assert isinstance(result.pagination, OffsetPagination)
@@ -822,7 +824,7 @@ class TestFilterBy:
result = await UserRoleFacetCrud.offset_paginate( result = await UserRoleFacetCrud.offset_paginate(
db_session, db_session,
filter_by={"name": "admin", "id": str(admin.id)}, filter_by={"role__name": "admin", "role__id": str(admin.id)},
schema=UserRead, schema=UserRead,
) )
@@ -914,15 +916,15 @@ class TestFilterParamsSchema:
param_names = set(inspect.signature(dep).parameters) param_names = set(inspect.signature(dep).parameters)
assert param_names == {"username", "email"} assert param_names == {"username", "email"}
def test_relationship_facet_uses_column_key(self): def test_relationship_facet_uses_full_chain_key(self):
"""Relationship tuple uses the terminal column's key.""" """Relationship tuple uses the full chain joined by __ as the key."""
import inspect import inspect
UserRoleCrud = CrudFactory(User, facet_fields=[(User.role, Role.name)]) UserRoleCrud = CrudFactory(User, facet_fields=[(User.role, Role.name)])
dep = UserRoleCrud.filter_params() dep = UserRoleCrud.filter_params()
param_names = set(inspect.signature(dep).parameters) param_names = set(inspect.signature(dep).parameters)
assert param_names == {"name"} assert param_names == {"role__name"}
def test_raises_when_no_facet_fields(self): def test_raises_when_no_facet_fields(self):
"""ValueError raised when no facet_fields are configured or provided.""" """ValueError raised when no facet_fields are configured or provided."""
@@ -976,6 +978,22 @@ class TestFilterParamsSchema:
keys = facet_keys([(rel_a, col_a), (rel_b, col_b)]) keys = facet_keys([(rel_a, col_a), (rel_b, col_b)])
assert keys == ["project__name", "os__name"] assert keys == ["project__name", "os__name"]
def test_deep_chain_joins_all_segments(self):
"""Three-element tuple produces all relation segments joined by __."""
from unittest.mock import MagicMock
from fastapi_toolsets.crud.search import facet_keys
rel_a = MagicMock()
rel_a.key = "role"
rel_b = MagicMock()
rel_b.key = "permission"
col = MagicMock()
col.key = "name"
keys = facet_keys([(rel_a, rel_b, col)])
assert keys == ["role__permission__name"]
def test_unique_column_keys_kept_plain(self): def test_unique_column_keys_kept_plain(self):
"""Fields with unique column keys are not prefixed.""" """Fields with unique column keys are not prefixed."""
from fastapi_toolsets.crud.search import facet_keys from fastapi_toolsets.crud.search import facet_keys
@@ -988,7 +1006,7 @@ class TestFilterParamsSchema:
UserFacetCrud = CrudFactory(User, facet_fields=[User.username]) UserFacetCrud = CrudFactory(User, facet_fields=[User.username])
dep = UserFacetCrud.filter_params() 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 @pytest.mark.anyio
async def test_integration_with_offset_paginate(self, db_session: AsyncSession): async def test_integration_with_offset_paginate(self, db_session: AsyncSession):
@@ -1193,3 +1211,245 @@ class TestOrderParamsSchema:
assert results[0].username == "alice" assert results[0].username == "alice"
assert results[1].username == "charlie" 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)

View File

@@ -68,6 +68,55 @@ class TestCreateDbDependency:
await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.drop_all)
await engine.dispose() 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: class TestCreateDbContext:
"""Tests for create_db_context.""" """Tests for create_db_context."""
@@ -363,7 +412,8 @@ class TestCreateDatabase:
.set(database="test_create_db_general") .set(database="test_create_db_general")
.render_as_string(hide_password=False) .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") engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
try: try:

View File

@@ -3,20 +3,42 @@
import inspect import inspect
import uuid import uuid
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Any, cast from typing import Annotated, Any, cast
import pytest import pytest
from fastapi.params import Depends from fastapi.params import Depends
from sqlalchemy.ext.asyncio import AsyncSession 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 from .conftest import Role, RoleCreate, RoleCrud, User
async def mock_get_db() -> AsyncGenerator[AsyncSession, None]: async def mock_get_db() -> AsyncGenerator[AsyncSession, None]:
"""Mock session dependency for testing.""" """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: class TestPathDependency:
@@ -95,6 +117,39 @@ class TestPathDependency:
assert result.id == role.id assert result.id == role.id
assert result.name == "test_role" 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: class TestBodyDependency:
"""Tests for BodyDependency factory.""" """Tests for BodyDependency factory."""
@@ -184,3 +239,39 @@ class TestBodyDependency:
assert result.id == role.id assert result.id == role.id
assert result.name == "body_test_role" 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"

View File

@@ -10,12 +10,13 @@ import datetime
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient 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.db import get_db
from docs_src.examples.pagination_search.models import Article, Base, Category from docs_src.examples.pagination_search.models import Article, Base, Category
from docs_src.examples.pagination_search.routes import router from docs_src.examples.pagination_search.routes import router
from fastapi_toolsets.exceptions import init_exceptions_handlers from fastapi_toolsets.exceptions import init_exceptions_handlers
from fastapi_toolsets.pytest import create_db_session
from .conftest import DATABASE_URL from .conftest import DATABASE_URL
@@ -35,20 +36,8 @@ def build_app(session: AsyncSession) -> FastAPI:
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
async def ex_db_session(): async def ex_db_session():
"""Isolated session for the example models (separate tables from conftest).""" """Isolated session for the example models (separate tables from conftest)."""
engine = create_async_engine(DATABASE_URL, echo=False) async with create_db_session(DATABASE_URL, Base) as session:
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:
yield 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 @pytest.fixture
@@ -108,7 +97,7 @@ class TestAppSessionDep:
gen = get_db() gen = get_db()
session = await gen.__anext__() session = await gen.__anext__()
assert isinstance(session, AsyncSession) assert isinstance(session, AsyncSession)
await session.close() await gen.aclose()
class TestOffsetPagination: class TestOffsetPagination:
@@ -193,8 +182,7 @@ class TestOffsetPagination:
body = resp.json() body = resp.json()
fa = body["filter_attributes"] fa = body["filter_attributes"]
assert set(fa["status"]) == {"draft", "published"} assert set(fa["status"]) == {"draft", "published"}
# "name" is unique across all facet fields — no prefix needed assert set(fa["category__name"]) == {"backend", "python"}
assert set(fa["name"]) == {"backend", "python"}
@pytest.mark.anyio @pytest.mark.anyio
async def test_filter_attributes_scoped_to_filter( async def test_filter_attributes_scoped_to_filter(
@@ -393,3 +381,105 @@ class TestCursorSorting:
body = resp.json() body = resp.json()
assert body["error_code"] == "SORT-422" assert body["error_code"] == "SORT-422"
assert body["status"] == "FAIL" 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

View File

@@ -1,6 +1,7 @@
"""Tests for fastapi_toolsets.fixtures module.""" """Tests for fastapi_toolsets.fixtures module."""
import uuid import uuid
from enum import Enum
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,10 +14,22 @@ from fastapi_toolsets.fixtures import (
load_fixtures, load_fixtures,
load_fixtures_by_context, load_fixtures_by_context,
) )
from fastapi_toolsets.fixtures.utils import _get_primary_key, _instance_to_dict
from fastapi_toolsets.fixtures.utils import _get_primary_key from .conftest import IntRole, Permission, Role, RoleCreate, RoleCrud, User, UserCrud
from .conftest import IntRole, Permission, Role, User
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: class TestContext:
@@ -39,6 +52,86 @@ class TestContext:
assert Context.TESTING.value == "testing" 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: class TestLoadStrategy:
"""Tests for LoadStrategy enum.""" """Tests for LoadStrategy enum."""
@@ -407,6 +500,37 @@ class TestDependencyResolution:
with pytest.raises(ValueError, match="Circular dependency"): with pytest.raises(ValueError, match="Circular dependency"):
registry.resolve_dependencies("a") 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): def test_resolve_context_dependencies(self):
"""Resolve all fixtures for a context with dependencies.""" """Resolve all fixtures for a context with dependencies."""
registry = FixtureRegistry() registry = FixtureRegistry()
@@ -447,8 +571,6 @@ class TestLoadFixtures:
assert "roles" in result assert "roles" in result
assert len(result["roles"]) == 2 assert len(result["roles"]) == 2
from .conftest import RoleCrud
count = await RoleCrud.count(db_session) count = await RoleCrud.count(db_session)
assert count == 2 assert count == 2
@@ -479,8 +601,6 @@ class TestLoadFixtures:
assert "roles" in result assert "roles" in result
assert "users" in result assert "users" in result
from .conftest import RoleCrud, UserCrud
assert await RoleCrud.count(db_session) == 1 assert await RoleCrud.count(db_session) == 1
assert await UserCrud.count(db_session) == 1 assert await UserCrud.count(db_session) == 1
@@ -497,11 +617,55 @@ class TestLoadFixtures:
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE) await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
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) count = await RoleCrud.count(db_session)
assert count == 1 assert count == 1
@pytest.mark.anyio
async def test_merge_does_not_overwrite_omitted_nullable_columns(
self, db_session: AsyncSession
):
"""MERGE must not clear nullable columns that the fixture didn't set.
When a fixture omits a nullable column (e.g. role_id or notes), a re-merge
must leave the existing DB value untouched — not overwrite it with NULL.
"""
registry = FixtureRegistry()
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
uid = uuid.uuid4()
# First load: user has role_id and notes set
@registry.register
def users():
return [
User(
id=uid,
username="alice",
email="a@test.com",
role_id=admin.id,
notes="original",
)
]
await load_fixtures(db_session, registry, "users", strategy=LoadStrategy.MERGE)
# Second load: fixture omits role_id and notes
registry2 = FixtureRegistry()
@registry2.register
def users(): # noqa: F811
return [User(id=uid, username="alice-updated", email="a@test.com")]
await load_fixtures(db_session, registry2, "users", strategy=LoadStrategy.MERGE)
from sqlalchemy import select
row = (
await db_session.execute(select(User).where(User.id == uid))
).scalar_one()
assert row.username == "alice-updated" # updated column changed
assert row.role_id == admin.id # omitted → preserved
assert row.notes == "original" # omitted → preserved
@pytest.mark.anyio @pytest.mark.anyio
async def test_load_with_skip_existing_strategy(self, db_session: AsyncSession): async def test_load_with_skip_existing_strategy(self, db_session: AsyncSession):
"""Load fixtures with SKIP_EXISTING strategy.""" """Load fixtures with SKIP_EXISTING strategy."""
@@ -526,8 +690,6 @@ class TestLoadFixtures:
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
) )
from .conftest import RoleCrud
role = await RoleCrud.first(db_session, [Role.id == role_id]) role = await RoleCrud.first(db_session, [Role.id == role_id])
assert role is not None assert role is not None
assert role.name == "original" assert role.name == "original"
@@ -553,8 +715,6 @@ class TestLoadFixtures:
assert "roles" in result assert "roles" in result
assert len(result["roles"]) == 2 assert len(result["roles"]) == 2
from .conftest import RoleCrud
count = await RoleCrud.count(db_session) count = await RoleCrud.count(db_session)
assert count == 2 assert count == 2
@@ -594,8 +754,6 @@ class TestLoadFixtures:
assert "roles" in result assert "roles" in result
assert "other_roles" in result assert "other_roles" in result
from .conftest import RoleCrud
count = await RoleCrud.count(db_session) count = await RoleCrud.count(db_session)
assert count == 2 assert count == 2
@@ -660,8 +818,6 @@ class TestLoadFixturesByContext:
await load_fixtures_by_context(db_session, registry, Context.BASE) await load_fixtures_by_context(db_session, registry, Context.BASE)
from .conftest import RoleCrud
count = await RoleCrud.count(db_session) count = await RoleCrud.count(db_session)
assert count == 1 assert count == 1
@@ -688,8 +844,6 @@ class TestLoadFixturesByContext:
db_session, registry, Context.BASE, Context.TESTING db_session, registry, Context.BASE, Context.TESTING
) )
from .conftest import RoleCrud
count = await RoleCrud.count(db_session) count = await RoleCrud.count(db_session)
assert count == 2 assert count == 2
@@ -717,8 +871,6 @@ class TestLoadFixturesByContext:
await load_fixtures_by_context(db_session, registry, Context.TESTING) await load_fixtures_by_context(db_session, registry, Context.TESTING)
from .conftest import RoleCrud, UserCrud
assert await RoleCrud.count(db_session) == 1 assert await RoleCrud.count(db_session) == 1
assert await UserCrud.count(db_session) == 1 assert await UserCrud.count(db_session) == 1
@@ -813,3 +965,145 @@ class TestGetPrimaryKey:
instance = Permission(subject="post") # action is None instance = Permission(subject="post") # action is None
pk = _get_primary_key(instance) pk = _get_primary_key(instance)
assert pk is None 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")
class TestInstanceToDict:
"""Unit tests for the _instance_to_dict helper."""
def test_explicit_values_included(self):
"""All explicitly set column values appear in the result."""
role_id = uuid.uuid4()
instance = Role(id=role_id, name="admin")
d = _instance_to_dict(instance)
assert d["id"] == role_id
assert d["name"] == "admin"
def test_callable_default_none_excluded(self):
"""A column whose value is None but has a callable Python-side default
(e.g. ``default=uuid.uuid4``) is excluded so the DB generates it."""
instance = Role(id=None, name="admin")
d = _instance_to_dict(instance)
assert "id" not in d
assert d["name"] == "admin"
def test_nullable_none_included(self):
"""None on a nullable column with no default is kept (explicit NULL)."""
instance = User(id=uuid.uuid4(), username="u", email="e@e.com", role_id=None)
d = _instance_to_dict(instance)
assert "role_id" in d
assert d["role_id"] is None
def test_nullable_str_no_default_omitted_not_in_dict(self):
"""Mapped[str | None] with no default, not provided in constructor, is absent from dict."""
instance = User(id=uuid.uuid4(), username="u", email="e@e.com")
d = _instance_to_dict(instance)
assert "notes" not in d
def test_nullable_str_no_default_explicit_none_included(self):
"""Mapped[str | None] with no default, explicitly set to None, is included as NULL."""
instance = User(id=uuid.uuid4(), username="u", email="e@e.com", notes=None)
d = _instance_to_dict(instance)
assert "notes" in d
assert d["notes"] is None
def test_nullable_str_no_default_with_value_included(self):
"""Mapped[str | None] with no default and a value set is included normally."""
instance = User(id=uuid.uuid4(), username="u", email="e@e.com", notes="hello")
d = _instance_to_dict(instance)
assert d["notes"] == "hello"
@pytest.mark.anyio
async def test_nullable_str_no_default_insert_roundtrip(
self, db_session: AsyncSession
):
"""Fixture loading works for models with Mapped[str | None] (no default).
Both the omitted-value (→ NULL) and explicit-None paths must insert without error.
"""
registry = FixtureRegistry()
uid_a = uuid.uuid4()
uid_b = uuid.uuid4()
uid_c = uuid.uuid4()
@registry.register
def users():
return [
User(
id=uid_a, username="no_notes", email="a@test.com"
), # notes omitted
User(
id=uid_b, username="null_notes", email="b@test.com", notes=None
), # explicit None
User(
id=uid_c, username="has_notes", email="c@test.com", notes="hi"
), # value set
]
result = await load_fixtures(db_session, registry, "users")
from sqlalchemy import select
rows = (
(await db_session.execute(select(User).order_by(User.username)))
.scalars()
.all()
)
by_username = {r.username: r for r in rows}
assert by_username["no_notes"].notes is None
assert by_username["null_notes"].notes is None
assert by_username["has_notes"].notes == "hi"
assert len(result["users"]) == 3
class TestBatchMergeNonPkColumns:
"""Batch MERGE on a model with no non-PK columns (PK-only table)."""
@pytest.mark.anyio
async def test_merge_pk_only_model(self, db_session: AsyncSession):
"""MERGE strategy on a PK-only model uses on_conflict_do_nothing."""
registry = FixtureRegistry()
@registry.register
def permissions():
return [
Permission(subject="post", action="read"),
Permission(subject="post", action="write"),
]
result = await load_fixtures(
db_session, registry, "permissions", strategy=LoadStrategy.MERGE
)
assert len(result["permissions"]) == 2
# Run again — conflicts are silently ignored.
result2 = await load_fixtures(
db_session, registry, "permissions", strategy=LoadStrategy.MERGE
)
assert len(result2["permissions"]) == 2

View File

@@ -101,7 +101,7 @@ class TestMetricsImportGuard:
with patch("builtins.__import__", side_effect=blocking_import): with patch("builtins.__import__", side_effect=blocking_import):
mod = importlib.import_module("fastapi_toolsets.metrics") mod = importlib.import_module("fastapi_toolsets.metrics")
with pytest.raises(ImportError, match="prometheus_client"): 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: finally:
for key in list(sys.modules): for key in list(sys.modules):
if key.startswith("fastapi_toolsets.metrics"): if key.startswith("fastapi_toolsets.metrics"):
@@ -171,8 +171,15 @@ class TestPytestImportGuard:
class TestCliImportGuard: class TestCliImportGuard:
"""Tests for CLI module import guard when typer is missing.""" """Tests for CLI module import guard when typer is missing."""
def test_import_raises_without_typer(self): @pytest.mark.parametrize(
"""Importing cli.app raises when typer is missing.""" "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( saved, blocking_import = _reload_without_package(
"fastapi_toolsets.cli.app", ["typer"] "fastapi_toolsets.cli.app", ["typer"]
) )
@@ -186,33 +193,7 @@ class TestCliImportGuard:
try: try:
with patch("builtins.__import__", side_effect=blocking_import): with patch("builtins.__import__", side_effect=blocking_import):
with pytest.raises(ImportError, match="typer"): with pytest.raises(ImportError, match=expected_match):
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\]"
):
importlib.import_module("fastapi_toolsets.cli.app") importlib.import_module("fastapi_toolsets.cli.app")
finally: finally:
for key in list(sys.modules): for key in list(sys.modules):

View File

@@ -1,6 +1,5 @@
"""Tests for fastapi_toolsets.metrics module.""" """Tests for fastapi_toolsets.metrics module."""
import os
import tempfile import tempfile
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@@ -287,6 +286,16 @@ class TestIncludeRegistry:
class TestInitMetrics: class TestInitMetrics:
"""Tests for init_metrics function.""" """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): def test_returns_app(self):
"""Returns the FastAPI app.""" """Returns the FastAPI app."""
app = FastAPI() app = FastAPI()
@@ -294,26 +303,14 @@ class TestInitMetrics:
result = init_metrics(app, registry) result = init_metrics(app, registry)
assert result is app assert result is app
def test_metrics_endpoint_responds(self): def test_metrics_endpoint_responds(self, metrics_client):
"""The /metrics endpoint returns 200.""" """The /metrics endpoint returns 200."""
app = FastAPI() response = metrics_client.get("/metrics")
registry = MetricsRegistry()
init_metrics(app, registry)
client = TestClient(app)
response = client.get("/metrics")
assert response.status_code == 200 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.""" """The /metrics endpoint returns prometheus content type."""
app = FastAPI() response = metrics_client.get("/metrics")
registry = MetricsRegistry()
init_metrics(app, registry)
client = TestClient(app)
response = client.get("/metrics")
assert "text/plain" in response.headers["content-type"] assert "text/plain" in response.headers["content-type"]
def test_custom_path(self): def test_custom_path(self):
@@ -445,36 +442,33 @@ class TestInitMetrics:
class TestMultiProcessMode: class TestMultiProcessMode:
"""Tests for multi-process Prometheus mode.""" """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.""" """Multi-process mode works when PROMETHEUS_MULTIPROC_DIR is set."""
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
os.environ["PROMETHEUS_MULTIPROC_DIR"] = tmpdir monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", tmpdir)
try: # Use a separate registry to avoid conflicts with default
# Use a separate registry to avoid conflicts with default prom_registry = CollectorRegistry()
prom_registry = CollectorRegistry() app = FastAPI()
app = FastAPI() registry = MetricsRegistry()
registry = MetricsRegistry()
@registry.register @registry.register
def mp_counter(): def mp_counter():
return Counter( return Counter(
"mp_test_counter", "mp_test_counter",
"A multiprocess counter", "A multiprocess counter",
registry=prom_registry, registry=prom_registry,
) )
init_metrics(app, registry) init_metrics(app, registry)
client = TestClient(app) client = TestClient(app)
response = client.get("/metrics") response = client.get("/metrics")
assert response.status_code == 200 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.""" """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() app = FastAPI()
registry = MetricsRegistry() registry = MetricsRegistry()

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,10 @@ from fastapi import Depends, FastAPI
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy import select, text from sqlalchemy import select, text
from sqlalchemy.engine import make_url 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 sqlalchemy.orm import selectinload
from fastapi_toolsets.db import get_transaction
from fastapi_toolsets.fixtures import Context, FixtureRegistry from fastapi_toolsets.fixtures import Context, FixtureRegistry
from fastapi_toolsets.pytest import ( from fastapi_toolsets.pytest import (
create_async_client, create_async_client,
@@ -336,6 +337,55 @@ class TestCreateDbSession:
result = await session.execute(select(Role)) result = await session.execute(select(Role))
assert result.all() == [] 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: class TestGetXdistWorker:
"""Tests for _get_xdist_worker helper.""" """Tests for _get_xdist_worker helper."""

View File

@@ -6,9 +6,12 @@ from pydantic import ValidationError
from fastapi_toolsets.schemas import ( from fastapi_toolsets.schemas import (
ApiError, ApiError,
CursorPagination, CursorPagination,
CursorPaginatedResponse,
ErrorResponse, ErrorResponse,
OffsetPagination, OffsetPagination,
OffsetPaginatedResponse,
PaginatedResponse, PaginatedResponse,
PaginationType,
Response, Response,
ResponseStatus, ResponseStatus,
) )
@@ -198,6 +201,88 @@ class TestOffsetPagination:
assert data["page"] == 2 assert data["page"] == 2
assert data["has_more"] is True 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: class TestCursorPagination:
"""Tests for CursorPagination schema.""" """Tests for CursorPagination schema."""
@@ -301,7 +386,7 @@ class TestPaginatedResponse:
page=1, page=1,
has_more=False, has_more=False,
) )
response = PaginatedResponse[dict]( response = PaginatedResponse(
data=[], data=[],
pagination=pagination, pagination=pagination,
) )
@@ -310,13 +395,32 @@ class TestPaginatedResponse:
assert response.data == [] assert response.data == []
assert response.pagination.total_count == 0 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): def test_generic_type_hint(self):
"""PaginatedResponse supports generic type hints.""" """PaginatedResponse supports generic type hints."""
class UserOut:
id: int
name: str
pagination = OffsetPagination( pagination = OffsetPagination(
total_count=1, total_count=1,
items_per_page=10, items_per_page=10,
@@ -371,6 +475,191 @@ class TestPaginatedResponse:
assert isinstance(response.pagination, CursorPagination) 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: class TestFromAttributes:
"""Tests for from_attributes config (ORM mode).""" """Tests for from_attributes config (ORM mode)."""

323
uv.lock generated
View File

@@ -113,101 +113,101 @@ wheels = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.13.4" version = "7.13.5"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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] [package.optional-dependencies]
@@ -251,7 +251,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "2.2.1" version = "2.4.3"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "asyncpg" }, { name = "asyncpg" },
@@ -286,6 +286,7 @@ dev = [
{ name = "fastapi-toolsets", extra = ["all"] }, { name = "fastapi-toolsets", extra = ["all"] },
{ name = "httpx" }, { name = "httpx" },
{ name = "mkdocstrings-python" }, { name = "mkdocstrings-python" },
{ name = "prek" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-anyio" }, { name = "pytest-anyio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
@@ -328,6 +329,7 @@ dev = [
{ name = "fastapi-toolsets", extras = ["all"] }, { name = "fastapi-toolsets", extras = ["all"] },
{ name = "httpx", specifier = ">=0.25.0" }, { name = "httpx", specifier = ">=0.25.0" },
{ name = "mkdocstrings-python", specifier = ">=2.0.2" }, { name = "mkdocstrings-python", specifier = ">=2.0.2" },
{ name = "prek", specifier = ">=0.3.8" },
{ name = "pytest", specifier = ">=8.0.0" }, { name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-anyio", specifier = ">=0.0.0" }, { name = "pytest-anyio", specifier = ">=0.0.0" },
{ name = "pytest-cov", specifier = ">=4.0.0" }, { name = "pytest-cov", specifier = ">=4.0.0" },
@@ -417,6 +419,7 @@ wheels = [
name = "griffelib" name = "griffelib"
version = "2.0.0" version = "2.0.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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" }, { 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" }, { 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]] [[package]]
name = "prometheus-client" name = "prometheus-client"
version = "0.24.1" version = "0.24.1"
@@ -894,16 +921,16 @@ wheels = [
[[package]] [[package]]
name = "pytest-cov" name = "pytest-cov"
version = "7.0.0" version = "7.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "coverage", extra = ["toml"] }, { name = "coverage", extra = ["toml"] },
{ name = "pluggy" }, { name = "pluggy" },
{ name = "pytest" }, { 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 = [ 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]] [[package]]
@@ -1013,27 +1040,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.5" version = "0.15.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } 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 = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, { 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/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, { 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/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, { 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/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, { 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/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, { 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/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, { 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/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, { 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/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, { 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/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, { 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/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, { 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/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, { 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/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, { 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/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, { 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/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, { 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/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, { 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/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, { 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/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, { 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]] [[package]]
@@ -1177,26 +1204,26 @@ wheels = [
[[package]] [[package]]
name = "ty" name = "ty"
version = "0.0.21" version = "0.0.25"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/20/2ba8fd9493c89c41dfe9dbb73bc70a28b28028463bc0d2897ba8be36230a/ty-0.0.21.tar.gz", hash = "sha256:a4c2ba5d67d64df8fcdefd8b280ac1149d24a73dbda82fa953a0dff9d21400ed", size = 5297967, upload-time = "2026-03-06T01:57:13.809Z" } 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 = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/70/edf38bb37517531681d1c37f5df64744e5ad02673c02eb48447eae4bea08/ty-0.0.21-py3-none-linux_armv6l.whl", hash = "sha256:7bdf2f572378de78e1f388d24691c89db51b7caf07cf90f2bfcc1d6b18b70a76", size = 10299222, upload-time = "2026-03-06T01:57:16.64Z" }, { 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/72/62/0047b0bd19afeefbc7286f20a5f78a2aa39f92b4d89853f0d7185ab89edc/ty-0.0.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7e9613994610431ab8625025bd2880dbcb77c5c9fabdd21134cda12d840a529d", size = 10130513, upload-time = "2026-03-06T01:57:29.93Z" }, { 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/a2/20/0b93a9e91aaed23155780258cdfdb4726ef68b6985378ac069bc427291a0/ty-0.0.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56d3b198b64dd0a19b2b66e257deaed2ecea568e722ae5352f3c6fb62027f89d", size = 9605425, upload-time = "2026-03-06T01:57:27.115Z" }, { 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/ea/fd/9945e2fa2996a1287b1e1d7ce050e97e1f420233b271e770934bfa0880a0/ty-0.0.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d23d2c34f7a77d974bb08f0860ef700addc8a683d81a0319f71c08f87506cfd0", size = 10108298, upload-time = "2026-03-06T01:57:35.429Z" }, { 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/52/e7/4ec52fcb15f3200826c9f048472c062549a05b0d1ef0b51f32d527b513c4/ty-0.0.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56b01fd2519637a4ca88344f61c96225f540c98ff18bca321d4eaa7bb0f7aa2f", size = 10121556, upload-time = "2026-03-06T01:57:03.242Z" }, { 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/ee/c0/ad457be2a8abea0f25549598bd098554540ced66229488daa0d558dad3c8/ty-0.0.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9de7e11c63c6afc40f3e9ba716374add171aee7fabc70b5146a510705c6d41b", size = 10603264, upload-time = "2026-03-06T01:56:52.134Z" }, { 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/f8/5b/2ecc7a2175243a4bcb72f5298ae41feabbb93b764bb0dc45722f3752c2c2/ty-0.0.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62f7f5b235c4f7876db305c36997aea07b7af29b1a068f373d0e2547e25f32ff", size = 11196428, upload-time = "2026-03-06T01:57:32.94Z" }, { 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/37/f5/aff507d6a901f328ef96a298032b0c11aaaf950a146ed7dd3b5bf2cd3acf/ty-0.0.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee8399f7c453a425291e6688efe430cfae7ab0ac4ffd50eba9f872bf878b54f6", size = 10866355, upload-time = "2026-03-06T01:56:57.831Z" }, { 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/be/30/822bbcb92d55b65989aa7ed06d9585f28ade9c9447369194ed4b0fb3b5b9/ty-0.0.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210e7568c9f886c4d01308d751949ee714ad7ad9d7d928d2ba90d329dd880367", size = 10738177, upload-time = "2026-03-06T01:57:11.256Z" }, { 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/57/cc/46e7991b6469e93ac2c7e533a028983e402485580150ac864c56352a3a82/ty-0.0.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:53508e345b11569f78b21ba8e2b4e61df38a9754947fb3cd9f2ef574367338fb", size = 10079158, upload-time = "2026-03-06T01:57:00.516Z" }, { 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/15/c2/0bbdadfbd008240f8f1a87dc877433cb3884436097926107ccf06e618199/ty-0.0.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:553e43571f4a35604c36cfd07d8b61a5eb7a714e3c67f8c4ff2cf674fefbaef9", size = 10150535, upload-time = "2026-03-06T01:57:08.815Z" }, { 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/c5/b5/2dbdb7b57b5362200ef0a39738ebd31331726328336def0143ac097ee59d/ty-0.0.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:666f6822e3b9200abfa7e95eb0ddd576460adb8d66b550c0ad2c70abc84a2048", size = 10319803, upload-time = "2026-03-06T01:57:19.106Z" }, { 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/72/84/70e52c0b7abc7c2086f9876ef454a73b161d3125315536d8d7e911c94ca4/ty-0.0.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0854d008347ce4a5fb351af132f660a390ab2a1163444d075251d43e6f74b9b", size = 10826239, upload-time = "2026-03-06T01:57:21.727Z" }, { 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/a1/8a/1f72480fd013bbc6cd1929002abbbcde9a0b08ead6a15154de9d7f7fa37e/ty-0.0.21-py3-none-win32.whl", hash = "sha256:bef3ab4c7b966bcc276a8ac6c11b63ba222d21355b48d471ea782c4104eee4e0", size = 9693196, upload-time = "2026-03-06T01:57:24.126Z" }, { 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/8d/f8/1104808b875c26c640e536945753a78562d606bef4e241d9dbf3d92477f6/ty-0.0.21-py3-none-win_amd64.whl", hash = "sha256:a709d576e5bea84b745d43058d8b9cd4f27f74a0b24acb4b0cbb7d3d41e0d050", size = 10668660, upload-time = "2026-03-06T01:56:55.06Z" }, { 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/1b/b8/25e0adc404bbf986977657b25318991f93097b49f8aea640d93c0b0db68e/ty-0.0.21-py3-none-win_arm64.whl", hash = "sha256:f72047996598ac20553fb7e21ba5741e3c82dee4e9eadf10d954551a5fe09391", size = 10104161, upload-time = "2026-03-06T01:57:06.072Z" }, { 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]] [[package]]
@@ -1264,7 +1291,7 @@ wheels = [
[[package]] [[package]]
name = "zensical" name = "zensical"
version = "0.0.26" version = "0.0.29"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@@ -1274,18 +1301,18 @@ dependencies = [
{ name = "pymdown-extensions" }, { name = "pymdown-extensions" },
{ name = "pyyaml" }, { name = "pyyaml" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d5/1f/0a0b1ce8e0553a9dabaedc736d0f34b11fc33d71ff46bce44d674996d41f/zensical-0.0.26.tar.gz", hash = "sha256:f4d9c8403df25fbb3d6dd9577122dc2f23c73a2d16ab778bb7d40370dd71e987", size = 3841473, upload-time = "2026-03-11T09:51:38.838Z" } 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 = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/58/fa3d9538ff1ea8cf4a193edbf47254f374fa7983fcfa876bb4336d72c53a/zensical-0.0.26-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7823b25afe7d36099253aa59d643abaac940f80fd015d4a37954210c87d3da56", size = 12263607, upload-time = "2026-03-11T09:50:49.202Z" }, { 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/5f/6e/44a3b21bd3569b9cad203364d73a956768d28a879e4c2be91bd889f74d2c/zensical-0.0.26-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c0254814382cdd3769bc7689180d09bf41de8879871dd736dc52d5f141e8ada7", size = 12144562, upload-time = "2026-03-11T09:50:53.685Z" }, { 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/07/ae/31b9885745b3e7ef23a3ae7f175b879807288d11b3fb7e2d3c119c916258/zensical-0.0.26-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c8e601b2bbd239e564b04cf235eefb9777e7dfc7e1857b8871d6cdcfb577aa0", size = 12506728, upload-time = "2026-03-11T09:50:57.775Z" }, { 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/bd/93/f5291e2c47076474f181f6eef35ef0428117d3f192da4358c0511e2ce09e/zensical-0.0.26-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2dc43c7e6c25d9724fc0450f0273ca4e5e2506eeb7f89f52f1405a592896ca3b", size = 12454975, upload-time = "2026-03-11T09:51:01.514Z" }, { 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/aa/2e/61cac4f2ebad31dab768eb02753ffde9e56d4d34b8f876b949bf516fbd50/zensical-0.0.26-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24ed236d1254cc474c19227eaa3670a1ccf921af53134ec5542b05853bdcd59c", size = 12791930, upload-time = "2026-03-11T09:51:05.162Z" }, { 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/02/86/51995d1ed2dd6ad8a1a70bcdf3c5eb16b50e62ea70e638d454a6b9061c4d/zensical-0.0.26-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1110147710d1dd025d932c4a7eada836bdf079c91b70fb0ae5b202e14b094617", size = 12548166, upload-time = "2026-03-11T09:51:09.218Z" }, { 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/3d/93/decbafdbfc77170cbc3851464632390846e9aaf45e743c8dd5a24d5673e9/zensical-0.0.26-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7d21596a785428cdebc20859bd94a05334abe14ad24f1bb9cd80d19219e3c220", size = 12682103, upload-time = "2026-03-11T09:51:12.68Z" }, { 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/fb/e2/391d2d08dde621177da069a796a886b549fefb15734aeeb6e696af99b662/zensical-0.0.26-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:680a3c7bb71499b4da784d6072e44b3d7b8c0df3ce9bbd9974e24bd8058c2736", size = 12724219, upload-time = "2026-03-11T09:51:17.32Z" }, { 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/80/2a/21b40c5c40a67da8a841f278d61dbd8d5e035e489de6fe1cef5f4e211b4f/zensical-0.0.26-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:e3294a79f98218b6fc2219232e166aa0932ae4dad58f6c8dbc0dbe0ecbff9c25", size = 12862117, upload-time = "2026-03-11T09:51:22.161Z" }, { 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/51/76/e1910d6d75d207654c867b8efbda6822dedda9fed3601bf4a864a1f4fe26/zensical-0.0.26-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:630229587df1fb47be184a4a69d0772ce59a44cd2c481ae9f7e8852fffaff11e", size = 12815714, upload-time = "2026-03-11T09:51:26.24Z" }, { 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/f4/eb/34b042542cd949192535f8bac172d33b3da5a0ec0853eed008a6ad3242e3/zensical-0.0.26-cp310-abi3-win32.whl", hash = "sha256:e0756581541aad2e63dd8b4abae47e6ff12229a474b4eede5b4da5cc183c5101", size = 11856425, upload-time = "2026-03-11T09:51:31.087Z" }, { 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/92/a5/30f6a88bb125c2bbeae3ae80a0812131614ab30e9b0b199d75d4199e5b66/zensical-0.0.26-cp310-abi3-win_amd64.whl", hash = "sha256:9ca07f5c75b5eac4d273d887100bbccd6eb8ba4959c904e2ab61971a0017c172", size = 12059895, upload-time = "2026-03-11T09:51:35.226Z" }, { 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" },
] ]