9 Commits

Author SHA1 Message Date
d3vyce
bbe63edc46 Version 3.0.0 (#201)
* chore: remove deprecated code

* docs: update v3 migration guide

* fix: pytest warnings

* Version 3.0.0

* fix: docs workflows
2026-04-02 11:21:31 +02:00
d3vyce
0b17c77dee fix: deduplicate relationship joins when searchable_fields and facet_fields reference the same model (#217) 2026-04-02 11:09:26 +02:00
dependabot[bot]
bce71bfd42 ⬆ Bump ty from 0.0.25 to 0.0.27 (#215)
Bumps [ty](https://github.com/astral-sh/ty) from 0.0.25 to 0.0.27.
- [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.25...0.0.27)

---
updated-dependencies:
- dependency-name: ty
  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-04-02 11:01:08 +02:00
dependabot[bot]
2f1eb4d468 ⬆ Bump fastapi from 0.135.1 to 0.135.3 (#214)
Bumps [fastapi](https://github.com/fastapi/fastapi) from 0.135.1 to 0.135.3.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.135.1...0.135.3)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.135.3
  dependency-type: direct:production
  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-04-02 11:00:59 +02:00
dependabot[bot]
1f06eab11d ⬆ Bump ruff from 0.15.7 to 0.15.8 (#213)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.7 to 0.15.8.
- [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.7...0.15.8)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.8
  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-04-02 11:00:44 +02:00
dependabot[bot]
fac9aa6f60 ⬆ Bump zensical from 0.0.30 to 0.0.31 (#212)
Bumps [zensical](https://github.com/zensical/zensical) from 0.0.30 to 0.0.31.
- [Release notes](https://github.com/zensical/zensical/releases)
- [Commits](https://github.com/zensical/zensical/compare/v0.0.30...v0.0.31)

---
updated-dependencies:
- dependency-name: zensical
  dependency-version: 0.0.31
  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-04-02 11:00:35 +02:00
dependabot[bot]
f310466697 ⬆ Bump codecov/codecov-action from 5 to 6 (#211)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 11:00:22 +02:00
d3vyce
32059dcb02 feat: consolidate *_params dependencies into per-paginate-style methods with feature toggles (#209) 2026-04-01 20:53:14 +02:00
d3vyce
f027981e80 feat: add search_column parameter and search_columns response field for targeted search (#207) 2026-04-01 18:10:56 +02:00
22 changed files with 1161 additions and 581 deletions

View File

@@ -93,7 +93,7 @@ jobs:
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
if: matrix.python-version == '3.14' if: matrix.python-version == '3.14'
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v6
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
report_type: coverage report_type: coverage
@@ -102,7 +102,7 @@ jobs:
- name: Upload test results to Codecov - name: Upload test results to Codecov
if: matrix.python-version == '3.14' if: matrix.python-version == '3.14'
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v6
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results report_type: test_results

View File

@@ -42,12 +42,12 @@ jobs:
LATEST_PREV_TAG=$(git tag -l "v${PREV_MAJOR}.*" | sort -V | tail -1) LATEST_PREV_TAG=$(git tag -l "v${PREV_MAJOR}.*" | sort -V | tail -1)
if [ -n "$LATEST_PREV_TAG" ]; then if [ -n "$LATEST_PREV_TAG" ]; then
git checkout "$LATEST_PREV_TAG" -- docs/ src/ zensical.toml git checkout "$LATEST_PREV_TAG" -- docs/ docs_src/ src/ zensical.toml
if ! grep -q '\[project\.extra\.version\]' zensical.toml; then if ! grep -q '\[project\.extra\.version\]' zensical.toml; then
printf '\n[project.extra.version]\nprovider = "mike"\ndefault = "stable"\nalias = true\n' >> zensical.toml printf '\n[project.extra.version]\nprovider = "mike"\ndefault = "stable"\nalias = true\n' >> zensical.toml
fi fi
uv run mike deploy "v${PREV_MAJOR}" uv run mike deploy "v${PREV_MAJOR}"
git checkout HEAD -- docs/ src/ zensical.toml git checkout HEAD -- docs/ docs_src/ src/ zensical.toml
fi fi
# Delete old feature versions # Delete old feature versions

View File

@@ -43,16 +43,16 @@ Declare `searchable_fields`, `facet_fields`, and `order_fields` once on [`CrudFa
## Routes ## Routes
```python title="routes.py:1:17" ```python title="routes.py:1:16"
--8<-- "docs_src/examples/pagination_search/routes.py:1:17" --8<-- "docs_src/examples/pagination_search/routes.py:1:16"
``` ```
### 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:20:40" ```python title="routes.py:19:37"
--8<-- "docs_src/examples/pagination_search/routes.py:20:40" --8<-- "docs_src/examples/pagination_search/routes.py:19:37"
``` ```
**Example request** **Example request**
@@ -92,8 +92,8 @@ To skip the `COUNT(*)` query for better performance on large tables, pass `inclu
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:43:63" ```python title="routes.py:40:58"
--8<-- "docs_src/examples/pagination_search/routes.py:43:63" --8<-- "docs_src/examples/pagination_search/routes.py:40:58"
``` ```
**Example request** **Example request**
@@ -132,8 +132,8 @@ Pass `next_cursor` as the `cursor` query parameter on the next request to advanc
[`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. [`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" ```python title="routes.py:61:79"
--8<-- "docs_src/examples/pagination_search/routes.py:66:90" --8<-- "docs_src/examples/pagination_search/routes.py:61:79"
``` ```
**Offset request** (default) **Offset request** (default)

View File

@@ -4,6 +4,93 @@ This page covers every breaking change introduced in **v3.0** and the steps requ
--- ---
## CRUD
### Facet keys now always use the full relationship chain
In `v2`, relationship facet fields used only the terminal column key (e.g. `"name"` for `Role.name`) and only prepended the relationship name when two facet fields shared the same column key. In `v3`, facet keys **always** include the full relationship chain joined by `__`, regardless of collisions.
=== "Before (`v2`)"
```
User.status -> status
(User.role, Role.name) -> name
(User.role, Role.permission, Permission.name) -> name
```
=== "Now (`v3`)"
```
User.status -> status
(User.role, Role.name) -> role__name
(User.role, Role.permission, Permission.name) -> role__permission__name
```
---
### `*_params` dependencies consolidated into per-paginate methods
The six individual dependency methods (`offset_params`, `cursor_params`, `paginate_params`, `filter_params`, `search_params`, `order_params`) have been **removed** and replaced by three consolidated methods that bundle pagination, search, filter, and order into a single `Depends()` call.
| Removed | Replacement |
|---|---|
| `offset_params()` + `filter_params()` + `search_params()` + `order_params()` | `offset_paginate_params()` |
| `cursor_params()` + `filter_params()` + `search_params()` + `order_params()` | `cursor_paginate_params()` |
| `paginate_params()` + `filter_params()` + `search_params()` + `order_params()` | `paginate_params()` |
Each new method accepts `search`, `filter`, and `order` boolean toggles (all `True` by default) to disable features you don't need.
=== "Before (`v2`)"
```python
from fastapi_toolsets.crud import OrderByClause
@router.get("/offset")
async def list_articles_offset(
session: SessionDep,
params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))],
filter_by: Annotated[dict, Depends(ArticleCrud.filter_params())],
order_by: Annotated[OrderByClause | None, Depends(ArticleCrud.order_params(default_field=Article.created_at))],
search: str | None = None,
) -> OffsetPaginatedResponse[ArticleRead]:
return await ArticleCrud.offset_paginate(
session=session,
**params,
search=search,
filter_by=filter_by or None,
order_by=order_by,
schema=ArticleRead,
)
```
=== "Now (`v3`)"
```python
@router.get("/offset")
async def list_articles_offset(
session: SessionDep,
params: Annotated[
dict,
Depends(
ArticleCrud.offset_paginate_params(
default_page_size=20,
default_order_field=Article.created_at,
)
),
],
) -> OffsetPaginatedResponse[ArticleRead]:
return await ArticleCrud.offset_paginate(session=session, **params, schema=ArticleRead)
```
The same pattern applies to `cursor_paginate_params()` and `paginate_params()`. To disable a feature, pass the toggle:
```python
# No search or ordering, only pagination + filtering
ArticleCrud.offset_paginate_params(search=False, order=False)
```
---
## Models ## Models
The lifecycle event system has been rewritten. Callbacks are now registered with a module-level [`listens_for`](../reference/models.md#fastapi_toolsets.models.listens_for) decorator and dispatched by [`EventSession`](../reference/models.md#fastapi_toolsets.models.EventSession), replacing the mixin-based approach from `v2`. The lifecycle event system has been rewritten. Callbacks are now registered with a module-level [`listens_for`](../reference/models.md#fastapi_toolsets.models.listens_for) decorator and dispatched by [`EventSession`](../reference/models.md#fastapi_toolsets.models.EventSession), replacing the mixin-based approach from `v2`.

View File

@@ -159,18 +159,15 @@ Three pagination methods are available. All return a typed response whose `pagi
### Offset pagination ### Offset pagination
```python ```python
from typing import Annotated
from fastapi import Depends
@router.get("") @router.get("")
async def get_users( async def get_users(
session: SessionDep, session: SessionDep,
items_per_page: int = 50, params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
page: int = 1,
) -> OffsetPaginatedResponse[UserRead]: ) -> OffsetPaginatedResponse[UserRead]:
return await UserCrud.offset_paginate( return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
session=session,
items_per_page=items_per_page,
page=page,
schema=UserRead,
)
``` ```
The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) method returns an [`OffsetPaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPaginatedResponse): The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) method returns an [`OffsetPaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPaginatedResponse):
@@ -194,32 +191,13 @@ The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.Async
!!! info "Added in `v2.4.1`" !!! 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: 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 `offset_paginate_params()` to skip it:
```python ```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("") @router.get("")
async def list_users( async def get_users(
session: SessionDep, session: SessionDep,
params: Annotated[dict, Depends(UserCrud.offset_params(default_page_size=20, max_page_size=100))], params: Annotated[dict, Depends(UserCrud.offset_paginate_params(include_total=False))],
) -> OffsetPaginatedResponse[UserRead]: ) -> OffsetPaginatedResponse[UserRead]:
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead) return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
``` ```
@@ -230,15 +208,9 @@ async def list_users(
@router.get("") @router.get("")
async def list_users( async def list_users(
session: SessionDep, session: SessionDep,
cursor: str | None = None, params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
items_per_page: int = 20,
) -> CursorPaginatedResponse[UserRead]: ) -> CursorPaginatedResponse[UserRead]:
return await UserCrud.cursor_paginate( return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
session=session,
cursor=cursor,
items_per_page=items_per_page,
schema=UserRead,
)
``` ```
The [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) method returns a [`CursorPaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPaginatedResponse): The [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) method returns a [`CursorPaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPaginatedResponse):
@@ -291,24 +263,6 @@ 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) ### Unified endpoint (both strategies)
!!! info "Added in `v2.3.0`" !!! info "Added in `v2.3.0`"
@@ -316,25 +270,14 @@ async def list_users(
[`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. [`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 ```python
from fastapi_toolsets.crud import PaginationType
from fastapi_toolsets.schemas import PaginatedResponse from fastapi_toolsets.schemas import PaginatedResponse
@router.get("") @router.get("")
async def list_users( async def list_users(
session: SessionDep, session: SessionDep,
pagination_type: PaginationType = PaginationType.OFFSET, params: Annotated[dict, Depends(UserCrud.paginate_params())],
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]: ) -> PaginatedResponse[UserRead]:
return await UserCrud.paginate( return await UserCrud.paginate(session, **params, schema=UserRead)
session,
pagination_type=pagination_type,
page=page,
cursor=cursor,
items_per_page=items_per_page,
schema=UserRead,
)
``` ```
``` ```
@@ -342,25 +285,6 @@ GET /users?pagination_type=offset&page=2&items_per_page=10
GET /users?pagination_type=cursor&cursor=eyJ2YWx1ZSI6...&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).
@@ -406,34 +330,18 @@ This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi
@router.get("") @router.get("")
async def get_users( async def get_users(
session: SessionDep, session: SessionDep,
items_per_page: int = 50, params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
page: int = 1,
search: str | None = None,
) -> OffsetPaginatedResponse[UserRead]: ) -> OffsetPaginatedResponse[UserRead]:
return await UserCrud.offset_paginate( return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
session=session,
items_per_page=items_per_page,
page=page,
search=search,
schema=UserRead,
)
``` ```
```python ```python
@router.get("") @router.get("")
async def get_users( async def get_users(
session: SessionDep, session: SessionDep,
cursor: str | None = None, params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
items_per_page: int = 50,
search: str | None = None,
) -> CursorPaginatedResponse[UserRead]: ) -> CursorPaginatedResponse[UserRead]:
return await UserCrud.cursor_paginate( return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
session=session,
items_per_page=items_per_page,
cursor=cursor,
search=search,
schema=UserRead,
)
``` ```
### Faceted search ### Faceted search
@@ -486,7 +394,7 @@ Use `filter_by` to pass the client's chosen filter values directly — no need t
`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.
Use [`filter_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.filter_params) to generate a dict with the facet filter values from the query parameters: Facet filtering is built into the consolidated params dependencies. When `filter=True` (the default), facet fields are exposed as query parameters and collected into `filter_by` automatically:
```python ```python
from typing import Annotated from typing import Annotated
@@ -501,13 +409,11 @@ UserCrud = CrudFactory(
@router.get("", response_model_exclude_none=True) @router.get("", response_model_exclude_none=True)
async def list_users( async def list_users(
session: SessionDep, session: SessionDep,
page: int = 1, params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
filter_by: Annotated[dict[str, list[str]], Depends(UserCrud.filter_params())],
) -> OffsetPaginatedResponse[UserRead]: ) -> OffsetPaginatedResponse[UserRead]:
return await UserCrud.offset_paginate( return await UserCrud.offset_paginate(
session=session, session=session,
page=page, **params,
filter_by=filter_by,
schema=UserRead, schema=UserRead,
) )
``` ```
@@ -536,20 +442,21 @@ UserCrud = CrudFactory(
) )
``` ```
Call [`order_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.order_params) to generate a FastAPI dependency that maps the query parameters to an [`OrderByClause`](../reference/crud.md#fastapi_toolsets.crud.factory.OrderByClause) expression: Ordering is built into the consolidated params dependencies. When `order=True` (the default), `order_by` and `order` query parameters are exposed and resolved into an `OrderByClause` automatically:
```python ```python
from typing import Annotated from typing import Annotated
from fastapi import Depends from fastapi import Depends
from fastapi_toolsets.crud import OrderByClause
@router.get("") @router.get("")
async def list_users( async def list_users(
session: SessionDep, session: SessionDep,
order_by: Annotated[OrderByClause | None, Depends(UserCrud.order_params())], params: Annotated[dict, Depends(UserCrud.offset_paginate_params(
default_order_field=User.created_at,
))],
) -> OffsetPaginatedResponse[UserRead]: ) -> OffsetPaginatedResponse[UserRead]:
return await UserCrud.offset_paginate(session=session, order_by=order_by, schema=UserRead) return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
``` ```
The dependency adds two query parameters to the endpoint: The dependency adds two query parameters to the endpoint:
@@ -566,10 +473,10 @@ GET /users?order_by=name&order=desc → ORDER BY users.name DESC
An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422). An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
You can also pass `order_fields` directly to `order_params()` to override the class-level defaults without modifying them: You can also pass `order_fields` directly to override the class-level defaults:
```python ```python
UserOrderParams = UserCrud.order_params(order_fields=[User.name]) params = UserCrud.offset_paginate_params(order_fields=[User.name])
``` ```
## Relationship loading ## Relationship loading
@@ -656,12 +563,11 @@ async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
) )
@router.get("") @router.get("")
async def list_users(session: SessionDep, page: int = 1) -> OffsetPaginatedResponse[UserRead]: async def list_users(
return await crud.UserCrud.offset_paginate( session: SessionDep,
session=session, params: Annotated[dict, Depends(crud.UserCrud.offset_paginate_params())],
page=page, ) -> OffsetPaginatedResponse[UserRead]:
schema=UserRead, return await crud.UserCrud.offset_paginate(session=session, **params, schema=UserRead)
)
``` ```
The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances. The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.

View File

@@ -79,9 +79,6 @@ The examples above are already compatible with parallel test execution with `pyt
## Cleaning up tables ## Cleaning up tables
!!! warning
Since `V2.1.0` `cleanup_tables` now live in `fastapi_toolsets.db`. For backward compatibility the function is still available in `fastapi_toolsets.pytest`, but this will be remove in `V3.0.0`.
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables), this will truncate all tables between tests for fast isolation: If you want to manually clean up a database you can use [`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables), this will truncate all tables between tests for fast isolation:
```python ```python

View File

@@ -2,7 +2,6 @@ from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi_toolsets.crud import OrderByClause
from fastapi_toolsets.schemas import ( from fastapi_toolsets.schemas import (
CursorPaginatedResponse, CursorPaginatedResponse,
OffsetPaginatedResponse, OffsetPaginatedResponse,
@@ -22,21 +21,18 @@ async def list_articles_offset(
session: SessionDep, session: SessionDep,
params: Annotated[ params: Annotated[
dict, dict,
Depends(ArticleCrud.offset_params(default_page_size=20, max_page_size=100)), Depends(
ArticleCrud.offset_paginate_params(
default_page_size=20,
max_page_size=100,
default_order_field=Article.created_at,
)
),
], ],
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,
) -> OffsetPaginatedResponse[ArticleRead]: ) -> OffsetPaginatedResponse[ArticleRead]:
return await ArticleCrud.offset_paginate( return await ArticleCrud.offset_paginate(
session=session, session=session,
**params, **params,
search=search,
filter_by=filter_by or None,
order_by=order_by,
schema=ArticleRead, schema=ArticleRead,
) )
@@ -46,21 +42,18 @@ async def list_articles_cursor(
session: SessionDep, session: SessionDep,
params: Annotated[ params: Annotated[
dict, dict,
Depends(ArticleCrud.cursor_params(default_page_size=20, max_page_size=100)), Depends(
ArticleCrud.cursor_paginate_params(
default_page_size=20,
max_page_size=100,
default_order_field=Article.created_at,
)
),
], ],
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,
) -> CursorPaginatedResponse[ArticleRead]: ) -> CursorPaginatedResponse[ArticleRead]:
return await ArticleCrud.cursor_paginate( return await ArticleCrud.cursor_paginate(
session=session, session=session,
**params, **params,
search=search,
filter_by=filter_by or None,
order_by=order_by,
schema=ArticleRead, schema=ArticleRead,
) )
@@ -70,20 +63,17 @@ async def list_articles(
session: SessionDep, session: SessionDep,
params: Annotated[ params: Annotated[
dict, dict,
Depends(ArticleCrud.paginate_params(default_page_size=20, max_page_size=100)), Depends(
ArticleCrud.paginate_params(
default_page_size=20,
max_page_size=100,
default_order_field=Article.created_at,
)
),
], ],
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]: ) -> PaginatedResponse[ArticleRead]:
return await ArticleCrud.paginate( return await ArticleCrud.paginate(
session, session,
**params, **params,
search=search,
filter_by=filter_by or None,
order_by=order_by,
schema=ArticleRead, schema=ArticleRead,
) )

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "2.4.3" version = "3.0.0"
description = "Production-ready utilities for FastAPI applications" description = "Production-ready utilities for FastAPI applications"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"

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.4.3" __version__ = "3.0.0"

View File

@@ -2,6 +2,7 @@
from ..exceptions import ( from ..exceptions import (
InvalidFacetFilterError, InvalidFacetFilterError,
InvalidSearchColumnError,
NoSearchableFieldsError, NoSearchableFieldsError,
UnsupportedFacetTypeError, UnsupportedFacetTypeError,
) )
@@ -22,6 +23,7 @@ __all__ = [
"FacetFieldType", "FacetFieldType",
"get_searchable_fields", "get_searchable_fields",
"InvalidFacetFilterError", "InvalidFacetFilterError",
"InvalidSearchColumnError",
"JoinType", "JoinType",
"M2MFieldType", "M2MFieldType",
"NoSearchableFieldsError", "NoSearchableFieldsError",

View File

@@ -47,6 +47,7 @@ from .search import (
build_filter_by, build_filter_by,
build_search_filters, build_search_filters,
facet_keys, facet_keys,
search_field_keys,
) )
@@ -115,8 +116,12 @@ def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
def _apply_search_joins(q: Any, search_joins: list[Any]) -> Any: def _apply_search_joins(q: Any, search_joins: list[Any]) -> Any:
"""Apply relationship-based outer joins (from search/filter_by) to a query.""" """Apply relationship-based outer joins (from search/filter_by) to a query."""
seen: set[str] = set()
for join_rel in search_joins: for join_rel in search_joins:
q = q.outerjoin(join_rel) key = str(join_rel)
if key not in seen:
seen.add(key)
q = q.outerjoin(join_rel)
return q return q
@@ -263,118 +268,285 @@ class AsyncCrud(Generic[ModelType]):
) )
@classmethod @classmethod
def filter_params( def _resolve_search_columns(
cls: type[Self],
search_fields: Sequence[SearchFieldType] | None,
) -> list[str] | None:
"""Return search column keys, or None if no searchable fields configured."""
fields = search_fields if search_fields is not None else cls.searchable_fields
if not fields:
return None
return search_field_keys(fields)
@classmethod
def _build_paginate_params(
cls: type[Self], cls: type[Self],
*, *,
facet_fields: Sequence[FacetFieldType] | None = None, pagination_params: list[inspect.Parameter],
) -> Callable[..., Awaitable[dict[str, list[str]]]]: pagination_fixed: dict[str, Any],
"""Return a FastAPI dependency that collects facet filter values from query parameters. dep_name: str,
search: bool,
filter: bool,
order: bool,
search_fields: Sequence[SearchFieldType] | None,
facet_fields: Sequence[FacetFieldType] | None,
order_fields: Sequence[QueryableAttribute[Any]] | None,
default_order_field: QueryableAttribute[Any] | None,
default_order: Literal["asc", "desc"],
) -> Callable[..., Awaitable[dict[str, Any]]]:
"""Build a consolidated FastAPI dependency that merges pagination, search, filter, and order params."""
all_params: list[inspect.Parameter] = list(pagination_params)
pagination_param_names = tuple(p.name for p in pagination_params)
reserved_names: set[str] = set(pagination_param_names)
Args: search_keys: list[str] | None = None
facet_fields: Override the facet fields for this dependency. Falls back to the if search:
class-level ``facet_fields`` if not provided. search_keys = cls._resolve_search_columns(search_fields)
if search_keys:
Returns: all_params.extend(
An async dependency function named ``{Model}FilterParams`` that resolves to a [
``dict[str, list[str]]`` containing only the keys that were supplied in the inspect.Parameter(
request (absent/``None`` parameters are excluded). "search",
inspect.Parameter.KEYWORD_ONLY,
Raises: annotation=str | None,
ValueError: If no facet fields are configured on this CRUD class and none are default=Query(
provided via ``facet_fields``. default=None, description="Search query string"
""" ),
fields = cls._resolve_facet_fields(facet_fields) ),
if not fields: inspect.Parameter(
raise ValueError( "search_column",
f"{cls.__name__} has no facet_fields configured. " inspect.Parameter.KEYWORD_ONLY,
"Pass facet_fields= or set them on CrudFactory." annotation=str | None,
) default=Query(
keys = facet_keys(fields) default=None,
description="Restrict search to a single column",
async def dependency(**kwargs: Any) -> dict[str, list[str]]: enum=search_keys,
return {k: v for k, v in kwargs.items() if v is not None} ),
),
dependency.__name__ = f"{cls.model.__name__}FilterParams" ]
dependency.__signature__ = inspect.Signature( # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
parameters=[
inspect.Parameter(
k,
inspect.Parameter.KEYWORD_ONLY,
annotation=list[str] | None,
default=Query(default=None),
) )
for k in keys reserved_names.update({"search", "search_column"})
]
)
filter_keys: list[str] | None = None
if filter:
resolved_facets = cls._resolve_facet_fields(facet_fields)
if resolved_facets:
filter_keys = facet_keys(resolved_facets)
for k in filter_keys:
if k in reserved_names:
raise ValueError(
f"Facet field key {k!r} conflicts with a reserved "
f"parameter name. Reserved names: {sorted(reserved_names)}"
)
all_params.extend(
inspect.Parameter(
k,
inspect.Parameter.KEYWORD_ONLY,
annotation=list[str] | None,
default=Query(default=None),
)
for k in filter_keys
)
reserved_names.update(filter_keys)
order_field_map: dict[str, QueryableAttribute[Any]] | None = None
order_valid_keys: list[str] | None = None
if order:
resolved_order = (
order_fields if order_fields is not None else cls.order_fields
)
if resolved_order:
order_field_map = {f.key: f for f in resolved_order}
order_valid_keys = sorted(order_field_map.keys())
all_params.extend(
[
inspect.Parameter(
"order_by",
inspect.Parameter.KEYWORD_ONLY,
annotation=str | None,
default=Query(
None,
description=f"Field to order by. Valid values: {order_valid_keys}",
enum=order_valid_keys,
),
),
inspect.Parameter(
"order",
inspect.Parameter.KEYWORD_ONLY,
annotation=Literal["asc", "desc"],
default=Query(default_order, description="Sort direction"),
),
]
)
async def dependency(**kwargs: Any) -> dict[str, Any]:
result: dict[str, Any] = dict(pagination_fixed)
for name in pagination_param_names:
result[name] = kwargs[name]
if search_keys is not None:
search_val = kwargs.get("search")
if search_val is not None:
result["search"] = search_val
search_col_val = kwargs.get("search_column")
if search_col_val is not None:
result["search_column"] = search_col_val
if filter_keys is not None:
filter_by = {
k: kwargs[k] for k in filter_keys if kwargs.get(k) is not None
}
result["filter_by"] = filter_by or None
if order_field_map is not None:
order_by_val = kwargs.get("order_by")
order_dir = kwargs.get("order", default_order)
if order_by_val is None:
field = default_order_field
elif order_by_val not in order_field_map:
raise InvalidOrderFieldError(order_by_val, order_valid_keys or [])
else:
field = order_field_map[order_by_val]
if field is not None:
result["order_by"] = (
field.asc() if order_dir == "asc" else field.desc()
)
else:
result["order_by"] = None
return result
dependency.__name__ = dep_name
dependency.__signature__ = inspect.Signature( # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
parameters=all_params,
)
return dependency return dependency
@classmethod @classmethod
def offset_params( def offset_paginate_params(
cls: type[Self], cls: type[Self],
*, *,
default_page_size: int = 20, default_page_size: int = 20,
max_page_size: int = 100, max_page_size: int = 100,
include_total: bool = True, include_total: bool = True,
search: bool = True,
filter: bool = True,
order: bool = True,
search_fields: Sequence[SearchFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None,
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
default_order_field: QueryableAttribute[Any] | None = None,
default_order: Literal["asc", "desc"] = "asc",
) -> Callable[..., Awaitable[dict[str, Any]]]: ) -> Callable[..., Awaitable[dict[str, Any]]]:
"""Return a FastAPI dependency that collects offset pagination params from query params. """Return a FastAPI dependency that collects all params for :meth:`offset_paginate`.
Args: Args:
default_page_size: Default value for the ``items_per_page`` query parameter. default_page_size: Default ``items_per_page`` value.
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via max_page_size: Maximum ``items_per_page`` value.
``le`` on the ``Query``). include_total: Whether to include total count (not a query param).
include_total: Server-side flag forwarded as-is to ``include_total`` in search: Enable search query parameters.
:meth:`offset_paginate`. Not exposed as a query parameter. filter: Enable facet filter query parameters.
order: Enable order query parameters.
search_fields: Override searchable fields.
facet_fields: Override facet fields.
order_fields: Override order fields.
default_order_field: Default field to order by when ``order_by`` is absent.
default_order: Default sort direction.
Returns: Returns:
An async dependency that resolves to a dict with ``page``, An async dependency that resolves to a dict ready to be unpacked
``items_per_page``, and ``include_total`` keys, ready to be into :meth:`offset_paginate`.
unpacked into :meth:`offset_paginate`.
""" """
pagination_params = [
async def dependency( inspect.Parameter(
page: int = Query(1, ge=1, description="Page number (1-indexed)"), "page",
items_per_page: int = _page_size_query(default_page_size, max_page_size), inspect.Parameter.KEYWORD_ONLY,
) -> dict[str, Any]: annotation=int,
return { default=Query(1, ge=1, description="Page number (1-indexed)"),
"page": page, ),
"items_per_page": items_per_page, inspect.Parameter(
"include_total": include_total, "items_per_page",
} inspect.Parameter.KEYWORD_ONLY,
annotation=int,
dependency.__name__ = f"{cls.model.__name__}OffsetParams" default=_page_size_query(default_page_size, max_page_size),
return dependency ),
]
return cls._build_paginate_params(
pagination_params=pagination_params,
pagination_fixed={"include_total": include_total},
dep_name=f"{cls.model.__name__}OffsetPaginateParams",
search=search,
filter=filter,
order=order,
search_fields=search_fields,
facet_fields=facet_fields,
order_fields=order_fields,
default_order_field=default_order_field,
default_order=default_order,
)
@classmethod @classmethod
def cursor_params( def cursor_paginate_params(
cls: type[Self], cls: type[Self],
*, *,
default_page_size: int = 20, default_page_size: int = 20,
max_page_size: int = 100, max_page_size: int = 100,
search: bool = True,
filter: bool = True,
order: bool = True,
search_fields: Sequence[SearchFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None,
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
default_order_field: QueryableAttribute[Any] | None = None,
default_order: Literal["asc", "desc"] = "asc",
) -> Callable[..., Awaitable[dict[str, Any]]]: ) -> Callable[..., Awaitable[dict[str, Any]]]:
"""Return a FastAPI dependency that collects cursor pagination params from query params. """Return a FastAPI dependency that collects all params for :meth:`cursor_paginate`.
Args: Args:
default_page_size: Default value for the ``items_per_page`` query parameter. default_page_size: Default ``items_per_page`` value.
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via max_page_size: Maximum ``items_per_page`` value.
``le`` on the ``Query``). search: Enable search query parameters.
filter: Enable facet filter query parameters.
order: Enable order query parameters.
search_fields: Override searchable fields.
facet_fields: Override facet fields.
order_fields: Override order fields.
default_order_field: Default field to order by when ``order_by`` is absent.
default_order: Default sort direction.
Returns: Returns:
An async dependency that resolves to a dict with ``cursor`` and An async dependency that resolves to a dict ready to be unpacked
``items_per_page`` keys, ready to be unpacked into into :meth:`cursor_paginate`.
:meth:`cursor_paginate`.
""" """
pagination_params = [
async def dependency( inspect.Parameter(
cursor: str | None = Query( "cursor",
None, description="Cursor token from a previous response" inspect.Parameter.KEYWORD_ONLY,
annotation=str | None,
default=Query(
None, description="Cursor token from a previous response"
),
), ),
items_per_page: int = _page_size_query(default_page_size, max_page_size), inspect.Parameter(
) -> dict[str, Any]: "items_per_page",
return {"cursor": cursor, "items_per_page": items_per_page} inspect.Parameter.KEYWORD_ONLY,
annotation=int,
dependency.__name__ = f"{cls.model.__name__}CursorParams" default=_page_size_query(default_page_size, max_page_size),
return dependency ),
]
return cls._build_paginate_params(
pagination_params=pagination_params,
pagination_fixed={},
dep_name=f"{cls.model.__name__}CursorPaginateParams",
search=search,
filter=filter,
order=order,
search_fields=search_fields,
facet_fields=facet_fields,
order_fields=order_fields,
default_order_field=default_order_field,
default_order=default_order,
)
@classmethod @classmethod
def paginate_params( def paginate_params(
@@ -384,102 +556,81 @@ class AsyncCrud(Generic[ModelType]):
max_page_size: int = 100, max_page_size: int = 100,
default_pagination_type: PaginationType = PaginationType.OFFSET, default_pagination_type: PaginationType = PaginationType.OFFSET,
include_total: bool = True, include_total: bool = True,
) -> Callable[..., Awaitable[dict[str, Any]]]: search: bool = True,
"""Return a FastAPI dependency that collects all pagination params from query params. filter: bool = True,
order: bool = True,
Args: search_fields: Sequence[SearchFieldType] | None = None,
default_page_size: Default value for the ``items_per_page`` query parameter. facet_fields: Sequence[FacetFieldType] | None = None,
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
``le`` on the ``Query``).
default_pagination_type: Default pagination strategy.
include_total: Server-side flag forwarded as-is to ``include_total`` in
:meth:`paginate`. Not exposed as a query parameter.
Returns:
An async dependency that resolves to a dict with ``pagination_type``,
``page``, ``cursor``, ``items_per_page``, and ``include_total`` keys,
ready to be unpacked into :meth:`paginate`.
"""
async def dependency(
pagination_type: PaginationType = Query(
default_pagination_type, description="Pagination strategy"
),
page: int = Query(
1, ge=1, description="Page number (1-indexed, offset only)"
),
cursor: str | None = Query(
None, description="Cursor token from a previous response (cursor only)"
),
items_per_page: int = _page_size_query(default_page_size, max_page_size),
) -> dict[str, Any]:
return {
"pagination_type": pagination_type,
"page": page,
"cursor": cursor,
"items_per_page": items_per_page,
"include_total": include_total,
}
dependency.__name__ = f"{cls.model.__name__}PaginateParams"
return dependency
@classmethod
def order_params(
cls: type[Self],
*,
order_fields: Sequence[QueryableAttribute[Any]] | None = None, order_fields: Sequence[QueryableAttribute[Any]] | None = None,
default_field: QueryableAttribute[Any] | None = None, default_order_field: QueryableAttribute[Any] | None = None,
default_order: Literal["asc", "desc"] = "asc", default_order: Literal["asc", "desc"] = "asc",
) -> Callable[..., Awaitable[OrderByClause | None]]: ) -> Callable[..., Awaitable[dict[str, Any]]]:
"""Return a FastAPI dependency that resolves order query params into an order_by clause. """Return a FastAPI dependency that collects all params for :meth:`paginate`.
Args: Args:
order_fields: Override the allowed order fields. Falls back to the class-level default_page_size: Default ``items_per_page`` value.
``order_fields`` if not provided. max_page_size: Maximum ``items_per_page`` value.
default_field: Field to order by when ``order_by`` query param is absent. default_pagination_type: Default pagination strategy.
If ``None`` and no ``order_by`` is provided, no ordering is applied. include_total: Whether to include total count (not a query param).
default_order: Default order direction when ``order`` is absent search: Enable search query parameters.
(``"asc"`` or ``"desc"``). filter: Enable facet filter query parameters.
order: Enable order query parameters.
search_fields: Override searchable fields.
facet_fields: Override facet fields.
order_fields: Override order fields.
default_order_field: Default field to order by when ``order_by`` is absent.
default_order: Default sort direction.
Returns: Returns:
An async dependency function named ``{Model}OrderParams`` that resolves to an An async dependency that resolves to a dict ready to be unpacked
``OrderByClause`` (or ``None``). Pass it to ``Depends()`` in your route. into :meth:`paginate`.
Raises:
ValueError: If no order fields are configured on this CRUD class and none are
provided via ``order_fields``.
InvalidOrderFieldError: When the request provides an unknown ``order_by`` value.
""" """
fields = order_fields if order_fields is not None else cls.order_fields pagination_params = [
if not fields: inspect.Parameter(
raise ValueError( "pagination_type",
f"{cls.__name__} has no order_fields configured. " inspect.Parameter.KEYWORD_ONLY,
"Pass order_fields= or set them on CrudFactory." annotation=PaginationType,
) default=Query(
field_map: dict[str, QueryableAttribute[Any]] = {f.key: f for f in fields} default_pagination_type, description="Pagination strategy"
valid_keys = sorted(field_map.keys()) ),
async def dependency(
order_by: str | None = Query(
None, description=f"Field to order by. Valid values: {valid_keys}"
), ),
order: Literal["asc", "desc"] = Query( inspect.Parameter(
default_order, description="Sort direction" "page",
inspect.Parameter.KEYWORD_ONLY,
annotation=int,
default=Query(
1, ge=1, description="Page number (1-indexed, offset only)"
),
), ),
) -> OrderByClause | None: inspect.Parameter(
if order_by is None: "cursor",
if default_field is None: inspect.Parameter.KEYWORD_ONLY,
return None annotation=str | None,
field = default_field default=Query(
elif order_by not in field_map: None,
raise InvalidOrderFieldError(order_by, valid_keys) description="Cursor token from a previous response (cursor only)",
else: ),
field = field_map[order_by] ),
return field.asc() if order == "asc" else field.desc() inspect.Parameter(
"items_per_page",
dependency.__name__ = f"{cls.model.__name__}OrderParams" inspect.Parameter.KEYWORD_ONLY,
return dependency annotation=int,
default=_page_size_query(default_page_size, max_page_size),
),
]
return cls._build_paginate_params(
pagination_params=pagination_params,
pagination_fixed={"include_total": include_total},
dep_name=f"{cls.model.__name__}PaginateParams",
search=search,
filter=filter,
order=order,
search_fields=search_fields,
facet_fields=facet_fields,
order_fields=order_fields,
default_order_field=default_order_field,
default_order=default_order,
)
@overload @overload
@classmethod @classmethod
@@ -1056,6 +1207,7 @@ class AsyncCrud(Generic[ModelType]):
include_total: bool = True, 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,
search_column: str | 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],
@@ -1075,6 +1227,7 @@ class AsyncCrud(Generic[ModelType]):
``pagination.total_count`` will be ``None``. ``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)
search_column: Restrict search to a single column key.
facet_fields: Columns to compute distinct values for (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. 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, Keys must match the column.key of a facet field. Scalar → equality,
@@ -1097,6 +1250,7 @@ class AsyncCrud(Generic[ModelType]):
search, search,
search_fields=search_fields, search_fields=search_fields,
default_fields=cls.searchable_fields, default_fields=cls.searchable_fields,
search_column=search_column,
) )
filters.extend(search_filters) filters.extend(search_filters)
search_joins.extend(new_search_joins) search_joins.extend(new_search_joins)
@@ -1153,6 +1307,7 @@ class AsyncCrud(Generic[ModelType]):
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
) )
search_columns = cls._resolve_search_columns(search_fields)
return OffsetPaginatedResponse( return OffsetPaginatedResponse(
data=items, data=items,
@@ -1163,6 +1318,7 @@ class AsyncCrud(Generic[ModelType]):
has_more=has_more, has_more=has_more,
), ),
filter_attributes=filter_attributes, filter_attributes=filter_attributes,
search_columns=search_columns,
) )
@classmethod @classmethod
@@ -1179,6 +1335,7 @@ class AsyncCrud(Generic[ModelType]):
items_per_page: int = 20, items_per_page: int = 20,
search: str | SearchConfig | None = None, search: str | SearchConfig | None = None,
search_fields: Sequence[SearchFieldType] | None = None, search_fields: Sequence[SearchFieldType] | None = None,
search_column: str | 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],
@@ -1199,6 +1356,7 @@ class AsyncCrud(Generic[ModelType]):
items_per_page: Number of items per page (default 20). items_per_page: Number of items per page (default 20).
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).
search_column: Restrict search to a single column key.
facet_fields: Columns to compute distinct values for (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. 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, Keys must match the column.key of a facet field. Scalar → equality,
@@ -1238,6 +1396,7 @@ class AsyncCrud(Generic[ModelType]):
search, search,
search_fields=search_fields, search_fields=search_fields,
default_fields=cls.searchable_fields, default_fields=cls.searchable_fields,
search_column=search_column,
) )
filters.extend(search_filters) filters.extend(search_filters)
search_joins.extend(new_search_joins) search_joins.extend(new_search_joins)
@@ -1308,6 +1467,7 @@ class AsyncCrud(Generic[ModelType]):
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
) )
search_columns = cls._resolve_search_columns(search_fields)
return CursorPaginatedResponse( return CursorPaginatedResponse(
data=items, data=items,
@@ -1318,6 +1478,7 @@ class AsyncCrud(Generic[ModelType]):
has_more=has_more, has_more=has_more,
), ),
filter_attributes=filter_attributes, filter_attributes=filter_attributes,
search_columns=search_columns,
) )
@overload @overload
@@ -1338,6 +1499,7 @@ class AsyncCrud(Generic[ModelType]):
include_total: bool = ..., include_total: bool = ...,
search: str | SearchConfig | None = ..., search: str | SearchConfig | None = ...,
search_fields: Sequence[SearchFieldType] | None = ..., search_fields: Sequence[SearchFieldType] | None = ...,
search_column: str | None = ...,
facet_fields: Sequence[FacetFieldType] | None = ..., facet_fields: Sequence[FacetFieldType] | None = ...,
filter_by: dict[str, Any] | BaseModel | None = ..., filter_by: dict[str, Any] | BaseModel | None = ...,
schema: type[BaseModel], schema: type[BaseModel],
@@ -1361,6 +1523,7 @@ class AsyncCrud(Generic[ModelType]):
include_total: bool = ..., include_total: bool = ...,
search: str | SearchConfig | None = ..., search: str | SearchConfig | None = ...,
search_fields: Sequence[SearchFieldType] | None = ..., search_fields: Sequence[SearchFieldType] | None = ...,
search_column: str | None = ...,
facet_fields: Sequence[FacetFieldType] | None = ..., facet_fields: Sequence[FacetFieldType] | None = ...,
filter_by: dict[str, Any] | BaseModel | None = ..., filter_by: dict[str, Any] | BaseModel | None = ...,
schema: type[BaseModel], schema: type[BaseModel],
@@ -1383,6 +1546,7 @@ class AsyncCrud(Generic[ModelType]):
include_total: bool = True, 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,
search_column: str | 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],
@@ -1410,6 +1574,7 @@ class AsyncCrud(Generic[ModelType]):
only applies when ``pagination_type`` is ``OFFSET``. only applies when ``pagination_type`` is ``OFFSET``.
search: Search query string or :class:`.SearchConfig` object. search: Search query string or :class:`.SearchConfig` object.
search_fields: Fields to search in (overrides class default). search_fields: Fields to search in (overrides class default).
search_column: Restrict search to a single column key.
facet_fields: Columns to compute distinct values for (overrides facet_fields: Columns to compute distinct values for (overrides
class default). class default).
filter_by: Dict of ``{column_key: value}`` to filter by declared filter_by: Dict of ``{column_key: value}`` to filter by declared
@@ -1438,6 +1603,7 @@ class AsyncCrud(Generic[ModelType]):
items_per_page=items_per_page, items_per_page=items_per_page,
search=search, search=search,
search_fields=search_fields, search_fields=search_fields,
search_column=search_column,
facet_fields=facet_fields, facet_fields=facet_fields,
filter_by=filter_by, filter_by=filter_by,
schema=schema, schema=schema,
@@ -1457,6 +1623,7 @@ class AsyncCrud(Generic[ModelType]):
include_total=include_total, include_total=include_total,
search=search, search=search,
search_fields=search_fields, search_fields=search_fields,
search_column=search_column,
facet_fields=facet_fields, facet_fields=facet_fields,
filter_by=filter_by, filter_by=filter_by,
schema=schema, schema=schema,
@@ -1488,7 +1655,7 @@ def CrudFactory(
responses. Supports direct columns (``User.status``) and relationship tuples responses. Supports direct columns (``User.status``) and relationship tuples
(``(User.role, Role.name)``). Can be overridden per call. (``(User.role, Role.name)``). Can be overridden per call.
order_fields: Optional list of model attributes that callers are allowed to order by order_fields: Optional list of model attributes that callers are allowed to order by
via ``order_params()``. Can be overridden per call. via ``offset_paginate_params()``. Can be overridden per call.
m2m_fields: Optional mapping for many-to-many relationships. m2m_fields: Optional mapping for many-to-many relationships.
Maps schema field names (containing lists of IDs) to Maps schema field names (containing lists of IDs) to
SQLAlchemy relationship attributes. SQLAlchemy relationship attributes.

View File

@@ -24,6 +24,7 @@ from sqlalchemy.types import (
from ..exceptions import ( from ..exceptions import (
InvalidFacetFilterError, InvalidFacetFilterError,
InvalidSearchColumnError,
NoSearchableFieldsError, NoSearchableFieldsError,
UnsupportedFacetTypeError, UnsupportedFacetTypeError,
) )
@@ -96,6 +97,7 @@ def build_search_filters(
search: str | SearchConfig, search: str | SearchConfig,
search_fields: Sequence[SearchFieldType] | None = None, search_fields: Sequence[SearchFieldType] | None = None,
default_fields: Sequence[SearchFieldType] | None = None, default_fields: Sequence[SearchFieldType] | None = None,
search_column: str | None = None,
) -> tuple[list["ColumnElement[bool]"], list[InstrumentedAttribute[Any]]]: ) -> tuple[list["ColumnElement[bool]"], list[InstrumentedAttribute[Any]]]:
"""Build SQLAlchemy filter conditions for search. """Build SQLAlchemy filter conditions for search.
@@ -104,6 +106,8 @@ def build_search_filters(
search: Search string or SearchConfig search: Search string or SearchConfig
search_fields: Fields specified per-call (takes priority) search_fields: Fields specified per-call (takes priority)
default_fields: Default fields (from ClassVar) default_fields: Default fields (from ClassVar)
search_column: Optional key to narrow search to a single field.
Must match one of the resolved search field keys.
Returns: Returns:
Tuple of (filter_conditions, joins_needed) Tuple of (filter_conditions, joins_needed)
@@ -130,6 +134,14 @@ def build_search_filters(
if not fields: if not fields:
raise NoSearchableFieldsError(model) raise NoSearchableFieldsError(model)
# Narrow to a single column when search_column is specified
if search_column is not None:
keys = search_field_keys(fields)
index = {k: f for k, f in zip(keys, fields)}
if search_column not in index:
raise InvalidSearchColumnError(search_column, sorted(index))
fields = [index[search_column]]
query = config.query.strip() query = config.query.strip()
filters: list[ColumnElement[bool]] = [] filters: list[ColumnElement[bool]] = []
joins: list[InstrumentedAttribute[Any]] = [] joins: list[InstrumentedAttribute[Any]] = []
@@ -164,6 +176,11 @@ def build_search_filters(
return filters, joins return filters, joins
def search_field_keys(fields: Sequence[SearchFieldType]) -> list[str]:
"""Return a human-readable key for each search field."""
return facet_keys(fields)
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. """Return a key for each facet field.
@@ -225,13 +242,19 @@ async def build_facets(
else: else:
q = select(column).select_from(model).distinct() q = select(column).select_from(model).distinct()
# Apply base joins (already done on main query, but needed here independently) # Apply base joins (deduplicated) — needed here independently
seen_joins: set[str] = set()
for rel in base_joins or []: for rel in base_joins or []:
q = q.outerjoin(rel) rel_key = str(rel)
if rel_key not in seen_joins:
seen_joins.add(rel_key)
q = q.outerjoin(rel)
# Add any extra joins required by this facet field that aren't already in base_joins # Add any extra joins required by this facet field that aren't already applied
for rel in rels: for rel in rels:
if str(rel) not in existing_join_keys: rel_key = str(rel)
if rel_key not in existing_join_keys and rel_key not in seen_joins:
seen_joins.add(rel_key)
q = q.outerjoin(rel) q = q.outerjoin(rel)
if base_filters: if base_filters:

View File

@@ -7,6 +7,7 @@ from .exceptions import (
ForbiddenError, ForbiddenError,
InvalidFacetFilterError, InvalidFacetFilterError,
InvalidOrderFieldError, InvalidOrderFieldError,
InvalidSearchColumnError,
NoSearchableFieldsError, NoSearchableFieldsError,
NotFoundError, NotFoundError,
UnauthorizedError, UnauthorizedError,
@@ -24,6 +25,7 @@ __all__ = [
"init_exceptions_handlers", "init_exceptions_handlers",
"InvalidFacetFilterError", "InvalidFacetFilterError",
"InvalidOrderFieldError", "InvalidOrderFieldError",
"InvalidSearchColumnError",
"NoSearchableFieldsError", "NoSearchableFieldsError",
"NotFoundError", "NotFoundError",
"UnauthorizedError", "UnauthorizedError",

View File

@@ -172,6 +172,33 @@ class UnsupportedFacetTypeError(ApiException):
) )
class InvalidSearchColumnError(ApiException):
"""Raised when search_column is not one of the configured searchable fields."""
api_error = ApiError(
code=400,
msg="Invalid Search Column",
desc="The requested search column is not a configured searchable field.",
err_code="SEARCH-COL-400",
)
def __init__(self, column: str, valid_columns: list[str]) -> None:
"""Initialize the exception.
Args:
column: The unknown search column provided by the caller.
valid_columns: List of valid search column keys.
"""
self.column = column
self.valid_columns = valid_columns
super().__init__(
desc=(
f"'{column}' is not a searchable column. "
f"Valid columns: {valid_columns}."
)
)
class InvalidOrderFieldError(ApiException): class InvalidOrderFieldError(ApiException):
"""Raised when order_by contains a field not in the allowed order fields.""" """Raised when order_by contains a field not in the allowed order fields."""

View File

@@ -122,7 +122,7 @@ def _format_validation_error(
) )
return JSONResponse( return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
content=error_response.model_dump(), content=error_response.model_dump(),
) )

View File

@@ -1,6 +1,6 @@
"""Prometheus metrics endpoint for FastAPI applications.""" """Prometheus metrics endpoint for FastAPI applications."""
import asyncio import inspect
import os import os
from fastapi import FastAPI from fastapi import FastAPI
@@ -55,10 +55,10 @@ def init_metrics(
# Partition collectors and cache env check at startup — both are stable for the app lifetime. # Partition collectors and cache env check at startup — both are stable for the app lifetime.
async_collectors = [ async_collectors = [
c for c in registry.get_collectors() if asyncio.iscoroutinefunction(c.func) c for c in registry.get_collectors() if inspect.iscoroutinefunction(c.func)
] ]
sync_collectors = [ sync_collectors = [
c for c in registry.get_collectors() if not asyncio.iscoroutinefunction(c.func) c for c in registry.get_collectors() if not inspect.iscoroutinefunction(c.func)
] ]
multiprocess_mode = _is_multiprocess() multiprocess_mode = _is_multiprocess()

View File

@@ -1,7 +1,6 @@
"""Pytest helper utilities for FastAPI testing.""" """Pytest helper utilities for FastAPI testing."""
import os import os
import warnings
from collections.abc import AsyncGenerator, Callable from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any from typing import Any
@@ -16,31 +15,10 @@ from sqlalchemy.ext.asyncio import (
) )
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from ..db import cleanup_tables as _cleanup_tables from ..db import cleanup_tables, create_database
from ..db import create_database
from ..models.watched import EventSession from ..models.watched import EventSession
async def cleanup_tables(
session: AsyncSession,
base: type[DeclarativeBase],
) -> None:
"""Truncate all tables for fast between-test cleanup.
.. deprecated::
Import ``cleanup_tables`` from ``fastapi_toolsets.db`` instead.
This re-export will be removed in v3.0.0.
"""
warnings.warn(
"Importing cleanup_tables from fastapi_toolsets.pytest is deprecated "
"and will be removed in v3.0.0. "
"Use 'from fastapi_toolsets.db import cleanup_tables' instead.",
DeprecationWarning,
stacklevel=2,
)
await _cleanup_tables(session=session, base=base)
def _get_xdist_worker(default_test_db: str) -> str: def _get_xdist_worker(default_test_db: str) -> str:
"""Return the pytest-xdist worker name, or *default_test_db* when not running under xdist. """Return the pytest-xdist worker name, or *default_test_db* when not running under xdist.
@@ -273,7 +251,7 @@ async def create_db_session(
yield session yield session
if cleanup: if cleanup:
await _cleanup_tables(session=session, base=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

@@ -162,6 +162,7 @@ class PaginatedResponse(BaseResponse, Generic[DataT]):
pagination: OffsetPagination | CursorPagination pagination: OffsetPagination | CursorPagination
pagination_type: PaginationType | None = None pagination_type: PaginationType | None = None
filter_attributes: dict[str, list[Any]] | None = None filter_attributes: dict[str, list[Any]] | None = None
search_columns: list[str] | None = None
_discriminated_union_cache: ClassVar[dict[Any, Any]] = {} _discriminated_union_cache: ClassVar[dict[Any, Any]] = {}

View File

@@ -211,6 +211,38 @@ class TestResolveLoadOptions:
assert crud._resolve_load_options([]) == [] assert crud._resolve_load_options([]) == []
class TestResolveSearchColumns:
"""Tests for _resolve_search_columns logic."""
def test_returns_none_when_no_searchable_fields(self):
"""Returns None when cls.searchable_fields is None and no search_fields passed."""
class AbstractCrud(AsyncCrud[User]):
pass
assert AbstractCrud._resolve_search_columns(None) is None
def test_returns_none_when_empty_search_fields_passed(self):
"""Returns None when an empty list is passed explicitly."""
crud = CrudFactory(User)
assert crud._resolve_search_columns([]) is None
def test_returns_keys_from_class_searchable_fields(self):
"""Returns column keys from cls.searchable_fields when no override passed."""
crud = CrudFactory(User, searchable_fields=[User.username])
result = crud._resolve_search_columns(None)
assert result is not None
assert "username" in result
def test_search_fields_override_takes_priority(self):
"""Explicit search_fields override cls.searchable_fields."""
crud = CrudFactory(User, searchable_fields=[User.username])
result = crud._resolve_search_columns([User.email])
assert result is not None
assert "email" in result
assert "username" not in result
class TestDefaultLoadOptionsIntegration: class TestDefaultLoadOptionsIntegration:
"""Integration tests for default_load_options with real DB queries.""" """Integration tests for default_load_options with real DB queries."""

View File

@@ -10,6 +10,7 @@ from sqlalchemy.sql.elements import ColumnElement, UnaryExpression
from fastapi_toolsets.crud import ( from fastapi_toolsets.crud import (
CrudFactory, CrudFactory,
InvalidFacetFilterError, InvalidFacetFilterError,
InvalidSearchColumnError,
SearchConfig, SearchConfig,
UnsupportedFacetTypeError, UnsupportedFacetTypeError,
get_searchable_fields, get_searchable_fields,
@@ -696,6 +697,67 @@ class TestFacetsRelationship:
assert result.filter_attributes is not None assert result.filter_attributes is not None
assert result.filter_attributes["role__name"] == ["admin"] assert result.filter_attributes["role__name"] == ["admin"]
@pytest.mark.anyio
async def test_relationship_search_and_filter_by_same_join(
self, db_session: AsyncSession
):
"""Search + filter_by on the same relationship must not duplicate the JOIN."""
UserSearchFacetCrud = CrudFactory(
User,
searchable_fields=[(User.role, Role.name)],
facet_fields=[(User.role, Role.name)],
)
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
editor = await RoleCrud.create(db_session, RoleCreate(name="editor"))
await UserCrud.create(
db_session,
UserCreate(username="alice", email="a@test.com", role_id=admin.id),
)
await UserCrud.create(
db_session,
UserCreate(username="bob", email="b@test.com", role_id=editor.id),
)
# Search by role name AND filter by role name — both need the same join
result = await UserSearchFacetCrud.offset_paginate(
db_session,
search="admin",
filter_by={"role__name": "admin"},
schema=UserRead,
)
assert len(result.data) == 1
assert result.data[0].username == "alice"
assert result.filter_attributes is not None
assert result.filter_attributes["role__name"] == ["admin"]
@pytest.mark.anyio
async def test_cursor_paginate_duplicate_join(self, db_session: AsyncSession):
"""cursor_paginate with overlapping search + facet joins must not fail."""
UserSearchFacetCursorCrud = CrudFactory(
User,
searchable_fields=[(User.role, Role.name)],
facet_fields=[(User.role, Role.name)],
cursor_column=User.id,
)
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
await UserCrud.create(
db_session,
UserCreate(username="alice", email="a@test.com", role_id=admin.id),
)
result = await UserSearchFacetCursorCrud.cursor_paginate(
db_session,
search="admin",
filter_by={"role__name": "admin"},
schema=UserRead,
)
assert len(result.data) == 1
assert result.data[0].username == "alice"
class TestFilterBy: class TestFilterBy:
"""Tests for the filter_by parameter on offset_paginate and cursor_paginate.""" """Tests for the filter_by parameter on offset_paginate and cursor_paginate."""
@@ -1030,61 +1092,64 @@ class TestFilterBy:
assert "JSON" in exc_info.value.col_type assert "JSON" in exc_info.value.col_type
class TestFilterParamsSchema: class TestFilterParamsViaConsolidated:
"""Tests for AsyncCrud.filter_params().""" """Tests for filter params via consolidated offset_paginate_params()."""
def test_generates_fields_from_facet_fields(self): def test_generates_fields_from_facet_fields(self):
"""Returned dependency has one keyword param per facet field.""" """Returned dependency has one keyword param per facet field."""
import inspect
UserFacetCrud = CrudFactory(User, facet_fields=[User.username, User.email]) UserFacetCrud = CrudFactory(User, facet_fields=[User.username, User.email])
dep = UserFacetCrud.filter_params() dep = UserFacetCrud.offset_paginate_params(search=False, order=False)
param_names = set(inspect.signature(dep).parameters) param_names = set(inspect.signature(dep).parameters)
assert param_names == {"username", "email"} assert "username" in param_names
assert "email" in param_names
def test_relationship_facet_uses_full_chain_key(self): def test_relationship_facet_uses_full_chain_key(self):
"""Relationship tuple uses the full chain joined by __ as the key.""" """Relationship tuple uses the full chain joined by __ as the key."""
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.offset_paginate_params(search=False, order=False)
param_names = set(inspect.signature(dep).parameters) param_names = set(inspect.signature(dep).parameters)
assert param_names == {"role__name"} assert "role__name" in param_names
def test_raises_when_no_facet_fields(self): def test_filter_disabled_no_facet_params(self):
"""ValueError raised when no facet_fields are configured or provided.""" """When filter=False, no facet params are generated."""
with pytest.raises(ValueError, match="no facet_fields"): UserFacetCrud = CrudFactory(User, facet_fields=[User.username, User.email])
UserCrud.filter_params() dep = UserFacetCrud.offset_paginate_params(
search=False, filter=False, order=False
)
param_names = set(inspect.signature(dep).parameters)
assert param_names == {"page", "items_per_page"}
def test_facet_fields_override(self): def test_facet_fields_override(self):
"""facet_fields= parameter overrides the class-level default.""" """facet_fields= parameter overrides the class-level default."""
import inspect
UserFacetCrud = CrudFactory(User, facet_fields=[User.username, User.email]) UserFacetCrud = CrudFactory(User, facet_fields=[User.username, User.email])
dep = UserFacetCrud.filter_params(facet_fields=[User.email]) dep = UserFacetCrud.offset_paginate_params(
search=False, order=False, facet_fields=[User.email]
)
param_names = set(inspect.signature(dep).parameters) param_names = set(inspect.signature(dep).parameters)
assert param_names == {"email"} assert "email" in param_names
assert "username" not in param_names
@pytest.mark.anyio @pytest.mark.anyio
async def test_awaiting_dep_returns_dict_with_values(self): async def test_awaiting_dep_returns_filter_by_with_values(self):
"""Awaiting the dependency returns a dict with only the supplied keys.""" """Awaiting the dependency returns filter_by dict with only supplied keys."""
UserFacetCrud = CrudFactory(User, facet_fields=[User.username, User.email]) UserFacetCrud = CrudFactory(User, facet_fields=[User.username, User.email])
dep = UserFacetCrud.filter_params() dep = UserFacetCrud.offset_paginate_params(search=False, order=False)
result = await dep(username=["alice"]) result = await dep(page=1, items_per_page=20, username=["alice"], email=None)
assert result == {"username": ["alice"]} assert result["filter_by"] == {"username": ["alice"]}
@pytest.mark.anyio @pytest.mark.anyio
async def test_multi_value_list_field(self): async def test_multi_value_list_field(self):
"""Multiple values are accepted as a list.""" """Multiple values are accepted as a list."""
UserFacetCrud = CrudFactory(User, facet_fields=[User.username]) UserFacetCrud = CrudFactory(User, facet_fields=[User.username])
dep = UserFacetCrud.filter_params() dep = UserFacetCrud.offset_paginate_params(search=False, order=False)
result = await dep(username=["alice", "bob"]) result = await dep(page=1, items_per_page=20, username=["alice", "bob"])
assert result == {"username": ["alice", "bob"]} assert result["filter_by"] == {"username": ["alice", "bob"]}
def test_disambiguates_duplicate_column_keys(self): def test_disambiguates_duplicate_column_keys(self):
"""Two relationship tuples sharing a terminal column key get prefixed names.""" """Two relationship tuples sharing a terminal column key get prefixed names."""
@@ -1129,15 +1194,15 @@ class TestFilterParamsSchema:
assert keys == ["username", "email"] assert keys == ["username", "email"]
def test_dependency_name_includes_model_name(self): def test_dependency_name_includes_model_name(self):
"""Returned dependency is named {Model}FilterParams.""" """Returned dependency is named {Model}OffsetPaginateParams."""
UserFacetCrud = CrudFactory(User, facet_fields=[User.username]) UserFacetCrud = CrudFactory(User, facet_fields=[User.username])
dep = UserFacetCrud.filter_params() dep = UserFacetCrud.offset_paginate_params(search=False, order=False)
assert dep.__name__ == "UserFilterParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] assert dep.__name__ == "UserOffsetPaginateParams" # 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):
"""Dependency result can be passed directly to offset_paginate via filter_by.""" """Dependency result can be unpacked directly into offset_paginate."""
UserFacetCrud = CrudFactory(User, facet_fields=[User.username]) UserFacetCrud = CrudFactory(User, facet_fields=[User.username])
await UserCrud.create( await UserCrud.create(
db_session, UserCreate(username="alice", email="a@test.com") db_session, UserCreate(username="alice", email="a@test.com")
@@ -1146,10 +1211,10 @@ class TestFilterParamsSchema:
db_session, UserCreate(username="bob", email="b@test.com") db_session, UserCreate(username="bob", email="b@test.com")
) )
dep = UserFacetCrud.filter_params() dep = UserFacetCrud.offset_paginate_params(search=False, order=False)
f = await dep(username=["alice"]) params = await dep(page=1, items_per_page=20, username=["alice"])
result = await UserFacetCrud.offset_paginate( result = await UserFacetCrud.offset_paginate(
db_session, filter_by=f, schema=UserRead db_session, **params, schema=UserRead
) )
assert isinstance(result.pagination, OffsetPagination) assert isinstance(result.pagination, OffsetPagination)
@@ -1158,7 +1223,7 @@ class TestFilterParamsSchema:
@pytest.mark.anyio @pytest.mark.anyio
async def test_dep_result_passed_to_cursor_paginate(self, db_session: AsyncSession): async def test_dep_result_passed_to_cursor_paginate(self, db_session: AsyncSession):
"""Dependency result can be passed directly to cursor_paginate via filter_by.""" """Dependency result can be unpacked directly into cursor_paginate."""
UserFacetCursorCrud = CrudFactory( UserFacetCursorCrud = CrudFactory(
User, cursor_column=User.id, facet_fields=[User.username] User, cursor_column=User.id, facet_fields=[User.username]
) )
@@ -1169,10 +1234,10 @@ class TestFilterParamsSchema:
db_session, UserCreate(username="bob", email="b@test.com") db_session, UserCreate(username="bob", email="b@test.com")
) )
dep = UserFacetCursorCrud.filter_params() dep = UserFacetCursorCrud.cursor_paginate_params(search=False, order=False)
f = await dep(username=["alice"]) params = await dep(cursor=None, items_per_page=20, username=["alice"])
result = await UserFacetCursorCrud.cursor_paginate( result = await UserFacetCursorCrud.cursor_paginate(
db_session, filter_by=f, schema=UserRead db_session, **params, schema=UserRead
) )
assert len(result.data) == 1 assert len(result.data) == 1
@@ -1180,7 +1245,7 @@ class TestFilterParamsSchema:
@pytest.mark.anyio @pytest.mark.anyio
async def test_all_none_dep_result_passes_no_filter(self, db_session: AsyncSession): async def test_all_none_dep_result_passes_no_filter(self, db_session: AsyncSession):
"""All-None dependency result results in no filter (returns all rows).""" """All-None dependency result results in filter_by=None (returns all rows)."""
UserFacetCrud = CrudFactory(User, facet_fields=[User.username]) UserFacetCrud = CrudFactory(User, facet_fields=[User.username])
await UserCrud.create( await UserCrud.create(
db_session, UserCreate(username="alice", email="a@test.com") db_session, UserCreate(username="alice", email="a@test.com")
@@ -1189,46 +1254,269 @@ class TestFilterParamsSchema:
db_session, UserCreate(username="bob", email="b@test.com") db_session, UserCreate(username="bob", email="b@test.com")
) )
dep = UserFacetCrud.filter_params() dep = UserFacetCrud.offset_paginate_params(search=False, order=False)
f = await dep() # all fields None params = await dep(page=1, items_per_page=20) # all facet fields None
assert params["filter_by"] is None
result = await UserFacetCrud.offset_paginate( result = await UserFacetCrud.offset_paginate(
db_session, filter_by=f, schema=UserRead db_session, **params, schema=UserRead
) )
assert isinstance(result.pagination, OffsetPagination) assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 2 assert result.pagination.total_count == 2
def test_facet_key_collision_raises(self):
"""ValueError raised when a facet key clashes with a reserved param name."""
# Create a mock facet field whose key would be "search"
from unittest.mock import MagicMock
class TestOrderParamsSchema: mock_field = MagicMock()
"""Tests for AsyncCrud.order_params().""" mock_field.key = "search"
mock_field.property.columns = [MagicMock()]
UserFacetCrud = CrudFactory(User, facet_fields=[mock_field])
with pytest.raises(ValueError, match="conflicts with a reserved"):
UserFacetCrud.offset_paginate_params(search=True, order=False)
class TestSearchParamsViaConsolidated:
"""Tests for search params via consolidated offset_paginate_params()."""
def test_generates_search_and_search_column_params(self):
"""Returned dependency has search and search_column query params."""
UserSearchCrud = CrudFactory(
User, searchable_fields=[User.username, User.email]
)
dep = UserSearchCrud.offset_paginate_params(filter=False, order=False)
param_names = set(inspect.signature(dep).parameters)
assert "search" in param_names
assert "search_column" in param_names
def test_search_disabled_no_search_params(self):
"""When search=False, no search params are generated."""
UserSearchCrud = CrudFactory(
User, searchable_fields=[User.username, User.email]
)
dep = UserSearchCrud.offset_paginate_params(
search=False, filter=False, order=False
)
param_names = set(inspect.signature(dep).parameters)
assert "search" not in param_names
assert "search_column" not in param_names
@pytest.mark.anyio
async def test_awaiting_dep_with_search_only(self):
"""Awaiting the dependency with only search returns search in dict."""
UserSearchCrud = CrudFactory(
User, searchable_fields=[User.username, User.email]
)
dep = UserSearchCrud.offset_paginate_params(filter=False, order=False)
result = await dep(
page=1, items_per_page=20, search="alice", search_column=None
)
assert result["search"] == "alice"
assert "search_column" not in result
@pytest.mark.anyio
async def test_awaiting_dep_with_search_and_column(self):
"""Awaiting the dependency with both params returns both keys."""
UserSearchCrud = CrudFactory(
User, searchable_fields=[User.username, User.email]
)
dep = UserSearchCrud.offset_paginate_params(filter=False, order=False)
result = await dep(
page=1, items_per_page=20, search="alice", search_column="username"
)
assert result["search"] == "alice"
assert result["search_column"] == "username"
@pytest.mark.anyio
async def test_awaiting_dep_with_no_search_values(self):
"""Awaiting the dependency with no search values omits search keys."""
UserSearchCrud = CrudFactory(
User, searchable_fields=[User.username, User.email]
)
dep = UserSearchCrud.offset_paginate_params(filter=False, order=False)
result = await dep(page=1, items_per_page=20, search=None, search_column=None)
assert "search" not in result
assert "search_column" not in result
def test_relationship_search_field_key(self):
"""Relationship tuple search fields use __ joined keys."""
UserRelSearchCrud = CrudFactory(
User, searchable_fields=[User.username, (User.role, Role.name)]
)
dep = UserRelSearchCrud.offset_paginate_params(filter=False, order=False)
params = inspect.signature(dep).parameters
search_column_param = params["search_column"]
assert search_column_param.default.json_schema_extra.get("enum") == [
"id",
"username",
"role__name",
]
class TestSearchColumns:
"""Tests for search_columns in paginated responses."""
@pytest.mark.anyio
async def test_search_columns_returned_in_offset_paginate(
self, db_session: AsyncSession
):
"""offset_paginate response includes search_columns."""
UserSearchCrud = CrudFactory(
User, searchable_fields=[User.username, User.email]
)
await UserCrud.create(
db_session, UserCreate(username="alice", email="a@test.com")
)
result = await UserSearchCrud.offset_paginate(db_session, schema=UserRead)
assert result.search_columns is not None
assert "username" in result.search_columns
assert "email" in result.search_columns
@pytest.mark.anyio
async def test_search_columns_returned_in_cursor_paginate(
self, db_session: AsyncSession
):
"""cursor_paginate response includes search_columns."""
UserSearchCursorCrud = CrudFactory(
User,
cursor_column=User.id,
searchable_fields=[User.username, User.email],
)
await UserCrud.create(
db_session, UserCreate(username="alice", email="a@test.com")
)
result = await UserSearchCursorCrud.cursor_paginate(db_session, schema=UserRead)
assert result.search_columns is not None
assert "username" in result.search_columns
assert "email" in result.search_columns
@pytest.mark.anyio
async def test_search_column_narrows_search(self, db_session: AsyncSession):
"""search_column restricts search to a single field."""
UserSearchCrud = CrudFactory(
User, searchable_fields=[User.username, User.email]
)
await UserCrud.create(
db_session, UserCreate(username="alice", email="bob@test.com")
)
await UserCrud.create(
db_session, UserCreate(username="bob", email="alice@test.com")
)
# Search "alice" in username only — should return only alice
result = await UserSearchCrud.offset_paginate(
db_session, search="alice", search_column="username", schema=UserRead
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 1
assert result.data[0].username == "alice"
@pytest.mark.anyio
async def test_search_column_invalid_raises(self, db_session: AsyncSession):
"""search_column with an invalid key raises InvalidSearchColumnError."""
UserSearchCrud = CrudFactory(
User, searchable_fields=[User.username, User.email]
)
with pytest.raises(InvalidSearchColumnError) as exc_info:
await UserSearchCrud.offset_paginate(
db_session,
search="alice",
search_column="nonexistent",
schema=UserRead,
)
assert exc_info.value.column == "nonexistent"
@pytest.mark.anyio
async def test_search_without_search_column_searches_all(
self, db_session: AsyncSession
):
"""search without search_column searches across all configured fields."""
UserSearchCrud = CrudFactory(
User, searchable_fields=[User.username, User.email]
)
await UserCrud.create(
db_session, UserCreate(username="alice", email="bob@test.com")
)
await UserCrud.create(
db_session, UserCreate(username="bob", email="alice@test.com")
)
# Search "alice" across all fields — should return both
result = await UserSearchCrud.offset_paginate(
db_session, search="alice", schema=UserRead
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 2
@pytest.mark.anyio
async def test_search_column_with_cursor_paginate(self, db_session: AsyncSession):
"""search_column works with cursor_paginate."""
UserSearchCursorCrud = CrudFactory(
User,
cursor_column=User.id,
searchable_fields=[User.username, User.email],
)
await UserCrud.create(
db_session, UserCreate(username="alice", email="bob@test.com")
)
await UserCrud.create(
db_session, UserCreate(username="bob", email="alice@test.com")
)
result = await UserSearchCursorCrud.cursor_paginate(
db_session, search="alice", search_column="email", schema=UserRead
)
assert len(result.data) == 1
assert result.data[0].username == "bob"
class TestOrderParamsViaConsolidated:
"""Tests for order params via consolidated offset_paginate_params()."""
def test_generates_order_by_and_order_params(self): def test_generates_order_by_and_order_params(self):
"""Returned dependency has order_by and order query params.""" """Returned dependency has order_by and order query params."""
UserOrderCrud = CrudFactory(User, order_fields=[User.username, User.email]) UserOrderCrud = CrudFactory(User, order_fields=[User.username, User.email])
dep = UserOrderCrud.order_params() dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
param_names = set(inspect.signature(dep).parameters) param_names = set(inspect.signature(dep).parameters)
assert param_names == {"order_by", "order"} assert "order_by" in param_names
assert "order" in param_names
def test_dependency_name_includes_model_name(self): def test_order_disabled_no_order_params(self):
"""Dependency function is named after the model.""" """When order=False, no order params are generated."""
UserOrderCrud = CrudFactory(User, order_fields=[User.username]) UserOrderCrud = CrudFactory(User, order_fields=[User.username])
dep = UserOrderCrud.order_params() dep = UserOrderCrud.offset_paginate_params(
assert getattr(dep, "__name__") == "UserOrderParams" search=False, filter=False, order=False
)
def test_raises_when_no_order_fields(self): param_names = set(inspect.signature(dep).parameters)
"""ValueError raised when no order_fields are configured or provided.""" assert "order_by" not in param_names
with pytest.raises(ValueError, match="no order_fields"): assert "order" not in param_names
UserCrud.order_params()
def test_order_fields_override(self): def test_order_fields_override(self):
"""order_fields= parameter overrides the class-level default.""" """order_fields= parameter overrides the class-level default."""
UserOrderCrud = CrudFactory(User, order_fields=[User.username, User.email]) UserOrderCrud = CrudFactory(User, order_fields=[User.username, User.email])
dep = UserOrderCrud.order_params(order_fields=[User.email]) dep = UserOrderCrud.offset_paginate_params(
search=False, filter=False, order_fields=[User.email]
)
param_names = set(inspect.signature(dep).parameters)
assert "order_by" in param_names
# description should only mention email, not username
sig = inspect.signature(dep) sig = inspect.signature(dep)
description = sig.parameters["order_by"].default.description description = sig.parameters["order_by"].default.description
assert "email" in description assert "email" in description
@@ -1237,7 +1525,7 @@ class TestOrderParamsSchema:
def test_order_by_description_lists_valid_fields(self): def test_order_by_description_lists_valid_fields(self):
"""order_by query param description mentions each allowed field.""" """order_by query param description mentions each allowed field."""
UserOrderCrud = CrudFactory(User, order_fields=[User.username, User.email]) UserOrderCrud = CrudFactory(User, order_fields=[User.username, User.email])
dep = UserOrderCrud.order_params() dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
sig = inspect.signature(dep) sig = inspect.signature(dep)
description = sig.parameters["order_by"].default.description description = sig.parameters["order_by"].default.description
@@ -1247,8 +1535,12 @@ class TestOrderParamsSchema:
def test_default_order_reflected_in_order_default(self): def test_default_order_reflected_in_order_default(self):
"""default_order is used as the default value for order.""" """default_order is used as the default value for order."""
UserOrderCrud = CrudFactory(User, order_fields=[User.username]) UserOrderCrud = CrudFactory(User, order_fields=[User.username])
dep_asc = UserOrderCrud.order_params(default_order="asc") dep_asc = UserOrderCrud.offset_paginate_params(
dep_desc = UserOrderCrud.order_params(default_order="desc") search=False, filter=False, default_order="asc"
)
dep_desc = UserOrderCrud.offset_paginate_params(
search=False, filter=False, default_order="desc"
)
sig_asc = inspect.signature(dep_asc) sig_asc = inspect.signature(dep_asc)
sig_desc = inspect.signature(dep_desc) sig_desc = inspect.signature(dep_desc)
@@ -1257,55 +1549,59 @@ class TestOrderParamsSchema:
@pytest.mark.anyio @pytest.mark.anyio
async def test_no_order_by_no_default_returns_none(self): async def test_no_order_by_no_default_returns_none(self):
"""Returns None when order_by is absent and no default_field is set.""" """Returns order_by=None when order_by is absent and no default_order_field is set."""
UserOrderCrud = CrudFactory(User, order_fields=[User.username]) UserOrderCrud = CrudFactory(User, order_fields=[User.username])
dep = UserOrderCrud.order_params() dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
result = await dep(order_by=None, order="asc") result = await dep(page=1, items_per_page=20, order_by=None, order="asc")
assert result is None assert result["order_by"] is None
@pytest.mark.anyio @pytest.mark.anyio
async def test_no_order_by_with_default_field_returns_asc_expression(self): async def test_no_order_by_with_default_field_returns_asc_expression(self):
"""Returns default_field.asc() when order_by absent and order=asc.""" """Returns default_order_field.asc() when order_by absent and order=asc."""
UserOrderCrud = CrudFactory(User, order_fields=[User.username]) UserOrderCrud = CrudFactory(User, order_fields=[User.username])
dep = UserOrderCrud.order_params(default_field=User.username) dep = UserOrderCrud.offset_paginate_params(
result = await dep(order_by=None, order="asc") search=False, filter=False, default_order_field=User.username
assert isinstance(result, UnaryExpression) )
assert "ASC" in str(result) result = await dep(page=1, items_per_page=20, order_by=None, order="asc")
assert isinstance(result["order_by"], UnaryExpression)
assert "ASC" in str(result["order_by"])
@pytest.mark.anyio @pytest.mark.anyio
async def test_no_order_by_with_default_field_returns_desc_expression(self): async def test_no_order_by_with_default_field_returns_desc_expression(self):
"""Returns default_field.desc() when order_by absent and order=desc.""" """Returns default_order_field.desc() when order_by absent and order=desc."""
UserOrderCrud = CrudFactory(User, order_fields=[User.username]) UserOrderCrud = CrudFactory(User, order_fields=[User.username])
dep = UserOrderCrud.order_params(default_field=User.username) dep = UserOrderCrud.offset_paginate_params(
result = await dep(order_by=None, order="desc") search=False, filter=False, default_order_field=User.username
assert isinstance(result, UnaryExpression) )
assert "DESC" in str(result) result = await dep(page=1, items_per_page=20, order_by=None, order="desc")
assert isinstance(result["order_by"], UnaryExpression)
assert "DESC" in str(result["order_by"])
@pytest.mark.anyio @pytest.mark.anyio
async def test_valid_order_by_asc(self): async def test_valid_order_by_asc(self):
"""Returns field.asc() for a valid order_by with order=asc.""" """Returns field.asc() for a valid order_by with order=asc."""
UserOrderCrud = CrudFactory(User, order_fields=[User.username]) UserOrderCrud = CrudFactory(User, order_fields=[User.username])
dep = UserOrderCrud.order_params() dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
result = await dep(order_by="username", order="asc") result = await dep(page=1, items_per_page=20, order_by="username", order="asc")
assert isinstance(result, UnaryExpression) assert isinstance(result["order_by"], UnaryExpression)
assert "ASC" in str(result) assert "ASC" in str(result["order_by"])
@pytest.mark.anyio @pytest.mark.anyio
async def test_valid_order_by_desc(self): async def test_valid_order_by_desc(self):
"""Returns field.desc() for a valid order_by with order=desc.""" """Returns field.desc() for a valid order_by with order=desc."""
UserOrderCrud = CrudFactory(User, order_fields=[User.username]) UserOrderCrud = CrudFactory(User, order_fields=[User.username])
dep = UserOrderCrud.order_params() dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
result = await dep(order_by="username", order="desc") result = await dep(page=1, items_per_page=20, order_by="username", order="desc")
assert isinstance(result, UnaryExpression) assert isinstance(result["order_by"], UnaryExpression)
assert "DESC" in str(result) assert "DESC" in str(result["order_by"])
@pytest.mark.anyio @pytest.mark.anyio
async def test_invalid_order_by_raises_invalid_order_field_error(self): async def test_invalid_order_by_raises_invalid_order_field_error(self):
"""Raises InvalidOrderFieldError for an unknown order_by value.""" """Raises InvalidOrderFieldError for an unknown order_by value."""
UserOrderCrud = CrudFactory(User, order_fields=[User.username]) UserOrderCrud = CrudFactory(User, order_fields=[User.username])
dep = UserOrderCrud.order_params() dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
with pytest.raises(InvalidOrderFieldError) as exc_info: with pytest.raises(InvalidOrderFieldError) as exc_info:
await dep(order_by="nonexistent", order="asc") await dep(page=1, items_per_page=20, order_by="nonexistent", order="asc")
assert exc_info.value.field == "nonexistent" assert exc_info.value.field == "nonexistent"
assert "username" in exc_info.value.valid_fields assert "username" in exc_info.value.valid_fields
@@ -1313,17 +1609,21 @@ class TestOrderParamsSchema:
async def test_multiple_fields_all_resolve(self): async def test_multiple_fields_all_resolve(self):
"""All configured fields resolve correctly via order_by.""" """All configured fields resolve correctly via order_by."""
UserOrderCrud = CrudFactory(User, order_fields=[User.username, User.email]) UserOrderCrud = CrudFactory(User, order_fields=[User.username, User.email])
dep = UserOrderCrud.order_params() dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
result_username = await dep(order_by="username", order="asc") result_username = await dep(
result_email = await dep(order_by="email", order="desc") page=1, items_per_page=20, order_by="username", order="asc"
assert isinstance(result_username, ColumnElement) )
assert isinstance(result_email, ColumnElement) result_email = await dep(
page=1, items_per_page=20, order_by="email", order="desc"
)
assert isinstance(result_username["order_by"], ColumnElement)
assert isinstance(result_email["order_by"], ColumnElement)
@pytest.mark.anyio @pytest.mark.anyio
async def test_order_params_integrates_with_get_multi( async def test_order_integrates_with_offset_paginate(
self, db_session: AsyncSession self, db_session: AsyncSession
): ):
"""order_params output is accepted by get_multi(order_by=...).""" """order in consolidated params is accepted by offset_paginate(order_by=...)."""
UserOrderCrud = CrudFactory(User, order_fields=[User.username]) UserOrderCrud = CrudFactory(User, order_fields=[User.username])
await UserCrud.create( await UserCrud.create(
db_session, UserCreate(username="charlie", email="c@test.com") db_session, UserCreate(username="charlie", email="c@test.com")
@@ -1332,37 +1632,43 @@ class TestOrderParamsSchema:
db_session, UserCreate(username="alice", email="a@test.com") db_session, UserCreate(username="alice", email="a@test.com")
) )
dep = UserOrderCrud.order_params() dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
order_by = await dep(order_by="username", order="asc") params = await dep(page=1, items_per_page=20, order_by="username", order="asc")
results = await UserOrderCrud.get_multi(db_session, order_by=order_by) result = await UserOrderCrud.offset_paginate(
db_session, **params, schema=UserRead
)
assert results[0].username == "alice" assert result.data[0].username == "alice"
assert results[1].username == "charlie" assert result.data[1].username == "charlie"
class TestOffsetParamsSchema: class TestOffsetPaginateParamsSchema:
"""Tests for AsyncCrud.offset_params().""" """Tests for AsyncCrud.offset_paginate_params()."""
def test_returns_page_and_items_per_page_params(self): def test_returns_page_and_items_per_page_params(self):
"""Returned dependency has page and items_per_page params only.""" """Returned dependency has page and items_per_page params."""
dep = RoleCrud.offset_params() dep = RoleCrud.offset_paginate_params(search=False, filter=False, order=False)
param_names = set(inspect.signature(dep).parameters) param_names = set(inspect.signature(dep).parameters)
assert param_names == {"page", "items_per_page"} assert param_names == {"page", "items_per_page"}
def test_dependency_name_includes_model_name(self): def test_dependency_name_includes_model_name(self):
"""Dependency function is named after the model.""" """Dependency function is named after the model."""
dep = RoleCrud.offset_params() dep = RoleCrud.offset_paginate_params(search=False, filter=False, order=False)
assert getattr(dep, "__name__") == "RoleOffsetParams" assert getattr(dep, "__name__") == "RoleOffsetPaginateParams"
def test_default_page_size_reflected_in_items_per_page_default(self): def test_default_page_size_reflected_in_items_per_page_default(self):
"""default_page_size is used as the default for items_per_page.""" """default_page_size is used as the default for items_per_page."""
dep = RoleCrud.offset_params(default_page_size=42) dep = RoleCrud.offset_paginate_params(
default_page_size=42, search=False, filter=False, order=False
)
sig = inspect.signature(dep) sig = inspect.signature(dep)
assert sig.parameters["items_per_page"].default.default == 42 assert sig.parameters["items_per_page"].default.default == 42
def test_max_page_size_reflected_in_items_per_page_le(self): def test_max_page_size_reflected_in_items_per_page_le(self):
"""max_page_size is used as le constraint on items_per_page.""" """max_page_size is used as le constraint on items_per_page."""
dep = RoleCrud.offset_params(max_page_size=50) dep = RoleCrud.offset_paginate_params(
max_page_size=50, search=False, filter=False, order=False
)
sig = inspect.signature(dep) sig = inspect.signature(dep)
le = next( le = next(
m.le m.le
@@ -1373,67 +1679,121 @@ class TestOffsetParamsSchema:
def test_include_total_not_a_query_param(self): def test_include_total_not_a_query_param(self):
"""include_total is not exposed as a query parameter.""" """include_total is not exposed as a query parameter."""
dep = RoleCrud.offset_params() dep = RoleCrud.offset_paginate_params(search=False, filter=False, order=False)
param_names = set(inspect.signature(dep).parameters) param_names = set(inspect.signature(dep).parameters)
assert "include_total" not in param_names assert "include_total" not in param_names
@pytest.mark.anyio @pytest.mark.anyio
async def test_include_total_true_forwarded_in_result(self): async def test_include_total_true_forwarded_in_result(self):
"""include_total=True factory arg appears in the resolved dict.""" """include_total=True factory arg appears in the resolved dict."""
result = await RoleCrud.offset_params(include_total=True)( result = await RoleCrud.offset_paginate_params(
page=1, items_per_page=10 include_total=True, search=False, filter=False, order=False
) )(page=1, items_per_page=10)
assert result["include_total"] is True assert result["include_total"] is True
@pytest.mark.anyio @pytest.mark.anyio
async def test_include_total_false_forwarded_in_result(self): async def test_include_total_false_forwarded_in_result(self):
"""include_total=False factory arg appears in the resolved dict.""" """include_total=False factory arg appears in the resolved dict."""
result = await RoleCrud.offset_params(include_total=False)( result = await RoleCrud.offset_paginate_params(
page=1, items_per_page=10 include_total=False, search=False, filter=False, order=False
) )(page=1, items_per_page=10)
assert result["include_total"] is False assert result["include_total"] is False
@pytest.mark.anyio @pytest.mark.anyio
async def test_awaiting_dep_returns_dict(self): async def test_awaiting_dep_returns_dict(self):
"""Awaiting the dependency returns a dict with page, items_per_page, include_total.""" """Awaiting the dependency returns a dict with page, items_per_page, include_total."""
dep = RoleCrud.offset_params(include_total=False) dep = RoleCrud.offset_paginate_params(
include_total=False, search=False, filter=False, order=False
)
result = await dep(page=2, items_per_page=10) result = await dep(page=2, items_per_page=10)
assert result == {"page": 2, "items_per_page": 10, "include_total": False} assert result == {"page": 2, "items_per_page": 10, "include_total": False}
@pytest.mark.anyio @pytest.mark.anyio
async def test_integrates_with_offset_paginate(self, db_session: AsyncSession): async def test_integrates_with_offset_paginate(self, db_session: AsyncSession):
"""offset_params output can be unpacked directly into offset_paginate.""" """offset_paginate_params output can be unpacked directly into offset_paginate."""
await RoleCrud.create(db_session, RoleCreate(name="admin")) await RoleCrud.create(db_session, RoleCreate(name="admin"))
dep = RoleCrud.offset_params() dep = RoleCrud.offset_paginate_params(search=False, filter=False, order=False)
params = await dep(page=1, items_per_page=10) params = await dep(page=1, items_per_page=10)
result = await RoleCrud.offset_paginate(db_session, **params, schema=RoleRead) result = await RoleCrud.offset_paginate(db_session, **params, schema=RoleRead)
assert result.pagination.page == 1 assert result.pagination.page == 1
assert result.pagination.items_per_page == 10 assert result.pagination.items_per_page == 10
def test_all_features_enabled(self):
"""With all features enabled, params include search, filter, and order."""
FullCrud = CrudFactory(
User,
searchable_fields=[User.username],
facet_fields=[User.email],
order_fields=[User.username],
)
dep = FullCrud.offset_paginate_params()
param_names = set(inspect.signature(dep).parameters)
assert param_names == {
"page",
"items_per_page",
"search",
"search_column",
"email",
"order_by",
"order",
}
class TestCursorParamsSchema: def test_search_enabled_but_no_searchable_fields(self):
"""Tests for AsyncCrud.cursor_params().""" """search=True with no searchable_fields silently skips search params."""
NoCrud = CrudFactory(Role)
NoCrud.searchable_fields = None
dep = NoCrud.offset_paginate_params(
search=True, filter=False, order=False, search_fields=None
)
param_names = set(inspect.signature(dep).parameters)
assert "search" not in param_names
assert "search_column" not in param_names
def test_filter_enabled_but_no_facet_fields(self):
"""filter=True with no facet_fields silently skips filter params."""
dep = RoleCrud.offset_paginate_params(search=False, filter=True, order=False)
param_names = set(inspect.signature(dep).parameters)
assert param_names == {"page", "items_per_page"}
def test_order_enabled_but_no_order_fields(self):
"""order=True with no order_fields silently skips order params."""
dep = RoleCrud.offset_paginate_params(search=False, filter=False, order=True)
param_names = set(inspect.signature(dep).parameters)
assert "order_by" not in param_names
assert "order" not in param_names
class TestCursorPaginateParamsSchema:
"""Tests for AsyncCrud.cursor_paginate_params()."""
def test_returns_cursor_and_items_per_page_params(self): def test_returns_cursor_and_items_per_page_params(self):
"""Returned dependency has cursor and items_per_page params.""" """Returned dependency has cursor and items_per_page params."""
dep = RoleCursorCrud.cursor_params() dep = RoleCursorCrud.cursor_paginate_params(
search=False, filter=False, order=False
)
param_names = set(inspect.signature(dep).parameters) param_names = set(inspect.signature(dep).parameters)
assert param_names == {"cursor", "items_per_page"} assert param_names == {"cursor", "items_per_page"}
def test_dependency_name_includes_model_name(self): def test_dependency_name_includes_model_name(self):
"""Dependency function is named after the model.""" """Dependency function is named after the model."""
dep = RoleCursorCrud.cursor_params() dep = RoleCursorCrud.cursor_paginate_params(
assert getattr(dep, "__name__") == "RoleCursorParams" search=False, filter=False, order=False
)
assert getattr(dep, "__name__") == "RoleCursorPaginateParams"
def test_default_page_size_reflected_in_items_per_page_default(self): def test_default_page_size_reflected_in_items_per_page_default(self):
"""default_page_size is used as the default for items_per_page.""" """default_page_size is used as the default for items_per_page."""
dep = RoleCursorCrud.cursor_params(default_page_size=15) dep = RoleCursorCrud.cursor_paginate_params(
default_page_size=15, search=False, filter=False, order=False
)
sig = inspect.signature(dep) sig = inspect.signature(dep)
assert sig.parameters["items_per_page"].default.default == 15 assert sig.parameters["items_per_page"].default.default == 15
def test_max_page_size_reflected_in_items_per_page_le(self): def test_max_page_size_reflected_in_items_per_page_le(self):
"""max_page_size is used as le constraint on items_per_page.""" """max_page_size is used as le constraint on items_per_page."""
dep = RoleCursorCrud.cursor_params(max_page_size=75) dep = RoleCursorCrud.cursor_paginate_params(
max_page_size=75, search=False, filter=False, order=False
)
sig = inspect.signature(dep) sig = inspect.signature(dep)
le = next( le = next(
m.le m.le
@@ -1444,22 +1804,28 @@ class TestCursorParamsSchema:
def test_cursor_defaults_to_none(self): def test_cursor_defaults_to_none(self):
"""cursor defaults to None.""" """cursor defaults to None."""
dep = RoleCursorCrud.cursor_params() dep = RoleCursorCrud.cursor_paginate_params(
search=False, filter=False, order=False
)
sig = inspect.signature(dep) sig = inspect.signature(dep)
assert sig.parameters["cursor"].default.default is None assert sig.parameters["cursor"].default.default is None
@pytest.mark.anyio @pytest.mark.anyio
async def test_awaiting_dep_returns_dict(self): async def test_awaiting_dep_returns_dict(self):
"""Awaiting the dependency returns a dict with cursor and items_per_page.""" """Awaiting the dependency returns a dict with cursor and items_per_page."""
dep = RoleCursorCrud.cursor_params() dep = RoleCursorCrud.cursor_paginate_params(
search=False, filter=False, order=False
)
result = await dep(cursor=None, items_per_page=5) result = await dep(cursor=None, items_per_page=5)
assert result == {"cursor": None, "items_per_page": 5} assert result == {"cursor": None, "items_per_page": 5}
@pytest.mark.anyio @pytest.mark.anyio
async def test_integrates_with_cursor_paginate(self, db_session: AsyncSession): async def test_integrates_with_cursor_paginate(self, db_session: AsyncSession):
"""cursor_params output can be unpacked directly into cursor_paginate.""" """cursor_paginate_params output can be unpacked directly into cursor_paginate."""
await RoleCrud.create(db_session, RoleCreate(name="admin")) await RoleCrud.create(db_session, RoleCreate(name="admin"))
dep = RoleCursorCrud.cursor_params() dep = RoleCursorCrud.cursor_paginate_params(
search=False, filter=False, order=False
)
params = await dep(cursor=None, items_per_page=10) params = await dep(cursor=None, items_per_page=10)
result = await RoleCursorCrud.cursor_paginate( result = await RoleCursorCrud.cursor_paginate(
db_session, **params, schema=RoleRead db_session, **params, schema=RoleRead
@@ -1472,13 +1838,13 @@ class TestPaginateParamsSchema:
def test_returns_all_params(self): def test_returns_all_params(self):
"""Returned dependency has pagination_type, page, cursor, items_per_page (no include_total).""" """Returned dependency has pagination_type, page, cursor, items_per_page (no include_total)."""
dep = RoleCursorCrud.paginate_params() dep = RoleCursorCrud.paginate_params(search=False, filter=False, order=False)
param_names = set(inspect.signature(dep).parameters) param_names = set(inspect.signature(dep).parameters)
assert param_names == {"pagination_type", "page", "cursor", "items_per_page"} assert param_names == {"pagination_type", "page", "cursor", "items_per_page"}
def test_dependency_name_includes_model_name(self): def test_dependency_name_includes_model_name(self):
"""Dependency function is named after the model.""" """Dependency function is named after the model."""
dep = RoleCursorCrud.paginate_params() dep = RoleCursorCrud.paginate_params(search=False, filter=False, order=False)
assert getattr(dep, "__name__") == "RolePaginateParams" assert getattr(dep, "__name__") == "RolePaginateParams"
def test_default_pagination_type(self): def test_default_pagination_type(self):
@@ -1486,7 +1852,10 @@ class TestPaginateParamsSchema:
from fastapi_toolsets.schemas import PaginationType from fastapi_toolsets.schemas import PaginationType
dep = RoleCursorCrud.paginate_params( dep = RoleCursorCrud.paginate_params(
default_pagination_type=PaginationType.CURSOR default_pagination_type=PaginationType.CURSOR,
search=False,
filter=False,
order=False,
) )
sig = inspect.signature(dep) sig = inspect.signature(dep)
assert ( assert (
@@ -1495,13 +1864,17 @@ class TestPaginateParamsSchema:
def test_default_page_size(self): def test_default_page_size(self):
"""default_page_size is reflected in items_per_page default.""" """default_page_size is reflected in items_per_page default."""
dep = RoleCursorCrud.paginate_params(default_page_size=15) dep = RoleCursorCrud.paginate_params(
default_page_size=15, search=False, filter=False, order=False
)
sig = inspect.signature(dep) sig = inspect.signature(dep)
assert sig.parameters["items_per_page"].default.default == 15 assert sig.parameters["items_per_page"].default.default == 15
def test_max_page_size_le_constraint(self): def test_max_page_size_le_constraint(self):
"""max_page_size is used as le constraint on items_per_page.""" """max_page_size is used as le constraint on items_per_page."""
dep = RoleCursorCrud.paginate_params(max_page_size=60) dep = RoleCursorCrud.paginate_params(
max_page_size=60, search=False, filter=False, order=False
)
sig = inspect.signature(dep) sig = inspect.signature(dep)
le = next( le = next(
m.le m.le
@@ -1512,19 +1885,23 @@ class TestPaginateParamsSchema:
def test_include_total_not_a_query_param(self): def test_include_total_not_a_query_param(self):
"""include_total is not exposed as a query parameter.""" """include_total is not exposed as a query parameter."""
dep = RoleCursorCrud.paginate_params() dep = RoleCursorCrud.paginate_params(search=False, filter=False, order=False)
assert "include_total" not in set(inspect.signature(dep).parameters) assert "include_total" not in set(inspect.signature(dep).parameters)
@pytest.mark.anyio @pytest.mark.anyio
async def test_include_total_forwarded_in_result(self): async def test_include_total_forwarded_in_result(self):
"""include_total factory arg appears in the resolved dict.""" """include_total factory arg appears in the resolved dict."""
result_true = await RoleCursorCrud.paginate_params(include_total=True)( result_true = await RoleCursorCrud.paginate_params(
include_total=True, search=False, filter=False, order=False
)(
pagination_type=PaginationType.OFFSET, pagination_type=PaginationType.OFFSET,
page=1, page=1,
cursor=None, cursor=None,
items_per_page=10, items_per_page=10,
) )
result_false = await RoleCursorCrud.paginate_params(include_total=False)( result_false = await RoleCursorCrud.paginate_params(
include_total=False, search=False, filter=False, order=False
)(
pagination_type=PaginationType.OFFSET, pagination_type=PaginationType.OFFSET,
page=1, page=1,
cursor=None, cursor=None,
@@ -1536,7 +1913,7 @@ class TestPaginateParamsSchema:
@pytest.mark.anyio @pytest.mark.anyio
async def test_awaiting_dep_returns_dict(self): async def test_awaiting_dep_returns_dict(self):
"""Awaiting the dependency returns a dict with all pagination keys.""" """Awaiting the dependency returns a dict with all pagination keys."""
dep = RoleCursorCrud.paginate_params() dep = RoleCursorCrud.paginate_params(search=False, filter=False, order=False)
result = await dep( result = await dep(
pagination_type=PaginationType.OFFSET, pagination_type=PaginationType.OFFSET,
page=2, page=2,
@@ -1557,7 +1934,9 @@ class TestPaginateParamsSchema:
from fastapi_toolsets.schemas import OffsetPagination from fastapi_toolsets.schemas import OffsetPagination
await RoleCrud.create(db_session, RoleCreate(name="admin")) await RoleCrud.create(db_session, RoleCreate(name="admin"))
params = await RoleCursorCrud.paginate_params()( params = await RoleCursorCrud.paginate_params(
search=False, filter=False, order=False
)(
pagination_type=PaginationType.OFFSET, pagination_type=PaginationType.OFFSET,
page=1, page=1,
cursor=None, cursor=None,
@@ -1572,7 +1951,9 @@ class TestPaginateParamsSchema:
from fastapi_toolsets.schemas import CursorPagination from fastapi_toolsets.schemas import CursorPagination
await RoleCrud.create(db_session, RoleCreate(name="admin")) await RoleCrud.create(db_session, RoleCreate(name="admin"))
params = await RoleCursorCrud.paginate_params()( params = await RoleCursorCrud.paginate_params(
search=False, filter=False, order=False
)(
pagination_type=PaginationType.CURSOR, pagination_type=PaginationType.CURSOR,
page=1, page=1,
cursor=None, cursor=None,

View File

@@ -374,19 +374,6 @@ class TestCreateDbSession:
pass 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."""

116
uv.lock generated
View File

@@ -235,7 +235,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.135.1" version = "0.135.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@@ -244,14 +244,14 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" },
] ]
[[package]] [[package]]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "2.4.3" version = "3.0.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "asyncpg" }, { name = "asyncpg" },
@@ -894,15 +894,15 @@ wheels = [
[[package]] [[package]]
name = "pymdown-extensions" name = "pymdown-extensions"
version = "10.21" version = "10.21.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markdown" }, { name = "markdown" },
{ name = "pyyaml" }, { name = "pyyaml" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" } sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" }, { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" },
] ]
[[package]] [[package]]
@@ -1064,27 +1064,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.7" version = "0.15.8"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
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" } sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
wheels = [ wheels = [
{ 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/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
{ 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/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
{ 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/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
{ 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/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
{ 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/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
{ 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/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
{ 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/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
{ 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/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
{ 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/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
{ 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/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
{ 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/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
{ 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/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
{ 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/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
{ 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/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
{ 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/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
{ 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/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
{ 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" }, { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
] ]
[[package]] [[package]]
@@ -1228,26 +1228,26 @@ wheels = [
[[package]] [[package]]
name = "ty" name = "ty"
version = "0.0.25" version = "0.0.27"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
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" } sdist = { url = "https://files.pythonhosted.org/packages/f4/de/e5cf1f151cf52fe1189e42d03d90909d7d1354fdc0c1847cbb63a0baa3da/ty-0.0.27.tar.gz", hash = "sha256:d7a8de3421d92420b40c94fe7e7d4816037560621903964dd035cf9bd0204a73", size = 5424130, upload-time = "2026-03-31T19:07:20.806Z" }
wheels = [ wheels = [
{ 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/fa/20/2a9ea661758bd67f2bfd54ce9daacb5a26c56c5f8b49fbd9a43b365a8a7d/ty-0.0.27-py3-none-linux_armv6l.whl", hash = "sha256:eb14456b8611c9e8287aa9b633f4d2a0d9f3082a31796969e0b50bdda8930281", size = 10571211, upload-time = "2026-03-31T19:07:23.28Z" },
{ 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/da/b2/8887a51f705d075ddbe78ae7f0d4755ef48d0a90235f67aee289e9cee950/ty-0.0.27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:02e662184703db7586118df611cf24a000d35dae38d950053d1dd7b6736fd2c4", size = 10427576, upload-time = "2026-03-31T19:07:15.499Z" },
{ 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/1d/c3/79d88163f508fb709ce19bc0b0a66c7c64b53d372d4caa56172c3d9b3ae8/ty-0.0.27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be5fc2899441f7f8f7ef40f9ffd006075a5ff6b06c44e8d2aa30e1b900c12f51", size = 9870359, upload-time = "2026-03-31T19:07:36.852Z" },
{ 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/dc/4d/ed1b0db0e1e46b5ed4976bbfe0d1825faf003b4e3774ef28c785ed73e4bb/ty-0.0.27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30231e652b14742a76b64755e54bf0cb1cd4c128bcaf625222e0ca92a2094887", size = 10380488, upload-time = "2026-03-31T19:07:31.268Z" },
{ 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/b1/f2/20372f6d510b01570028433064880adec2f8abe68bf0c4603be61a560bef/ty-0.0.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a119b1168f64261b3205a37e40b5b6c4aac8fd58e4587988f4e4b22c3c79847", size = 10390248, upload-time = "2026-03-31T19:07:28.345Z" },
{ 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/45/4b/46b31a7311306be1a560f7f20fdc37b5bf718787f60626cd265d9b637554/ty-0.0.27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e38f4e187b6975d2cbebf0f1eb1221f8f64f6e509bad14d7bb2a91afc97e4956", size = 10878479, upload-time = "2026-03-31T19:07:39.393Z" },
{ 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/42/ba/5231a2a1fb1cebe053a25de8fded95e1a30a1e77d3628a9e58487297bafc/ty-0.0.27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a07b1a8fbb23844f6d22091275430d9ac617175f34aa99159b268193de210389", size = 11461232, upload-time = "2026-03-31T19:07:02.518Z" },
{ 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/c3/37/558abab3e1f6670493524f61280b4dfcc3219555f13889223e733381dfab/ty-0.0.27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d3ec4033031f240836bb0337274bac5c49dde312c7c6d7575451ed719bf8ffa3", size = 11133002, upload-time = "2026-03-31T19:07:18.371Z" },
{ 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/32/38/188c14a57f52160407ce62c6abb556011718fd0bcbe1dca690529ce84c46/ty-0.0.27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:924a8849afd500d260bf5b7296165a05b7424fbb6b19113f30f3b999d682873f", size = 10986624, upload-time = "2026-03-31T19:07:13.066Z" },
{ 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/9f/f1/667a71393f47d2cd6ba9ed07541b8df3eb63aab1f2ee658e77d91b8362fa/ty-0.0.27-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d8270026c07e7423a1b3a3fd065b46ed1478748f0662518b523b57744f3fa025", size = 10366721, upload-time = "2026-03-31T19:07:00.131Z" },
{ 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/8b/aa/8edafe41be898bda774249abc5be6edd733e53fb1777d59ea9331e38537d/ty-0.0.27-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e26e9735d3bdfd95d881111ad1cf570eab8188d8c3be36d6bcaad044d38984d8", size = 10412239, upload-time = "2026-03-31T19:07:05.297Z" },
{ 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/53/ff/8bafaed4a18d38264f46bdfc427de7ea2974cf9064e4e0bdb1b6e6c724e3/ty-0.0.27-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7c09cc9a699810609acc0090af8d0db68adaee6e60a7c3e05ab80cc954a83db7", size = 10573507, upload-time = "2026-03-31T19:06:57.064Z" },
{ 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/16/2e/63a8284a2fefd08ab56ecbad0fde7dd4b2d4045a31cf24c1d1fcd9643227/ty-0.0.27-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d3e02853bb037221a456e034b1898aaa573e6374fbb53884e33cb7513ccb85a", size = 11090233, upload-time = "2026-03-31T19:07:34.139Z" },
{ 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/14/d3/d6fa1cafdfa2b34dbfa304fc6833af8e1669fc34e24d214fa76d2a2e5a25/ty-0.0.27-py3-none-win32.whl", hash = "sha256:34e7377f2047c14dbbb7bf5322e84114db7a5f2cb470db6bee63f8f3550cfc1e", size = 9984415, upload-time = "2026-03-31T19:07:07.98Z" },
{ 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/85/e6/dd4e27da9632b3472d5711ca49dbd3709dbd3e8c73f3af6db9c254235ca9/ty-0.0.27-py3-none-win_amd64.whl", hash = "sha256:3f7e4145aad8b815ed69b324c93b5b773eb864dda366ca16ab8693ff88ce6f36", size = 10961535, upload-time = "2026-03-31T19:07:10.566Z" },
{ 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" }, { url = "https://files.pythonhosted.org/packages/0e/1a/824b3496d66852ed7d5d68d9787711131552b68dce8835ce9410db32e618/ty-0.0.27-py3-none-win_arm64.whl", hash = "sha256:95bf8d01eb96bb2ba3ffc39faff19da595176448e80871a7b362f4d2de58476c", size = 10376689, upload-time = "2026-03-31T19:07:25.732Z" },
] ]
[[package]] [[package]]
@@ -1324,7 +1324,7 @@ wheels = [
[[package]] [[package]]
name = "zensical" name = "zensical"
version = "0.0.30" version = "0.0.31"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@@ -1334,18 +1334,18 @@ dependencies = [
{ name = "pymdown-extensions" }, { name = "pymdown-extensions" },
{ name = "pyyaml" }, { name = "pyyaml" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/1d/53/5e551f8912718816733a75adcb53a0787b2d2edca5869c156325aaf82e24/zensical-0.0.30.tar.gz", hash = "sha256:408b531683f6bcb6cc5ab928146d2c68afbc16fac4eda87ae3dd20af1498180f", size = 3844287, upload-time = "2026-03-28T17:55:52.836Z" } sdist = { url = "https://files.pythonhosted.org/packages/d5/1a/9b6f5285c5aef648db38f9132f49a7059bd2c9d748f68ef0c52ed8afcff3/zensical-0.0.31.tar.gz", hash = "sha256:9c12f07bde70c4bfdb13d6cae1bedf8d18064d257a6e81128a152502b28a8fc3", size = 3891758, upload-time = "2026-04-01T11:30:21.88Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/e3/ac0eb77a8a7f793613813de68bde26776d0da68d8041fa9eb8d0b986a449/zensical-0.0.30-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b67fca8bfcd71c94b331045a591bf6e24fe123a66fba94587aa3379faf521a16", size = 12313786, upload-time = "2026-03-28T17:55:18.839Z" }, { url = "https://files.pythonhosted.org/packages/c2/db/cc4e555d2e816f2d91304ff969d62cc3a401ee477dbb7c720b874bec67d6/zensical-0.0.31-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b489936d670733dd204f16b689a2acc0e45b69e42cc4901f5131ae57658b8fbc", size = 12419980, upload-time = "2026-04-01T11:29:44.01Z" },
{ url = "https://files.pythonhosted.org/packages/a5/6a/73e461dfa27d3bc415e48396f83a3287b43df2fd3361e25146bc86360aab/zensical-0.0.30-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8ceadfece1153edc26506e8ddf68d9818afe8517cf3bcdb6bfe4cb2793ae247b", size = 12186136, upload-time = "2026-03-28T17:55:21.836Z" }, { url = "https://files.pythonhosted.org/packages/e7/c1/6789f73164c7f5821f5defb8a80b1dba8d5af24bdec7db36876793c5afd9/zensical-0.0.31-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d9f678efc0d9918e45eeb8bc62847b2cce23db7393c8c59c1be6d1c064bbaacd", size = 12292301, upload-time = "2026-04-01T11:29:47.277Z" },
{ url = "https://files.pythonhosted.org/packages/a3/bc/9022156b4c28c1b95209acb64319b1e5cd0af2e97035bdd461e58408cb46/zensical-0.0.30-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e100b2b654337ac5306ba12818f3c5336c66d0d34c593ef05e316c124a5819cb", size = 12556115, upload-time = "2026-03-28T17:55:24.849Z" }, { url = "https://files.pythonhosted.org/packages/4f/9a/6a83ad209081a953e0285d5056e5452c4fbcabd2f104f3797d53e4bdd96f/zensical-0.0.31-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb2b50ecf674997f818e53f12f2a67875a21b0c79ed74c151dfaef2f1475e5bf", size = 12661472, upload-time = "2026-04-01T11:29:50.706Z" },
{ url = "https://files.pythonhosted.org/packages/0b/29/9e8f5bd6d33b35f4c368ae8b13d431dc42b2de17ea6eccbd71d48122eba6/zensical-0.0.30-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdf641ffddaf21c6971b91a4426b81cd76271c5b1adb7176afcce3f1508328b1", size = 12498121, upload-time = "2026-03-28T17:55:27.637Z" }, { url = "https://files.pythonhosted.org/packages/9c/4a/a82f5c81893b7a607cf9d439b75c3c3894b4ef4d3e92d5d818b4fa5c6f23/zensical-0.0.31-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6fb5c634fe88254770a2d4db5c05b06f1c3ee5e29d2ae3e7efdae8905e435b1d", size = 12603784, upload-time = "2026-04-01T11:29:53.623Z" },
{ url = "https://files.pythonhosted.org/packages/c4/e1/b8dfa0769050e62cd731358145fdeb67af35e322197bd7e7727250596e7b/zensical-0.0.30-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fd909a0c2116e26190c7f3ec4fb55837c417b7a8d99ebf4f3deb26b07b97e49", size = 12854142, upload-time = "2026-03-28T17:55:30.54Z" }, { url = "https://files.pythonhosted.org/packages/f7/1c/79c198628b8e006be32dfb1c5b73561757a349a6cf3069600a67ffa62495/zensical-0.0.31-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e64630552793274db1ec66c971e49a15ad351536d5d12de67ec6da7358ac50", size = 12959832, upload-time = "2026-04-01T11:29:56.736Z" },
{ url = "https://files.pythonhosted.org/packages/04/11/62a36cfb81522b6108db8f9e96d36da8cccb306b02c15ad19e1b333fa7c8/zensical-0.0.30-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16fd2da09fe4e5cbec2ca74f31abc70f32f7330d56593b647e0a114bb329171a", size = 12598341, upload-time = "2026-03-28T17:55:32.988Z" }, { url = "https://files.pythonhosted.org/packages/db/9d/45839d9ca0f69622e8a3b944f2d8d7f7d2b7c2da78201079c4feb275feb6/zensical-0.0.31-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:738a2fd5832e3b3c10ff642eebaf89c89ca1d28e4451dad0f36fdac53c415577", size = 12704024, upload-time = "2026-04-01T11:29:59.836Z" },
{ url = "https://files.pythonhosted.org/packages/a7/a4/8c7a6725fb226aa71d19209403d974e45f39d757e725f9558c6ed8d350a5/zensical-0.0.30-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:896b36eaef7fed5f8fc6f2c8264b2751aad63c2d66d3d8650e38481b6b4f6f7b", size = 12732307, upload-time = "2026-03-28T17:55:35.618Z" }, { url = "https://files.pythonhosted.org/packages/df/5f/451d7f4d94092bc38bd8d514826fb7b0329c188db506795b1d20bd07d517/zensical-0.0.31-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bd601f6132e285ef6c3e4c3852be2094fc0473295a8080003db76a79760f84fb", size = 12837788, upload-time = "2026-04-01T11:30:03.048Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a1/7858fb3f6ac67d7d24a8acbe834cbe26851d6bd151ece6fba3fc88b0f878/zensical-0.0.30-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:a1f515ec67a0d0250e53846327bf0c69635a1f39749da3b04feb68431188d3c6", size = 12770962, upload-time = "2026-03-28T17:55:38.627Z" }, { url = "https://files.pythonhosted.org/packages/d8/39/390a8fc384fb174ebd4450343a0aa2877b3a31ddcedf5ef0b8d26944e12c/zensical-0.0.31-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:dc3b6a9dfb5903c0aa779ef65cd6185add2b8aa1db237be840874b8c9db761b8", size = 12876822, upload-time = "2026-04-01T11:30:06.418Z" },
{ url = "https://files.pythonhosted.org/packages/49/b7/228298112a69d0b74e6e93041bffcf1fc96d03cf252be94a354f277d4789/zensical-0.0.30-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ce33d1002438838a35fa43358a1f43d74f874586596d3d116999d3756cded00e", size = 12919256, upload-time = "2026-03-28T17:55:41.413Z" }, { url = "https://files.pythonhosted.org/packages/d5/60/640da2f095782cf38974cd851fb7afa62651d09a36543a1d8942b31aabdc/zensical-0.0.31-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ddd4321b275e82c4897aa45b05038ce204b88fb311ad55f8c2af572173a9b56c", size = 13024036, upload-time = "2026-04-01T11:30:09.501Z" },
{ url = "https://files.pythonhosted.org/packages/de/c7/5b4ea036f7f7d84abf907f7f7a3e8420b054c89279c5273ca248d3bc9f48/zensical-0.0.30-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:029dad561568f4ae3056dde16a81012efd92c426d4eb7101f960f448c1168196", size = 12869760, upload-time = "2026-03-28T17:55:44.474Z" }, { url = "https://files.pythonhosted.org/packages/3f/06/0564377cbfccea3653254adfa851c1b20d1696e4b16770c7b2e1dd1ef1d7/zensical-0.0.31-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:147ab4bc17f3088f703aa6c4b9c416411f4ea8ca64d26f6586beae49d97fd3c7", size = 12975505, upload-time = "2026-04-01T11:30:12.268Z" },
{ url = "https://files.pythonhosted.org/packages/36/b4/77bef2132e43108db718ae014a5961fc511e88fc446c11f1c3483def429e/zensical-0.0.30-cp310-abi3-win32.whl", hash = "sha256:0105672850f053c326fba9fdd95adf60e9f90308f8cc1c08e3a00e15a8d5e90f", size = 11905658, upload-time = "2026-03-28T17:55:47.416Z" }, { url = "https://files.pythonhosted.org/packages/35/4b/b8a0c4e5937cb05882dcce667798403e00897135080a69f92363e5e3ff9f/zensical-0.0.31-cp310-abi3-win32.whl", hash = "sha256:03fa11e629a308507693489541f43e751697784e94365e7435b02104aefd1c2c", size = 12011233, upload-time = "2026-04-01T11:30:15.496Z" },
{ url = "https://files.pythonhosted.org/packages/a1/59/23b6c7ff062e2b299cc60e333095e853f9d38d1b5abe743c7b94c4ac432c/zensical-0.0.30-cp310-abi3-win_amd64.whl", hash = "sha256:b879dbf4c69d3ea41694bae33e1b948847e635dcbcd6ec8c522920833379dd48", size = 12101867, upload-time = "2026-03-28T17:55:50.083Z" }, { url = "https://files.pythonhosted.org/packages/3e/99/0eacdb466d344c0c86596932201268517be42f3e0bb6c78b2b0cd84c55f6/zensical-0.0.31-cp310-abi3-win_amd64.whl", hash = "sha256:d6621d4bb46af4143560045d4a18c8c76302db56bf1dbb6e2ce107d7fb643e09", size = 12207545, upload-time = "2026-04-01T11:30:19.054Z" },
] ]