mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 06:36:26 +02:00
Compare commits
3 Commits
v3.0.3
...
06cec296a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
06cec296a5
|
|||
|
ea9c31956f
|
|||
|
181ca35137
|
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -6,9 +6,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
@@ -96,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@v6
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
report_type: coverage
|
report_type: coverage
|
||||||
@@ -105,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@v6
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
report_type: test_results
|
report_type: test_results
|
||||||
|
|||||||
21
.github/workflows/docs.yml
vendored
21
.github/workflows/docs.yml
vendored
@@ -34,17 +34,26 @@ jobs:
|
|||||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||||
DEPLOY_VERSION="v$(echo "$VERSION" | cut -d. -f1-2)"
|
DEPLOY_VERSION="v$(echo "$VERSION" | cut -d. -f1-2)"
|
||||||
|
|
||||||
# On new major: keep only the latest feature version of the previous major
|
# On new major: consolidate previous major's feature versions into vX
|
||||||
PREV_MAJOR=$((MAJOR - 1))
|
PREV_MAJOR=$((MAJOR - 1))
|
||||||
OLD_FEATURE_VERSIONS=$(uv run mike list 2>/dev/null | grep -oE "^v${PREV_MAJOR}\.[0-9]+" || true)
|
OLD_FEATURE_VERSIONS=$(uv run mike list 2>/dev/null | grep -oE "^v${PREV_MAJOR}\.[0-9]+" || true)
|
||||||
|
|
||||||
if [ -n "$OLD_FEATURE_VERSIONS" ]; then
|
if [ -n "$OLD_FEATURE_VERSIONS" ]; then
|
||||||
LATEST_PREV=$(echo "$OLD_FEATURE_VERSIONS" | sort -t. -k2 -n | tail -1)
|
LATEST_PREV_TAG=$(git tag -l "v${PREV_MAJOR}.*" | sort -V | tail -1)
|
||||||
echo "$OLD_FEATURE_VERSIONS" | while read -r OLD_V; do
|
|
||||||
if [ "$OLD_V" != "$LATEST_PREV" ]; then
|
if [ -n "$LATEST_PREV_TAG" ]; then
|
||||||
echo "Deleting $OLD_V"
|
git checkout "$LATEST_PREV_TAG" -- docs/ src/ zensical.toml
|
||||||
uv run mike delete "$OLD_V"
|
if ! grep -q '\[project\.extra\.version\]' zensical.toml; then
|
||||||
|
printf '\n[project.extra.version]\nprovider = "mike"\ndefault = "stable"\nalias = true\n' >> zensical.toml
|
||||||
fi
|
fi
|
||||||
|
uv run mike deploy "v${PREV_MAJOR}"
|
||||||
|
git checkout HEAD -- docs/ src/ zensical.toml
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete old feature versions
|
||||||
|
echo "$OLD_FEATURE_VERSIONS" | while read -r OLD_V; do
|
||||||
|
echo "Deleting $OLD_V"
|
||||||
|
uv run mike delete "$OLD_V"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -43,16 +43,16 @@ Declare `searchable_fields`, `facet_fields`, and `order_fields` once on [`CrudFa
|
|||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
```python title="routes.py:1:16"
|
```python title="routes.py:1:17"
|
||||||
--8<-- "docs_src/examples/pagination_search/routes.py:1:16"
|
--8<-- "docs_src/examples/pagination_search/routes.py:1:17"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Offset pagination
|
### Offset pagination
|
||||||
|
|
||||||
Best for admin panels or any UI that needs a total item count and numbered pages.
|
Best for admin panels or any UI that needs a total item count and numbered pages.
|
||||||
|
|
||||||
```python title="routes.py:19:37"
|
```python title="routes.py:20:40"
|
||||||
--8<-- "docs_src/examples/pagination_search/routes.py:19:37"
|
--8<-- "docs_src/examples/pagination_search/routes.py:20:40"
|
||||||
```
|
```
|
||||||
|
|
||||||
**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:40:58"
|
```python title="routes.py:43:63"
|
||||||
--8<-- "docs_src/examples/pagination_search/routes.py:40:58"
|
--8<-- "docs_src/examples/pagination_search/routes.py:43:63"
|
||||||
```
|
```
|
||||||
|
|
||||||
**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:61:79"
|
```python title="routes.py:66:90"
|
||||||
--8<-- "docs_src/examples/pagination_search/routes.py:61:79"
|
--8<-- "docs_src/examples/pagination_search/routes.py:66:90"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Offset request** (default)
|
**Offset request** (default)
|
||||||
|
|||||||
@@ -28,69 +28,6 @@ In `v2`, relationship facet fields used only the terminal column key (e.g. `"nam
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `*_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`.
|
||||||
|
|||||||
@@ -159,15 +159,18 @@ 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,
|
||||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
items_per_page: int = 50,
|
||||||
|
page: int = 1,
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
return await UserCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
page=page,
|
||||||
|
schema=UserRead,
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) method returns 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):
|
||||||
@@ -191,13 +194,32 @@ 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 `offset_paginate_params()` 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 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 get_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(include_total=False))],
|
params: Annotated[dict, Depends(UserCrud.offset_params(default_page_size=20, max_page_size=100))],
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||||
```
|
```
|
||||||
@@ -208,9 +230,15 @@ async def get_users(
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
|
cursor: str | None = None,
|
||||||
|
items_per_page: int = 20,
|
||||||
) -> CursorPaginatedResponse[UserRead]:
|
) -> CursorPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
return await UserCrud.cursor_paginate(
|
||||||
|
session=session,
|
||||||
|
cursor=cursor,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
schema=UserRead,
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
The [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) method returns a [`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):
|
||||||
@@ -263,6 +291,24 @@ 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`"
|
||||||
@@ -270,14 +316,25 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
|||||||
[`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,
|
||||||
params: Annotated[dict, Depends(UserCrud.paginate_params())],
|
pagination_type: PaginationType = PaginationType.OFFSET,
|
||||||
|
page: int = Query(1, ge=1, description="Current page (offset only)"),
|
||||||
|
cursor: str | None = Query(None, description="Cursor token (cursor only)"),
|
||||||
|
items_per_page: int = Query(20, ge=1, le=100),
|
||||||
) -> PaginatedResponse[UserRead]:
|
) -> PaginatedResponse[UserRead]:
|
||||||
return await UserCrud.paginate(session, **params, schema=UserRead)
|
return await UserCrud.paginate(
|
||||||
|
session,
|
||||||
|
pagination_type=pagination_type,
|
||||||
|
page=page,
|
||||||
|
cursor=cursor,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
schema=UserRead,
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -285,6 +342,25 @@ 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).
|
||||||
@@ -324,63 +400,49 @@ result = await UserCrud.offset_paginate(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or via the dependency to narrow which fields are exposed as query parameters:
|
|
||||||
|
|
||||||
```python
|
|
||||||
params = UserCrud.offset_paginate_params(search_fields=[Post.title])
|
|
||||||
```
|
|
||||||
|
|
||||||
This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate):
|
This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_users(
|
async def get_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
items_per_page: int = 50,
|
||||||
|
page: int = 1,
|
||||||
|
search: str | None = None,
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
return await UserCrud.offset_paginate(
|
||||||
|
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,
|
||||||
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
|
cursor: str | None = None,
|
||||||
|
items_per_page: int = 50,
|
||||||
|
search: str | None = None,
|
||||||
) -> CursorPaginatedResponse[UserRead]:
|
) -> CursorPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
return await UserCrud.cursor_paginate(
|
||||||
|
session=session,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
cursor=cursor,
|
||||||
|
search=search,
|
||||||
|
schema=UserRead,
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
The dependency adds two query parameters to the endpoint:
|
|
||||||
|
|
||||||
| Parameter | Type |
|
|
||||||
| --------------- | ------------- |
|
|
||||||
| `search` | `str \| null` |
|
|
||||||
| `search_column` | `str \| null` |
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /posts?search=hello → search all configured columns
|
|
||||||
GET /posts?search=hello&search_column=title → search only Post.title
|
|
||||||
```
|
|
||||||
|
|
||||||
The available search column keys are returned in the `search_columns` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse). Use them to populate a column picker in the UI, or to validate `search_column` values on the client side:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"data": ["..."],
|
|
||||||
"pagination": { "..." },
|
|
||||||
"search_columns": ["content", "author__username", "title"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! info "Key format uses `__` as a separator for relationship chains."
|
|
||||||
A direct column `Post.title` produces `"title"`. A relationship tuple `(Post.author, User.username)` produces `"author__username"`. An unknown `search_column` value raises [`InvalidSearchColumnError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidSearchColumnError) (HTTP 422).
|
|
||||||
|
|
||||||
### Faceted search
|
### Faceted search
|
||||||
|
|
||||||
!!! info "Added in `v1.2`"
|
!!! info "Added in `v1.2`"
|
||||||
|
|
||||||
Declare `facet_fields` on the CRUD class to return distinct column values alongside paginated results. This is useful for populating filter dropdowns or building faceted search UIs. Relationship traversal is supported via tuples, using the same syntax as `searchable_fields`:
|
Declare `facet_fields` on the CRUD class to return distinct column values alongside paginated results. This is useful for populating filter dropdowns or building faceted search UIs.
|
||||||
|
|
||||||
|
Facet fields use the same syntax as `searchable_fields` — direct columns or relationship tuples:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
UserCrud = CrudFactory(
|
UserCrud = CrudFactory(
|
||||||
@@ -402,47 +464,7 @@ result = await UserCrud.offset_paginate(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or via the dependency to narrow which fields are exposed as query parameters:
|
The distinct values are returned in the `filter_attributes` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse):
|
||||||
|
|
||||||
```python
|
|
||||||
params = UserCrud.offset_paginate_params(facet_fields=[User.country])
|
|
||||||
```
|
|
||||||
|
|
||||||
Facet filtering is built into the consolidated params dependencies. When `filter=True` (the default), each facet field is exposed as a query parameter and values are collected into `filter_by` automatically:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import Depends
|
|
||||||
|
|
||||||
@router.get("", response_model_exclude_none=True)
|
|
||||||
async def list_users(
|
|
||||||
session: SessionDep,
|
|
||||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
|
||||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
@router.get("", response_model_exclude_none=True)
|
|
||||||
async def list_users(
|
|
||||||
session: SessionDep,
|
|
||||||
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
|
|
||||||
) -> CursorPaginatedResponse[UserRead]:
|
|
||||||
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
Both single-value and multi-value query parameters work:
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /users?status=active → filter_by={"status": ["active"]}
|
|
||||||
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
|
|
||||||
GET /users?role__name=admin&role__name=editor → filter_by={"role__name": ["admin", "editor"]} (IN clause)
|
|
||||||
```
|
|
||||||
|
|
||||||
`filter_by` and `filters` can be combined — both are applied with AND logic.
|
|
||||||
|
|
||||||
The distinct values for each facet field are returned in the `filter_attributes` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse). Use them to populate filter dropdowns in the UI, or to validate `filter_by` keys on the client side:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -457,14 +479,52 @@ The distinct values for each facet field are returned in the `filter_attributes`
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! info "Key format uses `__` as a separator for relationship chains."
|
Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError).
|
||||||
A direct column `User.status` produces `"status"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`. A deeper chain `(User.role, Role.permission, Permission.name)` produces `"role__permission__name"`. An unknown `filter_by` key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError) (HTTP 422).
|
|
||||||
|
!!! info "The keys in `filter_by` are the same keys the client received in `filter_attributes`."
|
||||||
|
Keys use `__` as a separator for the full relationship chain. A direct column `User.status` produces `"status"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`. A deeper chain `(User.role, Role.permission, Permission.name)` produces `"role__permission__name"`.
|
||||||
|
|
||||||
|
`filter_by` and `filters` can be combined — both are applied with AND logic.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
UserCrud = CrudFactory(
|
||||||
|
model=User,
|
||||||
|
facet_fields=[User.status, User.country, (User.role, Role.name)],
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("", response_model_exclude_none=True)
|
||||||
|
async def list_users(
|
||||||
|
session: SessionDep,
|
||||||
|
page: int = 1,
|
||||||
|
filter_by: Annotated[dict[str, list[str]], Depends(UserCrud.filter_params())],
|
||||||
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
|
return await UserCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
page=page,
|
||||||
|
filter_by=filter_by,
|
||||||
|
schema=UserRead,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both single-value and multi-value query parameters work:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users?status=active → filter_by={"status": ["active"]}
|
||||||
|
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
|
||||||
|
GET /users?role__name=admin&role__name=editor → filter_by={"role__name": ["admin", "editor"]} (IN clause)
|
||||||
|
```
|
||||||
|
|
||||||
## Sorting
|
## Sorting
|
||||||
|
|
||||||
!!! info "Added in `v1.3`"
|
!!! info "Added in `v1.3`"
|
||||||
|
|
||||||
Declare `order_fields` on the CRUD class. Relationship traversal is supported via tuples, using the same syntax as `searchable_fields` and `facet_fields`:
|
Declare `order_fields` on the CRUD class to expose client-driven column ordering via `order_by` and `order` query parameters.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
UserCrud = CrudFactory(
|
UserCrud = CrudFactory(
|
||||||
@@ -472,80 +532,46 @@ UserCrud = CrudFactory(
|
|||||||
order_fields=[
|
order_fields=[
|
||||||
User.name,
|
User.name,
|
||||||
User.created_at,
|
User.created_at,
|
||||||
(User.role, Role.name), # sort by a related model column
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
You can override `order_fields` per call:
|
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:
|
||||||
|
|
||||||
```python
|
|
||||||
result = await UserCrud.offset_paginate(
|
|
||||||
session=session,
|
|
||||||
order_fields=[User.name],
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Or via the dependency to narrow which fields are exposed as query parameters:
|
|
||||||
|
|
||||||
```python
|
|
||||||
params = UserCrud.offset_paginate_params(order_fields=[User.name])
|
|
||||||
```
|
|
||||||
|
|
||||||
Sorting 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,
|
||||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
order_by: Annotated[OrderByClause | None, Depends(UserCrud.order_params())],
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
return await UserCrud.offset_paginate(session=session, order_by=order_by, schema=UserRead)
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
@router.get("")
|
|
||||||
async def list_users(
|
|
||||||
session: SessionDep,
|
|
||||||
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
|
|
||||||
) -> CursorPaginatedResponse[UserRead]:
|
|
||||||
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The dependency adds two query parameters to the endpoint:
|
The dependency adds two query parameters to the endpoint:
|
||||||
|
|
||||||
| Parameter | Type |
|
| Parameter | Type |
|
||||||
| ---------- | --------------- |
|
| ---------- | --------------- |
|
||||||
| `order_by` | `str \| null` |
|
| `order_by` | `str | null` |
|
||||||
| `order` | `asc` or `desc` |
|
| `order` | `asc` or `desc` |
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /users?order_by=name&order=asc → ORDER BY users.name ASC
|
GET /users?order_by=name&order=asc → ORDER BY users.name ASC
|
||||||
GET /users?order_by=role__name&order=desc → LEFT JOIN roles ON ... ORDER BY roles.name DESC
|
GET /users?order_by=name&order=desc → ORDER BY users.name DESC
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! info "Relationship tuples are joined automatically."
|
An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
|
||||||
When a relation field is selected, the related table is LEFT OUTER JOINed automatically. 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:
|
||||||
|
|
||||||
The available sort keys are returned in the `order_columns` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse). Use them to populate a sort picker in the UI, or to validate `order_by` values on the client side:
|
```python
|
||||||
|
UserOrderParams = UserCrud.order_params(order_fields=[User.name])
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"data": ["..."],
|
|
||||||
"pagination": { "..." },
|
|
||||||
"order_columns": ["created_at", "name", "role__name"]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! info "Key format uses `__` as a separator for relationship chains."
|
|
||||||
A direct column `User.name` produces `"name"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`.
|
|
||||||
|
|
||||||
## Relationship loading
|
## Relationship loading
|
||||||
|
|
||||||
!!! info "Added in `v1.1`"
|
!!! info "Added in `v1.1`"
|
||||||
@@ -630,11 +656,12 @@ async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(
|
async def list_users(session: SessionDep, page: int = 1) -> OffsetPaginatedResponse[UserRead]:
|
||||||
session: SessionDep,
|
return await crud.UserCrud.offset_paginate(
|
||||||
params: Annotated[dict, Depends(crud.UserCrud.offset_paginate_params())],
|
session=session,
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
page=page,
|
||||||
return await crud.UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
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.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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,
|
||||||
@@ -21,18 +22,21 @@ async def list_articles_offset(
|
|||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[
|
params: Annotated[
|
||||||
dict,
|
dict,
|
||||||
Depends(
|
Depends(ArticleCrud.offset_params(default_page_size=20, max_page_size=100)),
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,18 +46,21 @@ async def list_articles_cursor(
|
|||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[
|
params: Annotated[
|
||||||
dict,
|
dict,
|
||||||
Depends(
|
Depends(ArticleCrud.cursor_params(default_page_size=20, max_page_size=100)),
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,17 +70,20 @@ async def list_articles(
|
|||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[
|
params: Annotated[
|
||||||
dict,
|
dict,
|
||||||
Depends(
|
Depends(ArticleCrud.paginate_params(default_page_size=20, max_page_size=100)),
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "3.0.3"
|
version = "2.4.3"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.0.3"
|
__version__ = "2.4.3"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from ..exceptions import (
|
from ..exceptions import (
|
||||||
InvalidFacetFilterError,
|
InvalidFacetFilterError,
|
||||||
InvalidSearchColumnError,
|
|
||||||
NoSearchableFieldsError,
|
NoSearchableFieldsError,
|
||||||
UnsupportedFacetTypeError,
|
UnsupportedFacetTypeError,
|
||||||
)
|
)
|
||||||
@@ -12,7 +11,6 @@ from ..types import (
|
|||||||
JoinType,
|
JoinType,
|
||||||
M2MFieldType,
|
M2MFieldType,
|
||||||
OrderByClause,
|
OrderByClause,
|
||||||
OrderFieldType,
|
|
||||||
SearchFieldType,
|
SearchFieldType,
|
||||||
)
|
)
|
||||||
from .factory import AsyncCrud, CrudFactory
|
from .factory import AsyncCrud, CrudFactory
|
||||||
@@ -24,12 +22,10 @@ __all__ = [
|
|||||||
"FacetFieldType",
|
"FacetFieldType",
|
||||||
"get_searchable_fields",
|
"get_searchable_fields",
|
||||||
"InvalidFacetFilterError",
|
"InvalidFacetFilterError",
|
||||||
"InvalidSearchColumnError",
|
|
||||||
"JoinType",
|
"JoinType",
|
||||||
"M2MFieldType",
|
"M2MFieldType",
|
||||||
"NoSearchableFieldsError",
|
"NoSearchableFieldsError",
|
||||||
"OrderByClause",
|
"OrderByClause",
|
||||||
"OrderFieldType",
|
|
||||||
"PaginationType",
|
"PaginationType",
|
||||||
"SearchConfig",
|
"SearchConfig",
|
||||||
"SearchFieldType",
|
"SearchFieldType",
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ from ..types import (
|
|||||||
M2MFieldType,
|
M2MFieldType,
|
||||||
ModelType,
|
ModelType,
|
||||||
OrderByClause,
|
OrderByClause,
|
||||||
OrderFieldType,
|
|
||||||
SchemaType,
|
SchemaType,
|
||||||
SearchFieldType,
|
SearchFieldType,
|
||||||
)
|
)
|
||||||
@@ -48,7 +47,6 @@ from .search import (
|
|||||||
build_filter_by,
|
build_filter_by,
|
||||||
build_search_filters,
|
build_search_filters,
|
||||||
facet_keys,
|
facet_keys,
|
||||||
search_field_keys,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -117,12 +115,8 @@ 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:
|
||||||
key = str(join_rel)
|
q = q.outerjoin(join_rel)
|
||||||
if key not in seen:
|
|
||||||
seen.add(key)
|
|
||||||
q = q.outerjoin(join_rel)
|
|
||||||
return q
|
return q
|
||||||
|
|
||||||
|
|
||||||
@@ -135,7 +129,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
model: ClassVar[type[DeclarativeBase]]
|
model: ClassVar[type[DeclarativeBase]]
|
||||||
searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
|
searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
|
||||||
facet_fields: ClassVar[Sequence[FacetFieldType] | None] = None
|
facet_fields: ClassVar[Sequence[FacetFieldType] | None] = None
|
||||||
order_fields: ClassVar[Sequence[OrderFieldType] | None] = None
|
order_fields: ClassVar[Sequence[QueryableAttribute[Any]] | None] = None
|
||||||
m2m_fields: ClassVar[M2MFieldType | None] = None
|
m2m_fields: ClassVar[M2MFieldType | None] = None
|
||||||
default_load_options: ClassVar[Sequence[ExecutableOption] | None] = None
|
default_load_options: ClassVar[Sequence[ExecutableOption] | None] = None
|
||||||
cursor_column: ClassVar[Any | None] = None
|
cursor_column: ClassVar[Any | None] = None
|
||||||
@@ -170,18 +164,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
return load_options
|
return load_options
|
||||||
return cls.default_load_options
|
return cls.default_load_options
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _reload_with_options(
|
|
||||||
cls: type[Self], session: AsyncSession, instance: ModelType
|
|
||||||
) -> ModelType:
|
|
||||||
"""Re-query instance by PK with default_load_options applied."""
|
|
||||||
mapper = cls.model.__mapper__
|
|
||||||
pk_filters = [
|
|
||||||
getattr(cls.model, col.key) == getattr(instance, col.key)
|
|
||||||
for col in mapper.primary_key
|
|
||||||
]
|
|
||||||
return await cls.get(session, filters=pk_filters)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _resolve_m2m(
|
async def _resolve_m2m(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
@@ -281,304 +263,118 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _resolve_search_columns(
|
def filter_params(
|
||||||
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 _resolve_order_columns(
|
|
||||||
cls: type[Self],
|
|
||||||
order_fields: Sequence[OrderFieldType] | None,
|
|
||||||
) -> list[str] | None:
|
|
||||||
"""Return sort column keys, or None if no order fields configured."""
|
|
||||||
fields = order_fields if order_fields is not None else cls.order_fields
|
|
||||||
if not fields:
|
|
||||||
return None
|
|
||||||
return sorted(facet_keys(fields))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _build_paginate_params(
|
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
*,
|
*,
|
||||||
pagination_params: list[inspect.Parameter],
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
pagination_fixed: dict[str, Any],
|
) -> Callable[..., Awaitable[dict[str, list[str]]]]:
|
||||||
dep_name: str,
|
"""Return a FastAPI dependency that collects facet filter values from query parameters.
|
||||||
search: bool,
|
|
||||||
filter: bool,
|
|
||||||
order: bool,
|
|
||||||
search_fields: Sequence[SearchFieldType] | None,
|
|
||||||
facet_fields: Sequence[FacetFieldType] | None,
|
|
||||||
order_fields: Sequence[OrderFieldType] | 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)
|
|
||||||
|
|
||||||
search_keys: list[str] | None = None
|
Args:
|
||||||
if search:
|
facet_fields: Override the facet fields for this dependency. Falls back to the
|
||||||
search_keys = cls._resolve_search_columns(search_fields)
|
class-level ``facet_fields`` if not provided.
|
||||||
if search_keys:
|
|
||||||
all_params.extend(
|
|
||||||
[
|
|
||||||
inspect.Parameter(
|
|
||||||
"search",
|
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
|
||||||
annotation=str | None,
|
|
||||||
default=Query(
|
|
||||||
default=None, description="Search query string"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
inspect.Parameter(
|
|
||||||
"search_column",
|
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
|
||||||
annotation=str | None,
|
|
||||||
default=Query(
|
|
||||||
default=None,
|
|
||||||
description="Restrict search to a single column",
|
|
||||||
enum=search_keys,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
reserved_names.update({"search", "search_column"})
|
|
||||||
|
|
||||||
filter_keys: list[str] | None = None
|
Returns:
|
||||||
if filter:
|
An async dependency function named ``{Model}FilterParams`` that resolves to a
|
||||||
resolved_facets = cls._resolve_facet_fields(facet_fields)
|
``dict[str, list[str]]`` containing only the keys that were supplied in the
|
||||||
if resolved_facets:
|
request (absent/``None`` parameters are excluded).
|
||||||
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, OrderFieldType] | None = None
|
Raises:
|
||||||
order_valid_keys: list[str] | None = None
|
ValueError: If no facet fields are configured on this CRUD class and none are
|
||||||
if order:
|
provided via ``facet_fields``.
|
||||||
resolved_order = (
|
"""
|
||||||
order_fields if order_fields is not None else cls.order_fields
|
fields = cls._resolve_facet_fields(facet_fields)
|
||||||
|
if not fields:
|
||||||
|
raise ValueError(
|
||||||
|
f"{cls.__name__} has no facet_fields configured. "
|
||||||
|
"Pass facet_fields= or set them on CrudFactory."
|
||||||
)
|
)
|
||||||
if resolved_order:
|
keys = facet_keys(fields)
|
||||||
keys = facet_keys(resolved_order)
|
|
||||||
order_field_map = dict(zip(keys, 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]:
|
async def dependency(**kwargs: Any) -> dict[str, list[str]]:
|
||||||
result: dict[str, Any] = dict(pagination_fixed)
|
return {k: v for k, v in kwargs.items() if v is not None}
|
||||||
for name in pagination_param_names:
|
|
||||||
result[name] = kwargs[name]
|
|
||||||
|
|
||||||
if search_keys is not None:
|
dependency.__name__ = f"{cls.model.__name__}FilterParams"
|
||||||
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:
|
|
||||||
if isinstance(field, tuple):
|
|
||||||
col = field[-1]
|
|
||||||
result["order_by"] = (
|
|
||||||
col.asc() if order_dir == "asc" else col.desc()
|
|
||||||
)
|
|
||||||
result["order_joins"] = list(field[:-1])
|
|
||||||
else:
|
|
||||||
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]
|
dependency.__signature__ = inspect.Signature( # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
|
||||||
parameters=all_params,
|
parameters=[
|
||||||
|
inspect.Parameter(
|
||||||
|
k,
|
||||||
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=list[str] | None,
|
||||||
|
default=Query(default=None),
|
||||||
|
)
|
||||||
|
for k in keys
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
return dependency
|
return dependency
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def offset_paginate_params(
|
def offset_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[OrderFieldType] | 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 all params for :meth:`offset_paginate`.
|
"""Return a FastAPI dependency that collects offset pagination params from query params.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
default_page_size: Default ``items_per_page`` value.
|
default_page_size: Default value for the ``items_per_page`` query parameter.
|
||||||
max_page_size: Maximum ``items_per_page`` value.
|
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
||||||
include_total: Whether to include total count (not a query param).
|
``le`` on the ``Query``).
|
||||||
search: Enable search query parameters.
|
include_total: Server-side flag forwarded as-is to ``include_total`` in
|
||||||
filter: Enable facet filter query parameters.
|
:meth:`offset_paginate`. Not exposed as a query parameter.
|
||||||
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 ready to be unpacked
|
An async dependency that resolves to a dict with ``page``,
|
||||||
into :meth:`offset_paginate`.
|
``items_per_page``, and ``include_total`` keys, ready to be
|
||||||
|
unpacked into :meth:`offset_paginate`.
|
||||||
"""
|
"""
|
||||||
pagination_params = [
|
|
||||||
inspect.Parameter(
|
async def dependency(
|
||||||
"page",
|
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
||||||
annotation=int,
|
) -> dict[str, Any]:
|
||||||
default=Query(1, ge=1, description="Page number (1-indexed)"),
|
return {
|
||||||
),
|
"page": page,
|
||||||
inspect.Parameter(
|
"items_per_page": items_per_page,
|
||||||
"items_per_page",
|
"include_total": include_total,
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
}
|
||||||
annotation=int,
|
|
||||||
default=_page_size_query(default_page_size, max_page_size),
|
dependency.__name__ = f"{cls.model.__name__}OffsetParams"
|
||||||
),
|
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_paginate_params(
|
def cursor_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[OrderFieldType] | 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 all params for :meth:`cursor_paginate`.
|
"""Return a FastAPI dependency that collects cursor pagination params from query params.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
default_page_size: Default ``items_per_page`` value.
|
default_page_size: Default value for the ``items_per_page`` query parameter.
|
||||||
max_page_size: Maximum ``items_per_page`` value.
|
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
||||||
search: Enable search query parameters.
|
``le`` on the ``Query``).
|
||||||
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 ready to be unpacked
|
An async dependency that resolves to a dict with ``cursor`` and
|
||||||
into :meth:`cursor_paginate`.
|
``items_per_page`` keys, ready to be unpacked into
|
||||||
|
:meth:`cursor_paginate`.
|
||||||
"""
|
"""
|
||||||
pagination_params = [
|
|
||||||
inspect.Parameter(
|
async def dependency(
|
||||||
"cursor",
|
cursor: str | None = Query(
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
None, description="Cursor token from a previous response"
|
||||||
annotation=str | None,
|
|
||||||
default=Query(
|
|
||||||
None, description="Cursor token from a previous response"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
inspect.Parameter(
|
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
||||||
"items_per_page",
|
) -> dict[str, Any]:
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
return {"cursor": cursor, "items_per_page": items_per_page}
|
||||||
annotation=int,
|
|
||||||
default=_page_size_query(default_page_size, max_page_size),
|
dependency.__name__ = f"{cls.model.__name__}CursorParams"
|
||||||
),
|
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(
|
||||||
@@ -588,81 +384,102 @@ 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,
|
||||||
search: bool = True,
|
|
||||||
filter: bool = True,
|
|
||||||
order: bool = True,
|
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
|
||||||
order_fields: Sequence[OrderFieldType] | 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 all params for :meth:`paginate`.
|
"""Return a FastAPI dependency that collects all pagination params from query params.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
default_page_size: Default ``items_per_page`` value.
|
default_page_size: Default value for the ``items_per_page`` query parameter.
|
||||||
max_page_size: Maximum ``items_per_page`` value.
|
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
||||||
|
``le`` on the ``Query``).
|
||||||
default_pagination_type: Default pagination strategy.
|
default_pagination_type: Default pagination strategy.
|
||||||
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:`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 ready to be unpacked
|
An async dependency that resolves to a dict with ``pagination_type``,
|
||||||
into :meth:`paginate`.
|
``page``, ``cursor``, ``items_per_page``, and ``include_total`` keys,
|
||||||
|
ready to be unpacked into :meth:`paginate`.
|
||||||
"""
|
"""
|
||||||
pagination_params = [
|
|
||||||
inspect.Parameter(
|
async def dependency(
|
||||||
"pagination_type",
|
pagination_type: PaginationType = Query(
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
default_pagination_type, description="Pagination strategy"
|
||||||
annotation=PaginationType,
|
|
||||||
default=Query(
|
|
||||||
default_pagination_type, description="Pagination strategy"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
inspect.Parameter(
|
page: int = Query(
|
||||||
"page",
|
1, ge=1, description="Page number (1-indexed, offset only)"
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
|
||||||
annotation=int,
|
|
||||||
default=Query(
|
|
||||||
1, ge=1, description="Page number (1-indexed, offset only)"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
inspect.Parameter(
|
cursor: str | None = Query(
|
||||||
"cursor",
|
None, description="Cursor token from a previous response (cursor only)"
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
|
||||||
annotation=str | None,
|
|
||||||
default=Query(
|
|
||||||
None,
|
|
||||||
description="Cursor token from a previous response (cursor only)",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
inspect.Parameter(
|
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
||||||
"items_per_page",
|
) -> dict[str, Any]:
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
return {
|
||||||
annotation=int,
|
"pagination_type": pagination_type,
|
||||||
default=_page_size_query(default_page_size, max_page_size),
|
"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,
|
||||||
|
default_field: QueryableAttribute[Any] | None = None,
|
||||||
|
default_order: Literal["asc", "desc"] = "asc",
|
||||||
|
) -> Callable[..., Awaitable[OrderByClause | None]]:
|
||||||
|
"""Return a FastAPI dependency that resolves order query params into an order_by clause.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
order_fields: Override the allowed order fields. Falls back to the class-level
|
||||||
|
``order_fields`` if not provided.
|
||||||
|
default_field: Field to order by when ``order_by`` query param is absent.
|
||||||
|
If ``None`` and no ``order_by`` is provided, no ordering is applied.
|
||||||
|
default_order: Default order direction when ``order`` is absent
|
||||||
|
(``"asc"`` or ``"desc"``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An async dependency function named ``{Model}OrderParams`` that resolves to an
|
||||||
|
``OrderByClause`` (or ``None``). Pass it to ``Depends()`` in your route.
|
||||||
|
|
||||||
|
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
|
||||||
|
if not fields:
|
||||||
|
raise ValueError(
|
||||||
|
f"{cls.__name__} has no order_fields configured. "
|
||||||
|
"Pass order_fields= or set them on CrudFactory."
|
||||||
|
)
|
||||||
|
field_map: dict[str, QueryableAttribute[Any]] = {f.key: f for f in fields}
|
||||||
|
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(
|
||||||
return cls._build_paginate_params(
|
default_order, description="Sort direction"
|
||||||
pagination_params=pagination_params,
|
),
|
||||||
pagination_fixed={"include_total": include_total},
|
) -> OrderByClause | None:
|
||||||
dep_name=f"{cls.model.__name__}PaginateParams",
|
if order_by is None:
|
||||||
search=search,
|
if default_field is None:
|
||||||
filter=filter,
|
return None
|
||||||
order=order,
|
field = default_field
|
||||||
search_fields=search_fields,
|
elif order_by not in field_map:
|
||||||
facet_fields=facet_fields,
|
raise InvalidOrderFieldError(order_by, valid_keys)
|
||||||
order_fields=order_fields,
|
else:
|
||||||
default_order_field=default_order_field,
|
field = field_map[order_by]
|
||||||
default_order=default_order,
|
return field.asc() if order == "asc" else field.desc()
|
||||||
)
|
|
||||||
|
dependency.__name__ = f"{cls.model.__name__}OrderParams"
|
||||||
|
return dependency
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -717,8 +534,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
|
|
||||||
session.add(db_model)
|
session.add(db_model)
|
||||||
await session.refresh(db_model)
|
await session.refresh(db_model)
|
||||||
if cls.default_load_options:
|
|
||||||
db_model = await cls._reload_with_options(session, db_model)
|
|
||||||
result = cast(ModelType, db_model)
|
result = cast(ModelType, db_model)
|
||||||
if schema:
|
if schema:
|
||||||
return Response(data=schema.model_validate(result))
|
return Response(data=schema.model_validate(result))
|
||||||
@@ -1074,8 +889,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
for rel_attr, related_instances in m2m_resolved.items():
|
for rel_attr, related_instances in m2m_resolved.items():
|
||||||
setattr(db_model, rel_attr, related_instances)
|
setattr(db_model, rel_attr, related_instances)
|
||||||
await session.refresh(db_model)
|
await session.refresh(db_model)
|
||||||
if cls.default_load_options:
|
|
||||||
db_model = await cls._reload_with_options(session, db_model)
|
|
||||||
if schema:
|
if schema:
|
||||||
return Response(data=schema.model_validate(db_model))
|
return Response(data=schema.model_validate(db_model))
|
||||||
return db_model
|
return db_model
|
||||||
@@ -1238,14 +1051,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
order_by: OrderByClause | None = None,
|
order_by: OrderByClause | None = None,
|
||||||
order_joins: list[Any] | None = None,
|
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
items_per_page: int = 20,
|
items_per_page: int = 20,
|
||||||
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,
|
|
||||||
order_fields: Sequence[OrderFieldType] | 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],
|
||||||
@@ -1265,8 +1075,6 @@ 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.
|
|
||||||
order_fields: Fields allowed for sorting (overrides class default).
|
|
||||||
facet_fields: Columns to compute distinct values for (overrides class default)
|
facet_fields: Columns to compute distinct values for (overrides class default)
|
||||||
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,
|
||||||
@@ -1289,7 +1097,6 @@ 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)
|
||||||
@@ -1303,10 +1110,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
# Apply search joins (always outer joins for search)
|
# Apply search joins (always outer joins for search)
|
||||||
q = _apply_search_joins(q, search_joins)
|
q = _apply_search_joins(q, search_joins)
|
||||||
|
|
||||||
# Apply order joins (relation joins required for order_by field)
|
|
||||||
if order_joins:
|
|
||||||
q = _apply_search_joins(q, order_joins)
|
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if resolved := cls._resolve_load_options(load_options):
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
@@ -1350,8 +1153,6 @@ 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)
|
|
||||||
order_columns = cls._resolve_order_columns(order_fields)
|
|
||||||
|
|
||||||
return OffsetPaginatedResponse(
|
return OffsetPaginatedResponse(
|
||||||
data=items,
|
data=items,
|
||||||
@@ -1362,8 +1163,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
has_more=has_more,
|
has_more=has_more,
|
||||||
),
|
),
|
||||||
filter_attributes=filter_attributes,
|
filter_attributes=filter_attributes,
|
||||||
search_columns=search_columns,
|
|
||||||
order_columns=order_columns,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1377,12 +1176,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
order_by: OrderByClause | None = None,
|
order_by: OrderByClause | None = None,
|
||||||
order_joins: list[Any] | None = None,
|
|
||||||
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,
|
|
||||||
order_fields: Sequence[OrderFieldType] | 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],
|
||||||
@@ -1403,8 +1199,6 @@ 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.
|
|
||||||
order_fields: Fields allowed for sorting (overrides class default).
|
|
||||||
facet_fields: Columns to compute distinct values for (overrides class default).
|
facet_fields: Columns to compute distinct values for (overrides class default).
|
||||||
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,
|
||||||
@@ -1444,7 +1238,6 @@ 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)
|
||||||
@@ -1458,10 +1251,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
# Apply search joins (always outer joins)
|
# Apply search joins (always outer joins)
|
||||||
q = _apply_search_joins(q, search_joins)
|
q = _apply_search_joins(q, search_joins)
|
||||||
|
|
||||||
# Apply order joins (relation joins required for order_by field)
|
|
||||||
if order_joins:
|
|
||||||
q = _apply_search_joins(q, order_joins)
|
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if resolved := cls._resolve_load_options(load_options):
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
@@ -1519,8 +1308,6 @@ 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)
|
|
||||||
order_columns = cls._resolve_order_columns(order_fields)
|
|
||||||
|
|
||||||
return CursorPaginatedResponse(
|
return CursorPaginatedResponse(
|
||||||
data=items,
|
data=items,
|
||||||
@@ -1531,8 +1318,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
has_more=has_more,
|
has_more=has_more,
|
||||||
),
|
),
|
||||||
filter_attributes=filter_attributes,
|
filter_attributes=filter_attributes,
|
||||||
search_columns=search_columns,
|
|
||||||
order_columns=order_columns,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -1547,15 +1332,12 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: bool = ...,
|
outer_join: bool = ...,
|
||||||
load_options: Sequence[ExecutableOption] | None = ...,
|
load_options: Sequence[ExecutableOption] | None = ...,
|
||||||
order_by: OrderByClause | None = ...,
|
order_by: OrderByClause | None = ...,
|
||||||
order_joins: list[Any] | None = ...,
|
|
||||||
page: int = ...,
|
page: int = ...,
|
||||||
cursor: str | None = ...,
|
cursor: str | None = ...,
|
||||||
items_per_page: int = ...,
|
items_per_page: int = ...,
|
||||||
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 = ...,
|
|
||||||
order_fields: Sequence[OrderFieldType] | 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],
|
||||||
@@ -1573,15 +1355,12 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: bool = ...,
|
outer_join: bool = ...,
|
||||||
load_options: Sequence[ExecutableOption] | None = ...,
|
load_options: Sequence[ExecutableOption] | None = ...,
|
||||||
order_by: OrderByClause | None = ...,
|
order_by: OrderByClause | None = ...,
|
||||||
order_joins: list[Any] | None = ...,
|
|
||||||
page: int = ...,
|
page: int = ...,
|
||||||
cursor: str | None = ...,
|
cursor: str | None = ...,
|
||||||
items_per_page: int = ...,
|
items_per_page: int = ...,
|
||||||
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 = ...,
|
|
||||||
order_fields: Sequence[OrderFieldType] | 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],
|
||||||
@@ -1598,15 +1377,12 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
order_by: OrderByClause | None = None,
|
order_by: OrderByClause | None = None,
|
||||||
order_joins: list[Any] | None = None,
|
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
items_per_page: int = 20,
|
items_per_page: int = 20,
|
||||||
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,
|
|
||||||
order_fields: Sequence[OrderFieldType] | 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],
|
||||||
@@ -1634,8 +1410,6 @@ 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.
|
|
||||||
order_fields: Fields allowed for sorting (overrides class default).
|
|
||||||
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
|
||||||
@@ -1661,12 +1435,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join=outer_join,
|
outer_join=outer_join,
|
||||||
load_options=load_options,
|
load_options=load_options,
|
||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
order_joins=order_joins,
|
|
||||||
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,
|
|
||||||
order_fields=order_fields,
|
|
||||||
facet_fields=facet_fields,
|
facet_fields=facet_fields,
|
||||||
filter_by=filter_by,
|
filter_by=filter_by,
|
||||||
schema=schema,
|
schema=schema,
|
||||||
@@ -1681,14 +1452,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join=outer_join,
|
outer_join=outer_join,
|
||||||
load_options=load_options,
|
load_options=load_options,
|
||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
order_joins=order_joins,
|
|
||||||
page=page,
|
page=page,
|
||||||
items_per_page=items_per_page,
|
items_per_page=items_per_page,
|
||||||
include_total=include_total,
|
include_total=include_total,
|
||||||
search=search,
|
search=search,
|
||||||
search_fields=search_fields,
|
search_fields=search_fields,
|
||||||
search_column=search_column,
|
|
||||||
order_fields=order_fields,
|
|
||||||
facet_fields=facet_fields,
|
facet_fields=facet_fields,
|
||||||
filter_by=filter_by,
|
filter_by=filter_by,
|
||||||
schema=schema,
|
schema=schema,
|
||||||
@@ -1703,7 +1471,7 @@ def CrudFactory(
|
|||||||
base_class: type[AsyncCrud[Any]] = AsyncCrud,
|
base_class: type[AsyncCrud[Any]] = AsyncCrud,
|
||||||
searchable_fields: Sequence[SearchFieldType] | None = None,
|
searchable_fields: Sequence[SearchFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||||
m2m_fields: M2MFieldType | None = None,
|
m2m_fields: M2MFieldType | None = None,
|
||||||
default_load_options: Sequence[ExecutableOption] | None = None,
|
default_load_options: Sequence[ExecutableOption] | None = None,
|
||||||
cursor_column: Any | None = None,
|
cursor_column: Any | None = None,
|
||||||
@@ -1720,7 +1488,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 ``offset_paginate_params()``. Can be overridden per call.
|
via ``order_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.
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ from sqlalchemy.types import (
|
|||||||
|
|
||||||
from ..exceptions import (
|
from ..exceptions import (
|
||||||
InvalidFacetFilterError,
|
InvalidFacetFilterError,
|
||||||
InvalidSearchColumnError,
|
|
||||||
NoSearchableFieldsError,
|
NoSearchableFieldsError,
|
||||||
UnsupportedFacetTypeError,
|
UnsupportedFacetTypeError,
|
||||||
)
|
)
|
||||||
@@ -97,7 +96,6 @@ 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.
|
||||||
|
|
||||||
@@ -106,8 +104,6 @@ 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)
|
||||||
@@ -134,14 +130,6 @@ 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]] = []
|
||||||
@@ -176,11 +164,6 @@ 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.
|
||||||
|
|
||||||
@@ -242,19 +225,13 @@ async def build_facets(
|
|||||||
else:
|
else:
|
||||||
q = select(column).select_from(model).distinct()
|
q = select(column).select_from(model).distinct()
|
||||||
|
|
||||||
# Apply base joins (deduplicated) — needed here independently
|
# Apply base joins (already done on main query, but needed here independently)
|
||||||
seen_joins: set[str] = set()
|
|
||||||
for rel in base_joins or []:
|
for rel in base_joins or []:
|
||||||
rel_key = str(rel)
|
q = q.outerjoin(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 applied
|
# Add any extra joins required by this facet field that aren't already in base_joins
|
||||||
for rel in rels:
|
for rel in rels:
|
||||||
rel_key = str(rel)
|
if str(rel) not in existing_join_keys:
|
||||||
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:
|
||||||
@@ -265,15 +242,7 @@ async def build_facets(
|
|||||||
else:
|
else:
|
||||||
q = q.order_by(column)
|
q = q.order_by(column)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
col_type = column.property.columns[0].type
|
values = [row[0] for row in result.all() if row[0] is not None]
|
||||||
enum_class = getattr(col_type, "enum_class", None)
|
|
||||||
values = [
|
|
||||||
row[0].name
|
|
||||||
if (enum_class is not None and isinstance(row[0], enum_class))
|
|
||||||
else row[0]
|
|
||||||
for row in result.all()
|
|
||||||
if row[0] is not None
|
|
||||||
]
|
|
||||||
return key, values
|
return key, values
|
||||||
|
|
||||||
pairs = await asyncio.gather(
|
pairs = await asyncio.gather(
|
||||||
@@ -286,18 +255,6 @@ _EQUALITY_TYPES = (String, Integer, Numeric, Date, DateTime, Time, Enum, Uuid)
|
|||||||
"""Column types that support equality / IN filtering in build_filter_by."""
|
"""Column types that support equality / IN filtering in build_filter_by."""
|
||||||
|
|
||||||
|
|
||||||
def _coerce_bool(value: Any) -> bool:
|
|
||||||
"""Coerce a string value to a Python bool for Boolean column filtering."""
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
if isinstance(value, str):
|
|
||||||
if value.lower() == "true":
|
|
||||||
return True
|
|
||||||
if value.lower() == "false":
|
|
||||||
return False
|
|
||||||
raise ValueError(f"Cannot coerce {value!r} to bool")
|
|
||||||
|
|
||||||
|
|
||||||
def build_filter_by(
|
def build_filter_by(
|
||||||
filter_by: dict[str, Any],
|
filter_by: dict[str, Any],
|
||||||
facet_fields: Sequence[FacetFieldType],
|
facet_fields: Sequence[FacetFieldType],
|
||||||
@@ -344,35 +301,16 @@ def build_filter_by(
|
|||||||
added_join_keys.add(rel_key)
|
added_join_keys.add(rel_key)
|
||||||
|
|
||||||
col_type = column.property.columns[0].type
|
col_type = column.property.columns[0].type
|
||||||
if isinstance(col_type, Boolean):
|
if isinstance(col_type, ARRAY):
|
||||||
coerce = _coerce_bool
|
|
||||||
if isinstance(value, list):
|
|
||||||
filters.append(column.in_([coerce(v) for v in value]))
|
|
||||||
else:
|
|
||||||
filters.append(column == coerce(value))
|
|
||||||
elif isinstance(col_type, ARRAY):
|
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
filters.append(column.overlap(value))
|
filters.append(column.overlap(value))
|
||||||
else:
|
else:
|
||||||
filters.append(column.any(value))
|
filters.append(column.any(value))
|
||||||
elif isinstance(col_type, Enum):
|
elif isinstance(col_type, Boolean):
|
||||||
enum_class = col_type.enum_class
|
if isinstance(value, list):
|
||||||
if enum_class is not None:
|
filters.append(column.in_(value))
|
||||||
|
else:
|
||||||
def _coerce_enum(v: Any) -> Any:
|
filters.append(column.is_(value))
|
||||||
if isinstance(v, enum_class):
|
|
||||||
return v
|
|
||||||
return enum_class[v] # lookup by name: "PENDING", "RED"
|
|
||||||
|
|
||||||
if isinstance(value, list):
|
|
||||||
filters.append(column.in_([_coerce_enum(v) for v in value]))
|
|
||||||
else:
|
|
||||||
filters.append(column == _coerce_enum(value))
|
|
||||||
else: # pragma: no cover
|
|
||||||
if isinstance(value, list):
|
|
||||||
filters.append(column.in_(value))
|
|
||||||
else:
|
|
||||||
filters.append(column == value)
|
|
||||||
elif isinstance(col_type, _EQUALITY_TYPES):
|
elif isinstance(col_type, _EQUALITY_TYPES):
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
filters.append(column.in_(value))
|
filters.append(column.in_(value))
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from .exceptions import (
|
|||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
InvalidFacetFilterError,
|
InvalidFacetFilterError,
|
||||||
InvalidOrderFieldError,
|
InvalidOrderFieldError,
|
||||||
InvalidSearchColumnError,
|
|
||||||
NoSearchableFieldsError,
|
NoSearchableFieldsError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
@@ -25,7 +24,6 @@ __all__ = [
|
|||||||
"init_exceptions_handlers",
|
"init_exceptions_handlers",
|
||||||
"InvalidFacetFilterError",
|
"InvalidFacetFilterError",
|
||||||
"InvalidOrderFieldError",
|
"InvalidOrderFieldError",
|
||||||
"InvalidSearchColumnError",
|
|
||||||
"NoSearchableFieldsError",
|
"NoSearchableFieldsError",
|
||||||
"NotFoundError",
|
"NotFoundError",
|
||||||
"UnauthorizedError",
|
"UnauthorizedError",
|
||||||
|
|||||||
@@ -172,33 +172,6 @@ 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."""
|
||||||
|
|
||||||
|
|||||||
@@ -162,8 +162,6 @@ 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
|
|
||||||
order_columns: list[str] | None = None
|
|
||||||
|
|
||||||
_discriminated_union_cache: ClassVar[dict[Any, Any]] = {}
|
_discriminated_union_cache: ClassVar[dict[Any, Any]] = {}
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,13 @@ ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
|||||||
SchemaType = TypeVar("SchemaType", bound=BaseModel)
|
SchemaType = TypeVar("SchemaType", bound=BaseModel)
|
||||||
|
|
||||||
# CRUD type aliases
|
# CRUD type aliases
|
||||||
JoinType = list[tuple[type[DeclarativeBase] | Any, Any]]
|
JoinType = list[tuple[type[DeclarativeBase], Any]]
|
||||||
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
||||||
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
||||||
|
|
||||||
# Search / facet / order type aliases
|
# Search / facet type aliases
|
||||||
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
||||||
FacetFieldType = SearchFieldType
|
FacetFieldType = SearchFieldType
|
||||||
OrderFieldType = SearchFieldType
|
|
||||||
|
|
||||||
# Dependency type aliases
|
# Dependency type aliases
|
||||||
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] | Any
|
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] | Any
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -13,7 +12,6 @@ from sqlalchemy import (
|
|||||||
Column,
|
Column,
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum as SAEnum,
|
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
JSON,
|
JSON,
|
||||||
@@ -141,46 +139,6 @@ class Post(Base):
|
|||||||
tags: Mapped[list[Tag]] = relationship(secondary=post_tags)
|
tags: Mapped[list[Tag]] = relationship(secondary=post_tags)
|
||||||
|
|
||||||
|
|
||||||
class OrderStatus(int, Enum):
|
|
||||||
"""Integer-backed enum for order status."""
|
|
||||||
|
|
||||||
PENDING = 1
|
|
||||||
PROCESSING = 2
|
|
||||||
SHIPPED = 3
|
|
||||||
CANCELLED = 4
|
|
||||||
|
|
||||||
|
|
||||||
class Color(str, Enum):
|
|
||||||
"""String-backed enum for color."""
|
|
||||||
|
|
||||||
RED = "red"
|
|
||||||
GREEN = "green"
|
|
||||||
BLUE = "blue"
|
|
||||||
|
|
||||||
|
|
||||||
class Order(Base):
|
|
||||||
"""Test model with an IntEnum column (Enum(int, Enum)) and a raw Integer column."""
|
|
||||||
|
|
||||||
__tablename__ = "orders"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
||||||
name: Mapped[str] = mapped_column(String(100))
|
|
||||||
status: Mapped[OrderStatus] = mapped_column(SAEnum(OrderStatus))
|
|
||||||
priority: Mapped[int] = mapped_column(Integer)
|
|
||||||
color: Mapped[Color] = mapped_column(SAEnum(Color))
|
|
||||||
|
|
||||||
|
|
||||||
class Transfer(Base):
|
|
||||||
"""Test model with two FKs to the same table (users)."""
|
|
||||||
|
|
||||||
__tablename__ = "transfers"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
||||||
amount: Mapped[str] = mapped_column(String(50))
|
|
||||||
sender_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
|
|
||||||
receiver_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
|
|
||||||
|
|
||||||
|
|
||||||
class Article(Base):
|
class Article(Base):
|
||||||
"""Test article model with ARRAY and JSON columns."""
|
"""Test article model with ARRAY and JSON columns."""
|
||||||
|
|
||||||
@@ -342,44 +300,6 @@ class ArticleRead(PydanticBase):
|
|||||||
labels: list[str]
|
labels: list[str]
|
||||||
|
|
||||||
|
|
||||||
class OrderCreate(BaseModel):
|
|
||||||
"""Schema for creating an order."""
|
|
||||||
|
|
||||||
id: uuid.UUID | None = None
|
|
||||||
name: str
|
|
||||||
status: OrderStatus
|
|
||||||
priority: int = 0
|
|
||||||
color: Color = Color.RED
|
|
||||||
|
|
||||||
|
|
||||||
class OrderRead(PydanticBase):
|
|
||||||
"""Schema for reading an order."""
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
status: OrderStatus
|
|
||||||
priority: int
|
|
||||||
color: Color
|
|
||||||
|
|
||||||
|
|
||||||
class TransferCreate(BaseModel):
|
|
||||||
"""Schema for creating a transfer."""
|
|
||||||
|
|
||||||
id: uuid.UUID | None = None
|
|
||||||
amount: str
|
|
||||||
sender_id: uuid.UUID
|
|
||||||
receiver_id: uuid.UUID
|
|
||||||
|
|
||||||
|
|
||||||
class TransferRead(PydanticBase):
|
|
||||||
"""Schema for reading a transfer."""
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
amount: str
|
|
||||||
|
|
||||||
|
|
||||||
OrderCrud = CrudFactory(Order)
|
|
||||||
TransferCrud = CrudFactory(Transfer)
|
|
||||||
ArticleCrud = CrudFactory(Article)
|
ArticleCrud = CrudFactory(Article)
|
||||||
RoleCrud = CrudFactory(Role)
|
RoleCrud = CrudFactory(Role)
|
||||||
RoleCursorCrud = CrudFactory(Role, cursor_column=Role.id)
|
RoleCursorCrud = CrudFactory(Role, cursor_column=Role.id)
|
||||||
|
|||||||
@@ -38,10 +38,6 @@ from .conftest import (
|
|||||||
Tag,
|
Tag,
|
||||||
TagCreate,
|
TagCreate,
|
||||||
TagCrud,
|
TagCrud,
|
||||||
Transfer,
|
|
||||||
TransferCreate,
|
|
||||||
TransferCrud,
|
|
||||||
TransferRead,
|
|
||||||
User,
|
User,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserCrud,
|
UserCrud,
|
||||||
@@ -215,92 +211,6 @@ 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 TestResolveOrderColumns:
|
|
||||||
"""Tests for _resolve_order_columns logic."""
|
|
||||||
|
|
||||||
def test_returns_none_when_no_order_fields(self):
|
|
||||||
"""Returns None when cls.order_fields is None and no order_fields passed."""
|
|
||||||
|
|
||||||
class AbstractCrud(AsyncCrud[User]):
|
|
||||||
pass
|
|
||||||
|
|
||||||
assert AbstractCrud._resolve_order_columns(None) is None
|
|
||||||
|
|
||||||
def test_returns_none_when_empty_order_fields_passed(self):
|
|
||||||
"""Returns None when an empty list is passed explicitly."""
|
|
||||||
crud = CrudFactory(User)
|
|
||||||
assert crud._resolve_order_columns([]) is None
|
|
||||||
|
|
||||||
def test_returns_keys_from_class_order_fields(self):
|
|
||||||
"""Returns sorted column keys from cls.order_fields when no override passed."""
|
|
||||||
crud = CrudFactory(User, order_fields=[User.username])
|
|
||||||
result = crud._resolve_order_columns(None)
|
|
||||||
assert result is not None
|
|
||||||
assert "username" in result
|
|
||||||
|
|
||||||
def test_order_fields_override_takes_priority(self):
|
|
||||||
"""Explicit order_fields override cls.order_fields."""
|
|
||||||
crud = CrudFactory(User, order_fields=[User.username])
|
|
||||||
result = crud._resolve_order_columns([User.email])
|
|
||||||
assert result is not None
|
|
||||||
assert "email" in result
|
|
||||||
assert "username" not in result
|
|
||||||
|
|
||||||
def test_returns_sorted_keys(self):
|
|
||||||
"""Keys are returned in sorted order."""
|
|
||||||
crud = CrudFactory(User, order_fields=[User.email, User.username])
|
|
||||||
result = crud._resolve_order_columns(None)
|
|
||||||
assert result is not None
|
|
||||||
assert result == sorted(result)
|
|
||||||
|
|
||||||
def test_relation_tuple_produces_dunder_key(self):
|
|
||||||
"""A (rel, column) tuple produces a 'rel__column' key."""
|
|
||||||
crud = CrudFactory(User, order_fields=[(User.role, Role.name)])
|
|
||||||
result = crud._resolve_order_columns(None)
|
|
||||||
assert result == ["role__name"]
|
|
||||||
|
|
||||||
def test_mixed_flat_and_relation_fields(self):
|
|
||||||
"""Flat and relation fields can be mixed; keys are sorted."""
|
|
||||||
crud = CrudFactory(User, order_fields=[User.username, (User.role, Role.name)])
|
|
||||||
result = crud._resolve_order_columns(None)
|
|
||||||
assert result is not None
|
|
||||||
assert "username" in result
|
|
||||||
assert "role__name" in result
|
|
||||||
assert result == sorted(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."""
|
||||||
|
|
||||||
@@ -380,43 +290,6 @@ class TestDefaultLoadOptionsIntegration:
|
|||||||
assert result.data[0].role is not None
|
assert result.data[0].role is not None
|
||||||
assert result.data[0].role.name == "admin"
|
assert result.data[0].role.name == "admin"
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_default_load_options_applied_to_create(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""default_load_options loads relationships after create()."""
|
|
||||||
UserWithDefaultLoad = CrudFactory(
|
|
||||||
User, default_load_options=[selectinload(User.role)]
|
|
||||||
)
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
|
||||||
user = await UserWithDefaultLoad.create(
|
|
||||||
db_session,
|
|
||||||
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
|
||||||
)
|
|
||||||
assert user.role is not None
|
|
||||||
assert user.role.name == "admin"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_default_load_options_applied_to_update(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""default_load_options loads relationships after update()."""
|
|
||||||
UserWithDefaultLoad = CrudFactory(
|
|
||||||
User, default_load_options=[selectinload(User.role)]
|
|
||||||
)
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
|
||||||
user = await UserCrud.create(
|
|
||||||
db_session,
|
|
||||||
UserCreate(username="alice", email="alice@test.com"),
|
|
||||||
)
|
|
||||||
updated = await UserWithDefaultLoad.update(
|
|
||||||
db_session,
|
|
||||||
UserUpdate(role_id=role.id),
|
|
||||||
filters=[User.id == user.id],
|
|
||||||
)
|
|
||||||
assert updated.role is not None
|
|
||||||
assert updated.role.name == "admin"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_load_options_overrides_default_load_options(
|
async def test_load_options_overrides_default_load_options(
|
||||||
self, db_session: AsyncSession
|
self, db_session: AsyncSession
|
||||||
@@ -1377,128 +1250,6 @@ class TestCrudJoins:
|
|||||||
assert users[0].username == "multi_join"
|
assert users[0].username == "multi_join"
|
||||||
|
|
||||||
|
|
||||||
class TestCrudAliasedJoins:
|
|
||||||
"""Tests for CRUD operations with aliased joins (same table joined twice)."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_get_multi_with_aliased_joins(self, db_session: AsyncSession):
|
|
||||||
"""Aliased joins allow joining the same table twice."""
|
|
||||||
from sqlalchemy.orm import aliased
|
|
||||||
|
|
||||||
alice = await UserCrud.create(
|
|
||||||
db_session, UserCreate(username="alice", email="alice@test.com")
|
|
||||||
)
|
|
||||||
bob = await UserCrud.create(
|
|
||||||
db_session, UserCreate(username="bob", email="bob@test.com")
|
|
||||||
)
|
|
||||||
await TransferCrud.create(
|
|
||||||
db_session,
|
|
||||||
TransferCreate(amount="100", sender_id=alice.id, receiver_id=bob.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
Sender = aliased(User)
|
|
||||||
Receiver = aliased(User)
|
|
||||||
|
|
||||||
results = await TransferCrud.get_multi(
|
|
||||||
db_session,
|
|
||||||
joins=[
|
|
||||||
(Sender, Transfer.sender_id == Sender.id),
|
|
||||||
(Receiver, Transfer.receiver_id == Receiver.id),
|
|
||||||
],
|
|
||||||
filters=[Sender.username == "alice", Receiver.username == "bob"],
|
|
||||||
)
|
|
||||||
assert len(results) == 1
|
|
||||||
assert results[0].amount == "100"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_get_multi_aliased_no_match(self, db_session: AsyncSession):
|
|
||||||
"""Aliased joins correctly filter out non-matching rows."""
|
|
||||||
from sqlalchemy.orm import aliased
|
|
||||||
|
|
||||||
alice = await UserCrud.create(
|
|
||||||
db_session, UserCreate(username="alice", email="alice@test.com")
|
|
||||||
)
|
|
||||||
bob = await UserCrud.create(
|
|
||||||
db_session, UserCreate(username="bob", email="bob@test.com")
|
|
||||||
)
|
|
||||||
await TransferCrud.create(
|
|
||||||
db_session,
|
|
||||||
TransferCreate(amount="100", sender_id=alice.id, receiver_id=bob.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
Sender = aliased(User)
|
|
||||||
Receiver = aliased(User)
|
|
||||||
|
|
||||||
# bob is receiver, not sender — should return nothing
|
|
||||||
results = await TransferCrud.get_multi(
|
|
||||||
db_session,
|
|
||||||
joins=[
|
|
||||||
(Sender, Transfer.sender_id == Sender.id),
|
|
||||||
(Receiver, Transfer.receiver_id == Receiver.id),
|
|
||||||
],
|
|
||||||
filters=[Sender.username == "bob", Receiver.username == "alice"],
|
|
||||||
)
|
|
||||||
assert len(results) == 0
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_paginate_with_aliased_joins(self, db_session: AsyncSession):
|
|
||||||
"""Aliased joins work with offset_paginate."""
|
|
||||||
from sqlalchemy.orm import aliased
|
|
||||||
|
|
||||||
alice = await UserCrud.create(
|
|
||||||
db_session, UserCreate(username="alice", email="alice@test.com")
|
|
||||||
)
|
|
||||||
bob = await UserCrud.create(
|
|
||||||
db_session, UserCreate(username="bob", email="bob@test.com")
|
|
||||||
)
|
|
||||||
await TransferCrud.create(
|
|
||||||
db_session,
|
|
||||||
TransferCreate(amount="50", sender_id=alice.id, receiver_id=bob.id),
|
|
||||||
)
|
|
||||||
await TransferCrud.create(
|
|
||||||
db_session,
|
|
||||||
TransferCreate(amount="75", sender_id=bob.id, receiver_id=alice.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
Sender = aliased(User)
|
|
||||||
result = await TransferCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
joins=[(Sender, Transfer.sender_id == Sender.id)],
|
|
||||||
filters=[Sender.username == "alice"],
|
|
||||||
schema=TransferRead,
|
|
||||||
)
|
|
||||||
assert result.pagination.total_count == 1
|
|
||||||
assert result.data[0].amount == "50"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_count_with_aliased_join(self, db_session: AsyncSession):
|
|
||||||
"""Aliased joins work with count."""
|
|
||||||
from sqlalchemy.orm import aliased
|
|
||||||
|
|
||||||
alice = await UserCrud.create(
|
|
||||||
db_session, UserCreate(username="alice", email="alice@test.com")
|
|
||||||
)
|
|
||||||
bob = await UserCrud.create(
|
|
||||||
db_session, UserCreate(username="bob", email="bob@test.com")
|
|
||||||
)
|
|
||||||
await TransferCrud.create(
|
|
||||||
db_session,
|
|
||||||
TransferCreate(amount="10", sender_id=alice.id, receiver_id=bob.id),
|
|
||||||
)
|
|
||||||
await TransferCrud.create(
|
|
||||||
db_session,
|
|
||||||
TransferCreate(amount="20", sender_id=alice.id, receiver_id=bob.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
Sender = aliased(User)
|
|
||||||
count = await TransferCrud.count(
|
|
||||||
db_session,
|
|
||||||
joins=[(Sender, Transfer.sender_id == Sender.id)],
|
|
||||||
filters=[Sender.username == "alice"],
|
|
||||||
)
|
|
||||||
assert count == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestCrudFactoryM2M:
|
class TestCrudFactoryM2M:
|
||||||
"""Tests for CrudFactory with m2m_fields parameter."""
|
"""Tests for CrudFactory with m2m_fields parameter."""
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
116
uv.lock
generated
116
uv.lock
generated
@@ -235,7 +235,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.135.3"
|
version = "0.135.1"
|
||||||
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/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "3.0.3"
|
version = "2.4.3"
|
||||||
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.2"
|
version = "10.21"
|
||||||
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/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1064,27 +1064,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.8"
|
version = "0.15.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
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" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1228,26 +1228,26 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "ty"
|
||||||
version = "0.0.27"
|
version = "0.0.25"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
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" }
|
sdist = { url = "https://files.pythonhosted.org/packages/12/bf/3c3147c7237277b0e8a911ff89de7183408be96b31fb42b38edb666d287f/ty-0.0.25.tar.gz", hash = "sha256:8ae3891be17dfb6acab51a2df3a8f8f6c551eb60ea674c10946dc92aae8d4401", size = 5375500, upload-time = "2026-03-24T22:32:34.608Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ url = "https://files.pythonhosted.org/packages/82/c7/0147bfb543df97740b45b222c54ff79ef20fa57f14b9d2c1dab3cd7d3faa/ty-0.0.25-py3-none-win_arm64.whl", hash = "sha256:d80b8cd965cbacbfd887ac2d985f5b6da09b7aa3569371e2894e0b30b26b89cd", size = 10225494, upload-time = "2026-03-24T22:32:30.611Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1324,7 +1324,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.31"
|
version = "0.0.30"
|
||||||
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/d5/1a/9b6f5285c5aef648db38f9132f49a7059bd2c9d748f68ef0c52ed8afcff3/zensical-0.0.31.tar.gz", hash = "sha256:9c12f07bde70c4bfdb13d6cae1bedf8d18064d257a6e81128a152502b28a8fc3", size = 3891758, upload-time = "2026-04-01T11:30:21.88Z" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,15 +2,10 @@
|
|||||||
site_name = "FastAPI Toolsets"
|
site_name = "FastAPI Toolsets"
|
||||||
site_description = "Production-ready utilities for FastAPI applications."
|
site_description = "Production-ready utilities for FastAPI applications."
|
||||||
site_author = "d3vyce"
|
site_author = "d3vyce"
|
||||||
site_url = "https://fastapi-toolsets.d3vyce.fr/"
|
site_url = "https://fastapi-toolsets.d3vyce.fr"
|
||||||
copyright = "Copyright © 2026 d3vyce"
|
copyright = "Copyright © 2026 d3vyce"
|
||||||
repo_url = "https://github.com/d3vyce/fastapi-toolsets"
|
repo_url = "https://github.com/d3vyce/fastapi-toolsets"
|
||||||
|
|
||||||
[project.extra.version]
|
|
||||||
provider = "mike"
|
|
||||||
default = "stable"
|
|
||||||
alias = true
|
|
||||||
|
|
||||||
[project.theme]
|
[project.theme]
|
||||||
custom_dir = "docs/overrides"
|
custom_dir = "docs/overrides"
|
||||||
language = "en"
|
language = "en"
|
||||||
|
|||||||
Reference in New Issue
Block a user