mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 14:46:24 +02:00
Compare commits
24 Commits
51d3917421
...
feat/add-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
e1f96ad7fe
|
|||
|
0ed93d62c8
|
|||
|
|
2a49814818 | ||
|
|
f8e090c7c3 | ||
|
|
54decaf3e1 | ||
|
6b127d9645
|
|||
|
|
8bed96f4bf | ||
|
|
74d15e13bc | ||
|
|
e38d8d2d4f | ||
|
9b74f162ab
|
|||
|
|
ab125c6ea1 | ||
|
|
e388e26858 | ||
|
|
04da241294 | ||
|
|
bbe63edc46 | ||
|
|
0b17c77dee | ||
|
|
bce71bfd42 | ||
|
|
2f1eb4d468 | ||
|
|
1f06eab11d | ||
|
|
fac9aa6f60 | ||
|
|
f310466697 | ||
|
|
32059dcb02 | ||
|
|
f027981e80 | ||
|
|
5c1487c24a | ||
|
|
ebaa61525f |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -93,7 +96,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.python-version == '3.14'
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
report_type: coverage
|
||||
@@ -102,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: matrix.python-version == '3.14'
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
report_type: test_results
|
||||
|
||||
17
.github/workflows/docs.yml
vendored
17
.github/workflows/docs.yml
vendored
@@ -34,26 +34,17 @@ jobs:
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
DEPLOY_VERSION="v$(echo "$VERSION" | cut -d. -f1-2)"
|
||||
|
||||
# On new major: consolidate previous major's feature versions into vX
|
||||
# On new major: keep only the latest feature version of the previous major
|
||||
PREV_MAJOR=$((MAJOR - 1))
|
||||
OLD_FEATURE_VERSIONS=$(uv run mike list 2>/dev/null | grep -oE "^v${PREV_MAJOR}\.[0-9]+" || true)
|
||||
|
||||
if [ -n "$OLD_FEATURE_VERSIONS" ]; then
|
||||
LATEST_PREV_TAG=$(git tag -l "v${PREV_MAJOR}.*" | sort -V | tail -1)
|
||||
|
||||
if [ -n "$LATEST_PREV_TAG" ]; then
|
||||
git checkout "$LATEST_PREV_TAG" -- docs/ src/ zensical.toml
|
||||
if ! grep -q '\[project\.extra\.version\]' zensical.toml; then
|
||||
printf '\n[project.extra.version]\nprovider = "mike"\ndefault = "stable"\nalias = true\n' >> zensical.toml
|
||||
fi
|
||||
uv run mike deploy "v${PREV_MAJOR}"
|
||||
git checkout HEAD -- docs/ src/ zensical.toml
|
||||
fi
|
||||
|
||||
# Delete old feature versions
|
||||
LATEST_PREV=$(echo "$OLD_FEATURE_VERSIONS" | sort -t. -k2 -n | tail -1)
|
||||
echo "$OLD_FEATURE_VERSIONS" | while read -r OLD_V; do
|
||||
if [ "$OLD_V" != "$LATEST_PREV" ]; then
|
||||
echo "Deleting $OLD_V"
|
||||
uv run mike delete "$OLD_V"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
@@ -43,16 +43,16 @@ Declare `searchable_fields`, `facet_fields`, and `order_fields` once on [`CrudFa
|
||||
|
||||
## Routes
|
||||
|
||||
```python title="routes.py:1:17"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:1:17"
|
||||
```python title="routes.py:1:16"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:1:16"
|
||||
```
|
||||
|
||||
### Offset pagination
|
||||
|
||||
Best for admin panels or any UI that needs a total item count and numbered pages.
|
||||
|
||||
```python title="routes.py:20:40"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:20:40"
|
||||
```python title="routes.py:19:37"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:19:37"
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
```python title="routes.py:43:63"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:43:63"
|
||||
```python title="routes.py:40:58"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:40:58"
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
```python title="routes.py:66:90"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:66:90"
|
||||
```python title="routes.py:61:79"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:61:79"
|
||||
```
|
||||
|
||||
**Offset request** (default)
|
||||
|
||||
@@ -4,6 +4,93 @@ This page covers every breaking change introduced in **v3.0** and the steps requ
|
||||
|
||||
---
|
||||
|
||||
## CRUD
|
||||
|
||||
### Facet keys now always use the full relationship chain
|
||||
|
||||
In `v2`, relationship facet fields used only the terminal column key (e.g. `"name"` for `Role.name`) and only prepended the relationship name when two facet fields shared the same column key. In `v3`, facet keys **always** include the full relationship chain joined by `__`, regardless of collisions.
|
||||
|
||||
=== "Before (`v2`)"
|
||||
|
||||
```
|
||||
User.status -> status
|
||||
(User.role, Role.name) -> name
|
||||
(User.role, Role.permission, Permission.name) -> name
|
||||
```
|
||||
|
||||
=== "Now (`v3`)"
|
||||
|
||||
```
|
||||
User.status -> status
|
||||
(User.role, Role.name) -> role__name
|
||||
(User.role, Role.permission, Permission.name) -> role__permission__name
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `*_params` dependencies consolidated into per-paginate methods
|
||||
|
||||
The six individual dependency methods (`offset_params`, `cursor_params`, `paginate_params`, `filter_params`, `search_params`, `order_params`) have been **removed** and replaced by three consolidated methods that bundle pagination, search, filter, and order into a single `Depends()` call.
|
||||
|
||||
| Removed | Replacement |
|
||||
|---|---|
|
||||
| `offset_params()` + `filter_params()` + `search_params()` + `order_params()` | `offset_paginate_params()` |
|
||||
| `cursor_params()` + `filter_params()` + `search_params()` + `order_params()` | `cursor_paginate_params()` |
|
||||
| `paginate_params()` + `filter_params()` + `search_params()` + `order_params()` | `paginate_params()` |
|
||||
|
||||
Each new method accepts `search`, `filter`, and `order` boolean toggles (all `True` by default) to disable features you don't need.
|
||||
|
||||
=== "Before (`v2`)"
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.crud import OrderByClause
|
||||
|
||||
@router.get("/offset")
|
||||
async def list_articles_offset(
|
||||
session: SessionDep,
|
||||
params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))],
|
||||
filter_by: Annotated[dict, Depends(ArticleCrud.filter_params())],
|
||||
order_by: Annotated[OrderByClause | None, Depends(ArticleCrud.order_params(default_field=Article.created_at))],
|
||||
search: str | None = None,
|
||||
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||
return await ArticleCrud.offset_paginate(
|
||||
session=session,
|
||||
**params,
|
||||
search=search,
|
||||
filter_by=filter_by or None,
|
||||
order_by=order_by,
|
||||
schema=ArticleRead,
|
||||
)
|
||||
```
|
||||
|
||||
=== "Now (`v3`)"
|
||||
|
||||
```python
|
||||
@router.get("/offset")
|
||||
async def list_articles_offset(
|
||||
session: SessionDep,
|
||||
params: Annotated[
|
||||
dict,
|
||||
Depends(
|
||||
ArticleCrud.offset_paginate_params(
|
||||
default_page_size=20,
|
||||
default_order_field=Article.created_at,
|
||||
)
|
||||
),
|
||||
],
|
||||
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||
return await ArticleCrud.offset_paginate(session=session, **params, schema=ArticleRead)
|
||||
```
|
||||
|
||||
The same pattern applies to `cursor_paginate_params()` and `paginate_params()`. To disable a feature, pass the toggle:
|
||||
|
||||
```python
|
||||
# No search or ordering, only pagination + filtering
|
||||
ArticleCrud.offset_paginate_params(search=False, order=False)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Models
|
||||
|
||||
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,18 +159,15 @@ Three pagination methods are available. All return a typed response whose `pagi
|
||||
### Offset pagination
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
|
||||
@router.get("")
|
||||
async def get_users(
|
||||
session: SessionDep,
|
||||
items_per_page: int = 50,
|
||||
page: int = 1,
|
||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
||||
) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await UserCrud.offset_paginate(
|
||||
session=session,
|
||||
items_per_page=items_per_page,
|
||||
page=page,
|
||||
schema=UserRead,
|
||||
)
|
||||
return await UserCrud.offset_paginate(session=session, **params, 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):
|
||||
@@ -194,32 +191,13 @@ The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.Async
|
||||
|
||||
!!! info "Added in `v2.4.1`"
|
||||
|
||||
By default `offset_paginate` runs two queries: one for the page items and one `COUNT(*)` for `total_count`. On large tables the `COUNT` can be expensive. Pass `include_total=False` to skip it:
|
||||
By default `offset_paginate` runs two queries: one for the page items and one `COUNT(*)` for `total_count`. On large tables the `COUNT` can be expensive. Pass `include_total=False` to `offset_paginate_params()` to skip it:
|
||||
|
||||
```python
|
||||
result = await UserCrud.offset_paginate(
|
||||
session=session,
|
||||
page=page,
|
||||
items_per_page=items_per_page,
|
||||
include_total=False,
|
||||
schema=UserRead,
|
||||
)
|
||||
```
|
||||
|
||||
#### Pagination params dependency
|
||||
|
||||
!!! info "Added in `v2.4.1`"
|
||||
|
||||
Use [`offset_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_params) to generate a FastAPI dependency that injects `page` and `items_per_page` from query parameters with configurable defaults and a `max_page_size` cap:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
async def get_users(
|
||||
session: SessionDep,
|
||||
params: Annotated[dict, Depends(UserCrud.offset_params(default_page_size=20, max_page_size=100))],
|
||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(include_total=False))],
|
||||
) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||
```
|
||||
@@ -230,15 +208,9 @@ async def list_users(
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
cursor: str | None = None,
|
||||
items_per_page: int = 20,
|
||||
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
|
||||
) -> CursorPaginatedResponse[UserRead]:
|
||||
return await UserCrud.cursor_paginate(
|
||||
session=session,
|
||||
cursor=cursor,
|
||||
items_per_page=items_per_page,
|
||||
schema=UserRead,
|
||||
)
|
||||
return await UserCrud.cursor_paginate(session=session, **params, 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):
|
||||
@@ -291,24 +263,6 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.id)
|
||||
PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
||||
```
|
||||
|
||||
#### Pagination params dependency
|
||||
|
||||
!!! info "Added in `v2.4.1`"
|
||||
|
||||
Use [`cursor_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_params) to inject `cursor` and `items_per_page` from query parameters with a `max_page_size` cap:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
params: Annotated[dict, Depends(UserCrud.cursor_params(default_page_size=20, max_page_size=100))],
|
||||
) -> CursorPaginatedResponse[UserRead]:
|
||||
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
||||
```
|
||||
|
||||
### Unified endpoint (both strategies)
|
||||
|
||||
!!! info "Added in `v2.3.0`"
|
||||
@@ -316,25 +270,14 @@ async def list_users(
|
||||
[`paginate()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate) dispatches to `offset_paginate` or `cursor_paginate` based on a `pagination_type` query parameter, letting you expose **one endpoint** that supports both strategies. The `pagination_type` field in the response tells clients which strategy was used, enabling frontend discriminated-union typing.
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.crud import PaginationType
|
||||
from fastapi_toolsets.schemas import PaginatedResponse
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
pagination_type: PaginationType = PaginationType.OFFSET,
|
||||
page: int = Query(1, ge=1, description="Current page (offset only)"),
|
||||
cursor: str | None = Query(None, description="Cursor token (cursor only)"),
|
||||
items_per_page: int = Query(20, ge=1, le=100),
|
||||
params: Annotated[dict, Depends(UserCrud.paginate_params())],
|
||||
) -> PaginatedResponse[UserRead]:
|
||||
return await UserCrud.paginate(
|
||||
session,
|
||||
pagination_type=pagination_type,
|
||||
page=page,
|
||||
cursor=cursor,
|
||||
items_per_page=items_per_page,
|
||||
schema=UserRead,
|
||||
)
|
||||
return await UserCrud.paginate(session, **params, schema=UserRead)
|
||||
```
|
||||
|
||||
```
|
||||
@@ -342,25 +285,6 @@ GET /users?pagination_type=offset&page=2&items_per_page=10
|
||||
GET /users?pagination_type=cursor&cursor=eyJ2YWx1ZSI6...&items_per_page=10
|
||||
```
|
||||
|
||||
#### Pagination params dependency
|
||||
|
||||
!!! info "Added in `v2.4.1`"
|
||||
|
||||
Use [`paginate_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate_params) to inject all parameters at once with configurable defaults and a `max_page_size` cap:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
from fastapi_toolsets.schemas import PaginatedResponse
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
params: Annotated[dict, Depends(UserCrud.paginate_params(default_page_size=20, max_page_size=100))],
|
||||
) -> PaginatedResponse[UserRead]:
|
||||
return await UserCrud.paginate(session, **params, schema=UserRead)
|
||||
```
|
||||
|
||||
## Search
|
||||
|
||||
Two search strategies are available, both compatible with [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate).
|
||||
@@ -400,49 +324,63 @@ 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):
|
||||
|
||||
```python
|
||||
@router.get("")
|
||||
async def get_users(
|
||||
session: SessionDep,
|
||||
items_per_page: int = 50,
|
||||
page: int = 1,
|
||||
search: str | None = None,
|
||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
||||
) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await UserCrud.offset_paginate(
|
||||
session=session,
|
||||
items_per_page=items_per_page,
|
||||
page=page,
|
||||
search=search,
|
||||
schema=UserRead,
|
||||
)
|
||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||
```
|
||||
|
||||
```python
|
||||
@router.get("")
|
||||
async def get_users(
|
||||
session: SessionDep,
|
||||
cursor: str | None = None,
|
||||
items_per_page: int = 50,
|
||||
search: str | None = None,
|
||||
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
|
||||
) -> CursorPaginatedResponse[UserRead]:
|
||||
return await UserCrud.cursor_paginate(
|
||||
session=session,
|
||||
items_per_page=items_per_page,
|
||||
cursor=cursor,
|
||||
search=search,
|
||||
schema=UserRead,
|
||||
)
|
||||
return await UserCrud.cursor_paginate(session=session, **params, 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
|
||||
|
||||
!!! 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.
|
||||
|
||||
Facet fields use the same syntax as `searchable_fields` — direct columns or relationship tuples:
|
||||
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`:
|
||||
|
||||
```python
|
||||
UserCrud = CrudFactory(
|
||||
@@ -464,7 +402,47 @@ result = await UserCrud.offset_paginate(
|
||||
)
|
||||
```
|
||||
|
||||
The distinct values are returned in the `filter_attributes` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse):
|
||||
Or via the dependency to narrow which fields are exposed as query parameters:
|
||||
|
||||
```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
|
||||
{
|
||||
@@ -479,52 +457,14 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated
|
||||
}
|
||||
```
|
||||
|
||||
Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError).
|
||||
|
||||
!!! 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)
|
||||
```
|
||||
!!! info "Key format uses `__` as a separator for relationship chains."
|
||||
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).
|
||||
|
||||
## Sorting
|
||||
|
||||
!!! info "Added in `v1.3`"
|
||||
|
||||
Declare `order_fields` on the CRUD class to expose client-driven column ordering via `order_by` and `order` query parameters.
|
||||
Declare `order_fields` on the CRUD class. Relationship traversal is supported via tuples, using the same syntax as `searchable_fields` and `facet_fields`:
|
||||
|
||||
```python
|
||||
UserCrud = CrudFactory(
|
||||
@@ -532,46 +472,80 @@ UserCrud = CrudFactory(
|
||||
order_fields=[
|
||||
User.name,
|
||||
User.created_at,
|
||||
(User.role, Role.name), # sort by a related model column
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
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:
|
||||
You can override `order_fields` per call:
|
||||
|
||||
```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
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi_toolsets.crud import OrderByClause
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
order_by: Annotated[OrderByClause | None, Depends(UserCrud.order_params())],
|
||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
||||
) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await UserCrud.offset_paginate(session=session, order_by=order_by, schema=UserRead)
|
||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||
```
|
||||
|
||||
```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:
|
||||
|
||||
| Parameter | Type |
|
||||
| ---------- | --------------- |
|
||||
| `order_by` | `str | null` |
|
||||
| `order_by` | `str \| null` |
|
||||
| `order` | `asc` or `desc` |
|
||||
|
||||
```
|
||||
GET /users?order_by=name&order=asc → ORDER BY users.name ASC
|
||||
GET /users?order_by=name&order=desc → ORDER BY users.name DESC
|
||||
GET /users?order_by=role__name&order=desc → LEFT JOIN roles ON ... ORDER BY roles.name DESC
|
||||
```
|
||||
|
||||
An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
|
||||
!!! info "Relationship tuples are joined automatically."
|
||||
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:
|
||||
|
||||
```python
|
||||
UserOrderParams = UserCrud.order_params(order_fields=[User.name])
|
||||
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:
|
||||
|
||||
```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
|
||||
|
||||
!!! info "Added in `v1.1`"
|
||||
@@ -656,12 +630,11 @@ async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
|
||||
)
|
||||
|
||||
@router.get("")
|
||||
async def list_users(session: SessionDep, page: int = 1) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await crud.UserCrud.offset_paginate(
|
||||
session=session,
|
||||
page=page,
|
||||
schema=UserRead,
|
||||
)
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
params: Annotated[dict, Depends(crud.UserCrud.offset_paginate_params())],
|
||||
) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await crud.UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||
```
|
||||
|
||||
The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.
|
||||
|
||||
@@ -79,9 +79,6 @@ The examples above are already compatible with parallel test execution with `pyt
|
||||
|
||||
## Cleaning up tables
|
||||
|
||||
!!! warning
|
||||
Since `V2.1.0` `cleanup_tables` now live in `fastapi_toolsets.db`. For backward compatibility the function is still available in `fastapi_toolsets.pytest`, but this will be remove in `V3.0.0`.
|
||||
|
||||
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables), this will truncate all tables between tests for fast isolation:
|
||||
|
||||
```python
|
||||
|
||||
@@ -2,7 +2,6 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from fastapi_toolsets.crud import OrderByClause
|
||||
from fastapi_toolsets.schemas import (
|
||||
CursorPaginatedResponse,
|
||||
OffsetPaginatedResponse,
|
||||
@@ -22,21 +21,18 @@ async def list_articles_offset(
|
||||
session: SessionDep,
|
||||
params: Annotated[
|
||||
dict,
|
||||
Depends(ArticleCrud.offset_params(default_page_size=20, max_page_size=100)),
|
||||
Depends(
|
||||
ArticleCrud.offset_paginate_params(
|
||||
default_page_size=20,
|
||||
max_page_size=100,
|
||||
default_order_field=Article.created_at,
|
||||
)
|
||||
),
|
||||
],
|
||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
||||
order_by: Annotated[
|
||||
OrderByClause | None,
|
||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||
],
|
||||
search: str | None = None,
|
||||
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||
return await ArticleCrud.offset_paginate(
|
||||
session=session,
|
||||
**params,
|
||||
search=search,
|
||||
filter_by=filter_by or None,
|
||||
order_by=order_by,
|
||||
schema=ArticleRead,
|
||||
)
|
||||
|
||||
@@ -46,21 +42,18 @@ async def list_articles_cursor(
|
||||
session: SessionDep,
|
||||
params: Annotated[
|
||||
dict,
|
||||
Depends(ArticleCrud.cursor_params(default_page_size=20, max_page_size=100)),
|
||||
Depends(
|
||||
ArticleCrud.cursor_paginate_params(
|
||||
default_page_size=20,
|
||||
max_page_size=100,
|
||||
default_order_field=Article.created_at,
|
||||
)
|
||||
),
|
||||
],
|
||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
||||
order_by: Annotated[
|
||||
OrderByClause | None,
|
||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||
],
|
||||
search: str | None = None,
|
||||
) -> CursorPaginatedResponse[ArticleRead]:
|
||||
return await ArticleCrud.cursor_paginate(
|
||||
session=session,
|
||||
**params,
|
||||
search=search,
|
||||
filter_by=filter_by or None,
|
||||
order_by=order_by,
|
||||
schema=ArticleRead,
|
||||
)
|
||||
|
||||
@@ -70,20 +63,17 @@ async def list_articles(
|
||||
session: SessionDep,
|
||||
params: Annotated[
|
||||
dict,
|
||||
Depends(ArticleCrud.paginate_params(default_page_size=20, max_page_size=100)),
|
||||
Depends(
|
||||
ArticleCrud.paginate_params(
|
||||
default_page_size=20,
|
||||
max_page_size=100,
|
||||
default_order_field=Article.created_at,
|
||||
)
|
||||
),
|
||||
],
|
||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
||||
order_by: Annotated[
|
||||
OrderByClause | None,
|
||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||
],
|
||||
search: str | None = None,
|
||||
) -> PaginatedResponse[ArticleRead]:
|
||||
return await ArticleCrud.paginate(
|
||||
session,
|
||||
**params,
|
||||
search=search,
|
||||
filter_by=filter_by or None,
|
||||
order_by=order_by,
|
||||
schema=ArticleRead,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "fastapi-toolsets"
|
||||
version = "2.4.3"
|
||||
version = "3.0.3"
|
||||
description = "Production-ready utilities for FastAPI applications"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -21,4 +21,4 @@ Example usage:
|
||||
return Response(data={"user": user.username}, message="Success")
|
||||
"""
|
||||
|
||||
__version__ = "2.4.3"
|
||||
__version__ = "3.0.3"
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
"""Generic async CRUD operations for SQLAlchemy models."""
|
||||
|
||||
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
||||
from ..exceptions import (
|
||||
InvalidFacetFilterError,
|
||||
InvalidSearchColumnError,
|
||||
NoSearchableFieldsError,
|
||||
UnsupportedFacetTypeError,
|
||||
)
|
||||
from ..schemas import PaginationType
|
||||
from ..types import (
|
||||
FacetFieldType,
|
||||
JoinType,
|
||||
M2MFieldType,
|
||||
OrderByClause,
|
||||
OrderFieldType,
|
||||
SearchFieldType,
|
||||
)
|
||||
from .factory import AsyncCrud, CrudFactory
|
||||
from .factory import AsyncCrud, CrudFactory, lateral_load
|
||||
from .search import SearchConfig, get_searchable_fields
|
||||
|
||||
__all__ = [
|
||||
"AsyncCrud",
|
||||
"CrudFactory",
|
||||
"lateral_load",
|
||||
"FacetFieldType",
|
||||
"get_searchable_fields",
|
||||
"InvalidFacetFilterError",
|
||||
"InvalidSearchColumnError",
|
||||
"JoinType",
|
||||
"M2MFieldType",
|
||||
"NoSearchableFieldsError",
|
||||
"OrderByClause",
|
||||
"OrderFieldType",
|
||||
"PaginationType",
|
||||
"SearchConfig",
|
||||
"SearchFieldType",
|
||||
"UnsupportedFacetTypeError",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,28 @@ from collections.abc import Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from sqlalchemy import String, and_, or_, select
|
||||
from sqlalchemy import String, and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
from sqlalchemy.types import (
|
||||
ARRAY,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Enum,
|
||||
Integer,
|
||||
Numeric,
|
||||
Time,
|
||||
Uuid,
|
||||
)
|
||||
|
||||
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
||||
from ..exceptions import (
|
||||
InvalidFacetFilterError,
|
||||
InvalidSearchColumnError,
|
||||
NoSearchableFieldsError,
|
||||
UnsupportedFacetTypeError,
|
||||
)
|
||||
from ..types import FacetFieldType, SearchFieldType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -81,6 +97,7 @@ def build_search_filters(
|
||||
search: str | SearchConfig,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
default_fields: Sequence[SearchFieldType] | None = None,
|
||||
search_column: str | None = None,
|
||||
) -> tuple[list["ColumnElement[bool]"], list[InstrumentedAttribute[Any]]]:
|
||||
"""Build SQLAlchemy filter conditions for search.
|
||||
|
||||
@@ -89,6 +106,8 @@ def build_search_filters(
|
||||
search: Search string or SearchConfig
|
||||
search_fields: Fields specified per-call (takes priority)
|
||||
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:
|
||||
Tuple of (filter_conditions, joins_needed)
|
||||
@@ -115,6 +134,14 @@ def build_search_filters(
|
||||
if not fields:
|
||||
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()
|
||||
filters: list[ColumnElement[bool]] = []
|
||||
joins: list[InstrumentedAttribute[Any]] = []
|
||||
@@ -149,6 +176,11 @@ def build_search_filters(
|
||||
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]:
|
||||
"""Return a key for each facet field.
|
||||
|
||||
@@ -201,23 +233,47 @@ async def build_facets(
|
||||
rels = ()
|
||||
column = field
|
||||
|
||||
col_type = column.property.columns[0].type
|
||||
is_array = isinstance(col_type, ARRAY)
|
||||
|
||||
if is_array:
|
||||
unnested = func.unnest(column).label(column.key)
|
||||
q = select(unnested).select_from(model).distinct()
|
||||
else:
|
||||
q = select(column).select_from(model).distinct()
|
||||
|
||||
# Apply base joins (already done on main query, but needed here independently)
|
||||
# Apply base joins (deduplicated) — needed here independently
|
||||
seen_joins: set[str] = set()
|
||||
for rel in base_joins or []:
|
||||
rel_key = str(rel)
|
||||
if rel_key not in seen_joins:
|
||||
seen_joins.add(rel_key)
|
||||
q = q.outerjoin(rel)
|
||||
|
||||
# Add any extra joins required by this facet field that aren't already in base_joins
|
||||
# Add any extra joins required by this facet field that aren't already applied
|
||||
for rel in rels:
|
||||
if str(rel) not in existing_join_keys:
|
||||
rel_key = str(rel)
|
||||
if rel_key not in existing_join_keys and rel_key not in seen_joins:
|
||||
seen_joins.add(rel_key)
|
||||
q = q.outerjoin(rel)
|
||||
|
||||
if base_filters:
|
||||
q = q.where(and_(*base_filters))
|
||||
|
||||
if is_array:
|
||||
q = q.order_by(unnested)
|
||||
else:
|
||||
q = q.order_by(column)
|
||||
result = await session.execute(q)
|
||||
values = [row[0] for row in result.all() if row[0] is not None]
|
||||
col_type = column.property.columns[0].type
|
||||
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
|
||||
|
||||
pairs = await asyncio.gather(
|
||||
@@ -226,6 +282,22 @@ async def build_facets(
|
||||
return dict(pairs)
|
||||
|
||||
|
||||
_EQUALITY_TYPES = (String, Integer, Numeric, Date, DateTime, Time, Enum, Uuid)
|
||||
"""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(
|
||||
filter_by: dict[str, Any],
|
||||
facet_fields: Sequence[FacetFieldType],
|
||||
@@ -271,9 +343,42 @@ def build_filter_by(
|
||||
joins.append(rel)
|
||||
added_join_keys.add(rel_key)
|
||||
|
||||
col_type = column.property.columns[0].type
|
||||
if isinstance(col_type, Boolean):
|
||||
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):
|
||||
filters.append(column.overlap(value))
|
||||
else:
|
||||
filters.append(column.any(value))
|
||||
elif isinstance(col_type, Enum):
|
||||
enum_class = col_type.enum_class
|
||||
if enum_class is not None:
|
||||
|
||||
def _coerce_enum(v: Any) -> Any:
|
||||
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):
|
||||
if isinstance(value, list):
|
||||
filters.append(column.in_(value))
|
||||
else:
|
||||
filters.append(column == value)
|
||||
else:
|
||||
raise UnsupportedFacetTypeError(key, type(col_type).__name__)
|
||||
|
||||
return filters, joins
|
||||
|
||||
@@ -7,9 +7,11 @@ from .exceptions import (
|
||||
ForbiddenError,
|
||||
InvalidFacetFilterError,
|
||||
InvalidOrderFieldError,
|
||||
InvalidSearchColumnError,
|
||||
NoSearchableFieldsError,
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
UnsupportedFacetTypeError,
|
||||
generate_error_responses,
|
||||
)
|
||||
from .handler import init_exceptions_handlers
|
||||
@@ -23,7 +25,9 @@ __all__ = [
|
||||
"init_exceptions_handlers",
|
||||
"InvalidFacetFilterError",
|
||||
"InvalidOrderFieldError",
|
||||
"InvalidSearchColumnError",
|
||||
"NoSearchableFieldsError",
|
||||
"NotFoundError",
|
||||
"UnauthorizedError",
|
||||
"UnsupportedFacetTypeError",
|
||||
]
|
||||
|
||||
@@ -144,6 +144,61 @@ class InvalidFacetFilterError(ApiException):
|
||||
)
|
||||
|
||||
|
||||
class UnsupportedFacetTypeError(ApiException):
|
||||
"""Raised when a facet field has a column type not supported by filter_by."""
|
||||
|
||||
api_error = ApiError(
|
||||
code=400,
|
||||
msg="Unsupported Facet Type",
|
||||
desc="The column type is not supported for facet filtering.",
|
||||
err_code="FACET-TYPE-400",
|
||||
)
|
||||
|
||||
def __init__(self, key: str, col_type: str) -> None:
|
||||
"""Initialize the exception.
|
||||
|
||||
Args:
|
||||
key: The facet field key.
|
||||
col_type: The unsupported column type name.
|
||||
"""
|
||||
self.key = key
|
||||
self.col_type = col_type
|
||||
super().__init__(
|
||||
desc=(
|
||||
f"Facet field '{key}' has unsupported column type '{col_type}'. "
|
||||
f"Supported types: String, Integer, Numeric, Boolean, "
|
||||
f"Date, DateTime, Time, Enum, Uuid, ARRAY."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
"""Raised when order_by contains a field not in the allowed order fields."""
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ def _format_validation_error(
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
content=error_response.model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Prometheus metrics endpoint for FastAPI applications."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
@@ -55,10 +55,10 @@ def init_metrics(
|
||||
|
||||
# Partition collectors and cache env check at startup — both are stable for the app lifetime.
|
||||
async_collectors = [
|
||||
c for c in registry.get_collectors() if asyncio.iscoroutinefunction(c.func)
|
||||
c for c in registry.get_collectors() if inspect.iscoroutinefunction(c.func)
|
||||
]
|
||||
sync_collectors = [
|
||||
c for c in registry.get_collectors() if not asyncio.iscoroutinefunction(c.func)
|
||||
c for c in registry.get_collectors() if not inspect.iscoroutinefunction(c.func)
|
||||
]
|
||||
multiprocess_mode = _is_multiprocess()
|
||||
|
||||
|
||||
@@ -231,6 +231,13 @@ class EventSession(AsyncSession):
|
||||
k: v for k, v in field_changes.items() if k not in transient_ids
|
||||
}
|
||||
|
||||
# Suppress updates for deleted objects (row is gone, refresh would fail).
|
||||
if deletes and field_changes:
|
||||
deleted_ids = {id(o) for o, _ in deletes}
|
||||
field_changes = {
|
||||
k: v for k, v in field_changes.items() if k not in deleted_ids
|
||||
}
|
||||
|
||||
# Suppress updates for newly created objects (CREATE-only semantics).
|
||||
if creates and field_changes:
|
||||
create_ids = {id(o) for o in creates}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Pytest helper utilities for FastAPI testing."""
|
||||
|
||||
import os
|
||||
import warnings
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
@@ -16,31 +15,10 @@ from sqlalchemy.ext.asyncio import (
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from ..db import cleanup_tables as _cleanup_tables
|
||||
from ..db import create_database
|
||||
from ..db import cleanup_tables, create_database
|
||||
from ..models.watched import EventSession
|
||||
|
||||
|
||||
async def cleanup_tables(
|
||||
session: AsyncSession,
|
||||
base: type[DeclarativeBase],
|
||||
) -> None:
|
||||
"""Truncate all tables for fast between-test cleanup.
|
||||
|
||||
.. deprecated::
|
||||
Import ``cleanup_tables`` from ``fastapi_toolsets.db`` instead.
|
||||
This re-export will be removed in v3.0.0.
|
||||
"""
|
||||
warnings.warn(
|
||||
"Importing cleanup_tables from fastapi_toolsets.pytest is deprecated "
|
||||
"and will be removed in v3.0.0. "
|
||||
"Use 'from fastapi_toolsets.db import cleanup_tables' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
await _cleanup_tables(session=session, base=base)
|
||||
|
||||
|
||||
def _get_xdist_worker(default_test_db: str) -> str:
|
||||
"""Return the pytest-xdist worker name, or *default_test_db* when not running under xdist.
|
||||
|
||||
@@ -273,7 +251,7 @@ async def create_db_session(
|
||||
yield session
|
||||
|
||||
if cleanup:
|
||||
await _cleanup_tables(session=session, base=base)
|
||||
await cleanup_tables(session=session, base=base)
|
||||
|
||||
if drop_tables:
|
||||
async with engine.begin() as conn:
|
||||
|
||||
@@ -162,6 +162,8 @@ class PaginatedResponse(BaseResponse, Generic[DataT]):
|
||||
pagination: OffsetPagination | CursorPagination
|
||||
pagination_type: PaginationType | 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]] = {}
|
||||
|
||||
|
||||
@@ -15,13 +15,15 @@ ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
||||
SchemaType = TypeVar("SchemaType", bound=BaseModel)
|
||||
|
||||
# CRUD type aliases
|
||||
JoinType = list[tuple[type[DeclarativeBase], Any]]
|
||||
JoinType = list[tuple[type[DeclarativeBase] | Any, Any]]
|
||||
LateralJoinType = list[tuple[Any, Any]]
|
||||
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
||||
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
||||
|
||||
# Search / facet type aliases
|
||||
# Search / facet / order type aliases
|
||||
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
||||
FacetFieldType = SearchFieldType
|
||||
OrderFieldType = SearchFieldType
|
||||
|
||||
# Dependency type aliases
|
||||
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] | Any
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
@@ -12,13 +13,16 @@ from sqlalchemy import (
|
||||
Column,
|
||||
Date,
|
||||
DateTime,
|
||||
Enum as SAEnum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
JSON,
|
||||
Numeric,
|
||||
String,
|
||||
Table,
|
||||
Uuid,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
@@ -137,6 +141,57 @@ class Post(Base):
|
||||
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):
|
||||
"""Test article model with ARRAY and JSON columns."""
|
||||
|
||||
__tablename__ = "articles"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
labels: Mapped[list[str]] = mapped_column(ARRAY(String))
|
||||
metadata_: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
|
||||
class RoleCreate(BaseModel):
|
||||
"""Schema for creating a role."""
|
||||
|
||||
@@ -271,6 +326,61 @@ class ProductCreate(BaseModel):
|
||||
price: decimal.Decimal
|
||||
|
||||
|
||||
class ArticleCreate(BaseModel):
|
||||
"""Schema for creating an article."""
|
||||
|
||||
id: uuid.UUID | None = None
|
||||
title: str
|
||||
labels: list[str] = []
|
||||
|
||||
|
||||
class ArticleRead(PydanticBase):
|
||||
"""Schema for reading an article."""
|
||||
|
||||
id: uuid.UUID
|
||||
title: 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)
|
||||
RoleCrud = CrudFactory(Role)
|
||||
RoleCursorCrud = CrudFactory(Role, cursor_column=Role.id)
|
||||
IntRoleCursorCrud = CrudFactory(IntRole, cursor_column=IntRole.id)
|
||||
|
||||
@@ -6,9 +6,15 @@ import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from fastapi_toolsets.crud import CrudFactory, PaginationType
|
||||
from fastapi_toolsets.crud.factory import AsyncCrud, _CursorDirection
|
||||
from fastapi_toolsets.crud import CrudFactory, PaginationType, lateral_load
|
||||
from fastapi_toolsets.crud.factory import (
|
||||
AsyncCrud,
|
||||
_CursorDirection,
|
||||
_LateralLoad,
|
||||
_ResolvedLateral,
|
||||
)
|
||||
from fastapi_toolsets.exceptions import NotFoundError
|
||||
from fastapi_toolsets.schemas import PydanticBase
|
||||
|
||||
from .conftest import (
|
||||
EventCreate,
|
||||
@@ -38,6 +44,10 @@ from .conftest import (
|
||||
Tag,
|
||||
TagCreate,
|
||||
TagCrud,
|
||||
Transfer,
|
||||
TransferCreate,
|
||||
TransferCrud,
|
||||
TransferRead,
|
||||
User,
|
||||
UserCreate,
|
||||
UserCrud,
|
||||
@@ -47,6 +57,12 @@ from .conftest import (
|
||||
)
|
||||
|
||||
|
||||
class UserWithRoleRead(PydanticBase):
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
role: RoleRead | None = None
|
||||
|
||||
|
||||
class TestCrudFactory:
|
||||
"""Tests for CrudFactory."""
|
||||
|
||||
@@ -204,11 +220,97 @@ class TestResolveLoadOptions:
|
||||
assert crud._resolve_load_options(None) is None
|
||||
|
||||
def test_empty_list_overrides_default(self):
|
||||
"""An empty list is a valid override and disables default_load_options."""
|
||||
"""An explicit empty list disables default_load_options (no options applied)."""
|
||||
default = [selectinload(User.role)]
|
||||
crud = CrudFactory(User, default_load_options=default)
|
||||
# Empty list is not None, so it should replace default
|
||||
assert crud._resolve_load_options([]) == []
|
||||
# Empty list replaces default; None and [] are both falsy → no options applied
|
||||
assert not 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:
|
||||
@@ -269,13 +371,6 @@ class TestDefaultLoadOptionsIntegration:
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""default_load_options loads relationships automatically on offset_paginate()."""
|
||||
from fastapi_toolsets.schemas import PydanticBase
|
||||
|
||||
class UserWithRoleRead(PydanticBase):
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
role: RoleRead | None = None
|
||||
|
||||
UserWithDefaultLoad = CrudFactory(
|
||||
User, default_load_options=[selectinload(User.role)]
|
||||
)
|
||||
@@ -290,6 +385,43 @@ class TestDefaultLoadOptionsIntegration:
|
||||
assert result.data[0].role is not None
|
||||
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
|
||||
async def test_load_options_overrides_default_load_options(
|
||||
self, db_session: AsyncSession
|
||||
@@ -1250,6 +1382,128 @@ class TestCrudJoins:
|
||||
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:
|
||||
"""Tests for CrudFactory with m2m_fields parameter."""
|
||||
|
||||
@@ -2213,12 +2467,7 @@ class TestCursorPaginateExtraOptions:
|
||||
@pytest.mark.anyio
|
||||
async def test_with_load_options(self, db_session: AsyncSession):
|
||||
"""cursor_paginate passes load_options to the query."""
|
||||
from fastapi_toolsets.schemas import CursorPagination, PydanticBase
|
||||
|
||||
class UserWithRoleRead(PydanticBase):
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
role: RoleRead | None = None
|
||||
from fastapi_toolsets.schemas import CursorPagination
|
||||
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="manager"))
|
||||
for i in range(3):
|
||||
@@ -2584,3 +2833,445 @@ class TestPaginate:
|
||||
|
||||
assert isinstance(result.pagination, OffsetPagination)
|
||||
assert result.pagination.total_count is None
|
||||
|
||||
|
||||
class TestLateralLoadValidation:
|
||||
"""lateral_load() raises immediately for bad relationship types."""
|
||||
|
||||
def test_valid_many_to_one_returns_marker(self):
|
||||
"""lateral_load() on a Many:One rel returns a _LateralLoad with rel_attr set."""
|
||||
marker = lateral_load(User.role)
|
||||
assert isinstance(marker, _LateralLoad)
|
||||
assert marker.rel_attr is User.role
|
||||
|
||||
def test_raises_type_error_for_plain_column(self):
|
||||
"""lateral_load() raises TypeError when passed a plain column."""
|
||||
with pytest.raises(TypeError, match="relationship attribute"):
|
||||
lateral_load(User.username)
|
||||
|
||||
def test_raises_value_error_for_many_to_many(self):
|
||||
"""lateral_load() raises ValueError for Many:Many (secondary table)."""
|
||||
with pytest.raises(ValueError, match="Many:Many"):
|
||||
lateral_load(Post.tags)
|
||||
|
||||
def test_raises_value_error_for_one_to_many(self):
|
||||
"""lateral_load() raises ValueError for One:Many (uselist=True)."""
|
||||
with pytest.raises(ValueError, match="One:Many"):
|
||||
lateral_load(Role.users)
|
||||
|
||||
|
||||
class TestLateralLoadInSubclass:
|
||||
"""lateral_load() markers in default_load_options are processed at class definition."""
|
||||
|
||||
def test_marker_extracted_from_default_load_options(self):
|
||||
"""_LateralLoad is removed from default_load_options and stored in _resolved_lateral."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
assert UserLateralCrud.default_load_options is None
|
||||
assert UserLateralCrud._resolved_lateral is not None
|
||||
|
||||
def test_resolved_lateral_has_one_join_and_eager(self):
|
||||
"""_resolved_lateral contains exactly one join and one eager option."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
resolved = UserLateralCrud._resolved_lateral
|
||||
assert isinstance(resolved, _ResolvedLateral)
|
||||
assert len(resolved.joins) == 1
|
||||
assert len(resolved.eager) == 1
|
||||
|
||||
def test_regular_options_preserved_alongside_lateral(self):
|
||||
"""Non-lateral opts stay in default_load_options; lateral marker is extracted."""
|
||||
regular = selectinload(User.role)
|
||||
|
||||
class UserMixedCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role), regular]
|
||||
|
||||
assert UserMixedCrud._resolved_lateral is not None
|
||||
assert UserMixedCrud.default_load_options == [regular]
|
||||
|
||||
def test_no_lateral_leaves_default_load_options_untouched(self):
|
||||
"""When no lateral marker is present, default_load_options is unchanged."""
|
||||
opts = [selectinload(User.role)]
|
||||
|
||||
class UserNormalCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = opts
|
||||
|
||||
assert UserNormalCrud.default_load_options is opts
|
||||
assert UserNormalCrud._resolved_lateral is None
|
||||
|
||||
def test_no_default_load_options_leaves_resolved_lateral_none(self):
|
||||
"""_resolved_lateral stays None when default_load_options is not set."""
|
||||
|
||||
class UserPlainCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
|
||||
assert UserPlainCrud._resolved_lateral is None
|
||||
|
||||
|
||||
class TestResolveLoadOptionsWithLateral:
|
||||
"""_resolve_load_options always appends lateral eager options."""
|
||||
|
||||
def test_lateral_eager_included_when_no_call_site_opts(self):
|
||||
"""contains_eager from lateral_load is returned when load_options=None."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
resolved = UserLateralCrud._resolve_load_options(None)
|
||||
assert resolved is not None
|
||||
assert len(resolved) == 1 # the contains_eager
|
||||
|
||||
def test_call_site_opts_bypass_lateral_eager(self):
|
||||
"""When call-site load_options are provided, lateral eager is NOT appended."""
|
||||
extra = selectinload(User.role)
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
resolved = UserLateralCrud._resolve_load_options([extra])
|
||||
assert resolved is not None
|
||||
assert len(resolved) == 1 # only the call-site option; lateral eager skipped
|
||||
|
||||
def test_lateral_eager_appended_to_default_load_options(self):
|
||||
"""default_load_options (regular) + lateral eager are both returned."""
|
||||
regular = selectinload(User.role)
|
||||
|
||||
class UserMixedCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role), regular]
|
||||
|
||||
resolved = UserMixedCrud._resolve_load_options(None)
|
||||
assert resolved is not None
|
||||
assert len(resolved) == 2
|
||||
|
||||
|
||||
class TestGetLateralJoins:
|
||||
"""_get_lateral_joins merges auto-resolved and manual lateral_joins."""
|
||||
|
||||
def test_returns_none_when_no_lateral_configured(self):
|
||||
"""Returns None when neither lateral_joins nor lateral_load is set."""
|
||||
|
||||
class UserPlainCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
|
||||
assert UserPlainCrud._get_lateral_joins() is None
|
||||
|
||||
def test_returns_resolved_lateral_joins(self):
|
||||
"""Returns the join tuple built from lateral_load()."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
joins = UserLateralCrud._get_lateral_joins()
|
||||
assert joins is not None
|
||||
assert len(joins) == 1
|
||||
|
||||
def test_manual_lateral_joins_included(self):
|
||||
"""Manual lateral_joins class var is included in _get_lateral_joins."""
|
||||
from sqlalchemy import select, true
|
||||
|
||||
manual_sub = select(Role).where(Role.id == User.role_id).lateral("_manual_role")
|
||||
|
||||
class UserManualCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
lateral_joins = [(manual_sub, true())]
|
||||
|
||||
joins = UserManualCrud._get_lateral_joins()
|
||||
assert joins is not None
|
||||
assert len(joins) == 1
|
||||
|
||||
def test_manual_and_auto_lateral_joins_merged(self):
|
||||
"""Both manual lateral_joins and auto-resolved from lateral_load are combined."""
|
||||
from sqlalchemy import select, true
|
||||
|
||||
manual_sub = select(Role).where(Role.id == User.role_id).lateral("_manual_role")
|
||||
|
||||
class UserBothCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
lateral_joins = [(manual_sub, true())]
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
joins = UserBothCrud._get_lateral_joins()
|
||||
assert joins is not None
|
||||
assert len(joins) == 2
|
||||
|
||||
|
||||
class TestLateralLoadIntegration:
|
||||
"""lateral_load() in real DB queries: relationship loaded, pagination correct."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_loads_relationship(self, db_session: AsyncSession):
|
||||
"""get() populates the relationship via lateral join."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
user = await UserCrud.create(
|
||||
db_session,
|
||||
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
||||
)
|
||||
|
||||
fetched = await UserLateralCrud.get(db_session, [User.id == user.id])
|
||||
assert fetched.role is not None
|
||||
assert fetched.role.name == "admin"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_null_fk_preserved(self, db_session: AsyncSession):
|
||||
"""User with null role_id still returned (LEFT JOIN behaviour)."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
user = await UserCrud.create(
|
||||
db_session, UserCreate(username="bob", email="bob@test.com")
|
||||
)
|
||||
|
||||
fetched = await UserLateralCrud.get(db_session, [User.id == user.id])
|
||||
assert fetched is not None
|
||||
assert fetched.role is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_first_loads_relationship(self, db_session: AsyncSession):
|
||||
"""first() populates the relationship via lateral join."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="editor"))
|
||||
await UserCrud.create(
|
||||
db_session,
|
||||
UserCreate(username="carol", email="carol@test.com", role_id=role.id),
|
||||
)
|
||||
|
||||
user = await UserLateralCrud.first(db_session)
|
||||
assert user is not None
|
||||
assert user.role is not None
|
||||
assert user.role.name == "editor"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_multi_loads_relationship(self, db_session: AsyncSession):
|
||||
"""get_multi() populates the relationship via lateral join for all rows."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="member"))
|
||||
for i in range(3):
|
||||
await UserCrud.create(
|
||||
db_session,
|
||||
UserCreate(
|
||||
username=f"user{i}", email=f"u{i}@test.com", role_id=role.id
|
||||
),
|
||||
)
|
||||
|
||||
users = await UserLateralCrud.get_multi(db_session)
|
||||
assert len(users) == 3
|
||||
assert all(u.role is not None and u.role.name == "member" for u in users)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offset_paginate_correct_count(self, db_session: AsyncSession):
|
||||
"""offset_paginate total_count is not inflated by the lateral join."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
for i in range(5):
|
||||
await UserCrud.create(
|
||||
db_session,
|
||||
UserCreate(
|
||||
username=f"user{i}", email=f"u{i}@test.com", role_id=role.id
|
||||
),
|
||||
)
|
||||
|
||||
result = await UserLateralCrud.offset_paginate(
|
||||
db_session, schema=UserWithRoleRead, items_per_page=10
|
||||
)
|
||||
assert result.pagination.total_count == 5
|
||||
assert len(result.data) == 5
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offset_paginate_loads_relationship(self, db_session: AsyncSession):
|
||||
"""offset_paginate serializes relationship data loaded via lateral."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
await UserCrud.create(
|
||||
db_session,
|
||||
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
||||
)
|
||||
|
||||
result = await UserLateralCrud.offset_paginate(
|
||||
db_session, schema=UserWithRoleRead, items_per_page=10
|
||||
)
|
||||
assert result.data[0].role is not None
|
||||
assert result.data[0].role.name == "admin"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offset_paginate_mixed_null_fk(self, db_session: AsyncSession):
|
||||
"""offset_paginate returns all users including those with null role_id."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
await UserCrud.create(
|
||||
db_session,
|
||||
UserCreate(username="with_role", email="a@test.com", role_id=role.id),
|
||||
)
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="no_role", email="b@test.com")
|
||||
)
|
||||
|
||||
result = await UserLateralCrud.offset_paginate(
|
||||
db_session, schema=UserWithRoleRead, items_per_page=10
|
||||
)
|
||||
assert result.pagination.total_count == 2
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cursor_paginate_loads_relationship(self, db_session: AsyncSession):
|
||||
"""cursor_paginate populates the relationship via lateral join."""
|
||||
|
||||
class UserLateralCursorCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
cursor_column = User.id
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
for i in range(3):
|
||||
await UserCrud.create(
|
||||
db_session,
|
||||
UserCreate(
|
||||
username=f"user{i}", email=f"u{i}@test.com", role_id=role.id
|
||||
),
|
||||
)
|
||||
|
||||
result = await UserLateralCursorCrud.cursor_paginate(
|
||||
db_session, schema=UserWithRoleRead, items_per_page=10
|
||||
)
|
||||
assert len(result.data) == 3
|
||||
assert all(item.role is not None for item in result.data)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offset_paginate_with_search_and_lateral(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""search filter works alongside lateral join."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
searchable_fields = [User.username]
|
||||
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
await UserCrud.create(
|
||||
db_session,
|
||||
UserCreate(username="alice", email="a@test.com", role_id=role.id),
|
||||
)
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="bob", email="b@test.com", role_id=role.id)
|
||||
)
|
||||
|
||||
result = await UserLateralCrud.offset_paginate(
|
||||
db_session, schema=UserWithRoleRead, search="alice", items_per_page=10
|
||||
)
|
||||
assert result.pagination.total_count == 1
|
||||
assert result.data[0].username == "alice"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_first_call_site_load_options_bypasses_lateral(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""When load_options is provided, lateral join is skipped (no conflict)."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
user = await UserCrud.create(
|
||||
db_session,
|
||||
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
||||
)
|
||||
|
||||
# Passing explicit load_options bypasses the lateral join — role loaded via selectinload
|
||||
fetched = await UserLateralCrud.first(
|
||||
db_session,
|
||||
filters=[User.id == user.id],
|
||||
load_options=[selectinload(User.role)],
|
||||
)
|
||||
assert fetched is not None
|
||||
assert fetched.role is not None
|
||||
assert fetched.role.name == "admin"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_multi_call_site_load_options_bypasses_lateral(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""When load_options is provided, lateral join is skipped (no conflict)."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="viewer"))
|
||||
for i in range(2):
|
||||
await UserCrud.create(
|
||||
db_session,
|
||||
UserCreate(username=f"u{i}", email=f"u{i}@test.com", role_id=role.id),
|
||||
)
|
||||
|
||||
# Passing explicit load_options bypasses the lateral join — role loaded via selectinload
|
||||
users = await UserLateralCrud.get_multi(
|
||||
db_session, load_options=[selectinload(User.role)]
|
||||
)
|
||||
assert len(users) == 2
|
||||
assert all(u.role is not None and u.role.name == "viewer" for u in users)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offset_paginate_call_site_load_options_bypasses_lateral(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""When load_options is provided, lateral join is skipped (no conflict)."""
|
||||
|
||||
class UserLateralCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
default_load_options = [lateral_load(User.role)]
|
||||
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="editor"))
|
||||
for i in range(3):
|
||||
await UserCrud.create(
|
||||
db_session,
|
||||
UserCreate(username=f"e{i}", email=f"e{i}@test.com", role_id=role.id),
|
||||
)
|
||||
|
||||
# Passing explicit load_options bypasses the lateral join — role loaded via selectinload
|
||||
result = await UserLateralCrud.offset_paginate(
|
||||
db_session,
|
||||
schema=UserWithRoleRead,
|
||||
items_per_page=10,
|
||||
load_options=[selectinload(User.role)],
|
||||
)
|
||||
assert result.pagination.total_count == 3
|
||||
assert all(item.role is not None for item in result.data)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1041,6 +1041,25 @@ class TestTransientObject:
|
||||
assert len(creates) == 1
|
||||
assert len(deletes) == 1
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_then_delete_suppresses_update_callback(self, mixin_session):
|
||||
"""UPDATE callback is suppressed when the object is also deleted in the same transaction."""
|
||||
obj = WatchedModel(status="initial", other="x")
|
||||
mixin_session.add(obj)
|
||||
await mixin_session.commit()
|
||||
|
||||
_test_events.clear()
|
||||
|
||||
obj.status = "changed"
|
||||
await mixin_session.flush()
|
||||
await mixin_session.delete(obj)
|
||||
await mixin_session.commit()
|
||||
|
||||
updates = [e for e in _test_events if e["event"] == "update"]
|
||||
deletes = [e for e in _test_events if e["event"] == "delete"]
|
||||
assert updates == []
|
||||
assert len(deletes) == 1
|
||||
|
||||
|
||||
class TestPolymorphism:
|
||||
"""Event dispatch with STI (Single Table Inheritance)."""
|
||||
|
||||
@@ -374,19 +374,6 @@ class TestCreateDbSession:
|
||||
pass
|
||||
|
||||
|
||||
class TestDeprecatedCleanupTables:
|
||||
"""Tests for the deprecated cleanup_tables re-export in fastapi_toolsets.pytest."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_emits_deprecation_warning(self):
|
||||
"""cleanup_tables imported from fastapi_toolsets.pytest emits DeprecationWarning."""
|
||||
from fastapi_toolsets.pytest.utils import cleanup_tables
|
||||
|
||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
||||
with pytest.warns(DeprecationWarning, match="fastapi_toolsets.db"):
|
||||
await cleanup_tables(session, Base)
|
||||
|
||||
|
||||
class TestGetXdistWorker:
|
||||
"""Tests for _get_xdist_worker helper."""
|
||||
|
||||
|
||||
116
uv.lock
generated
116
uv.lock
generated
@@ -235,7 +235,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.135.1"
|
||||
version = "0.135.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -244,14 +244,14 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-toolsets"
|
||||
version = "2.4.3"
|
||||
version = "3.0.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "asyncpg" },
|
||||
@@ -894,15 +894,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pymdown-extensions"
|
||||
version = "10.21"
|
||||
version = "10.21.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1064,27 +1064,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.7"
|
||||
version = "0.15.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" },
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1228,26 +1228,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.25"
|
||||
version = "0.0.27"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/bf/3c3147c7237277b0e8a911ff89de7183408be96b31fb42b38edb666d287f/ty-0.0.25.tar.gz", hash = "sha256:8ae3891be17dfb6acab51a2df3a8f8f6c551eb60ea674c10946dc92aae8d4401", size = 5375500, upload-time = "2026-03-24T22:32:34.608Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/de/e5cf1f151cf52fe1189e42d03d90909d7d1354fdc0c1847cbb63a0baa3da/ty-0.0.27.tar.gz", hash = "sha256:d7a8de3421d92420b40c94fe7e7d4816037560621903964dd035cf9bd0204a73", size = 5424130, upload-time = "2026-03-31T19:07:20.806Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a4/6c289cbd1474285223124a4ffb55c078dbe9ae1d925d0b6a948643c7f115/ty-0.0.25-py3-none-linux_armv6l.whl", hash = "sha256:26d6d5aede5d54fb055779460f896d9c1473c6fb996716bd11cb90f027d8fee7", size = 10452747, upload-time = "2026-03-24T22:32:32.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/13/74cb9de356b9ceb3f281ab048f8c4ac2207122161b0ac0066886ce129abe/ty-0.0.25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aedcfbc7b6b96dbc55b0da78fa02bd049373ff3d8a827f613dadd8bd17d10758", size = 10271349, upload-time = "2026-03-24T22:32:13.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/93/ffc5a20cc9e14fa9b32b0c54884864bede30d144ce2ae013805bce0c86d0/ty-0.0.25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0a8fb3c1e28f73618941811e2568dca195178a1a6314651d4ee97086a4497253", size = 9730308, upload-time = "2026-03-24T22:32:19.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/78/52e05ef32a5f172fce70633a4e19d8e04364271a4322ae12382c7344b0de/ty-0.0.25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814870b7f347b5d0276304cddb98a0958f08de183bf159abc920ebe321247ad4", size = 10247664, upload-time = "2026-03-24T22:32:08.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/64/0d0a47ed0aa1d634c666c2cc15d3b0af4b95d0fd3dbb796032bd493f3433/ty-0.0.25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:781150e23825dc110cd5e1f50ca3d61664f7a5db5b4a55d5dbf7d3b1e246b917", size = 10261961, upload-time = "2026-03-24T22:32:43.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/ba/4666b96f0499465efb97c244554107c541d74a1add393e62276b3de9b54f/ty-0.0.25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc81ff2a0143911321251dc81d1c259fa5cdc56d043019a733c845d55409e2a", size = 10746076, upload-time = "2026-03-24T22:32:26.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ed/aa958ccbcd85cc206600e48fbf0a1c27aef54b4b90112d9a73f69ed0c739/ty-0.0.25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f03c5c5b5c10355ea030cbe3cd93b2e759b9492c66688288ea03a68086069f2e", size = 11287331, upload-time = "2026-03-24T22:32:21.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/e4/f4a004e1952e6042f5bfeeb7d09cffb379270ef009d9f8568471863e86e6/ty-0.0.25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fc1ef49cd6262eb9223ccf6e258ac899aaa53e7dc2151ba65a2c9fa248dfa75", size = 11028804, upload-time = "2026-03-24T22:32:39.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/32/5c15bb8ea20ed54d43c734f253a2a5da95d41474caecf4ef3682df9f68f5/ty-0.0.25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad98da1393161096235a387cc36abecd31861060c68416761eccdb7c1bc326b", size = 10845246, upload-time = "2026-03-24T22:32:41.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/fe/4ddd83e810c8682fcfada0d1c9d38936a34a024d32d7736075c1e53a038e/ty-0.0.25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2d4336aa5381eb4eab107c3dec75fe22943a648ef6646f5a8431ef1c8cdabb66", size = 10233515, upload-time = "2026-03-24T22:32:17.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/db/9fe54f6fb952e5b218f2e661e64ed656512edf2046cfbb9c159558e255db/ty-0.0.25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e10ed39564227de2b7bd89398250b65daaedbef15a25cef8eee70078f5d9e0b2", size = 10275289, upload-time = "2026-03-24T22:32:28.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/e0/090d7b33791b42bc7ec29463ac6a634738e16b289e027608ebe542682773/ty-0.0.25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aca04e9ed9b61c706064a1c0b71a247c3f92f373d0222103f3bc54b649421796", size = 10461195, upload-time = "2026-03-24T22:32:24.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/31/5bf12bce01b80b72a7a4e627380779b41510e730f6000862a1d078e423f7/ty-0.0.25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:18a5443e4ef339c1bd8c57fc13112c22080617ea582bfc22b497d82d65361325", size = 10931471, upload-time = "2026-03-24T22:32:14.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5e/ab60c11f8a6dd2a0ae96daac83458ef2e9be1ae70481d1ad9c59d3eaf20f/ty-0.0.25-py3-none-win32.whl", hash = "sha256:a685b9a611b69195b5a557e05dbb7ebcd12815f6c32fb27fdf15edeb1fa33d8f", size = 9835974, upload-time = "2026-03-24T22:32:36.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/55/625acc2ef34646268bc2baa8fdd6e22fb47cd5965e2acd3be92c687fb6b0/ty-0.0.25-py3-none-win_amd64.whl", hash = "sha256:0d4d37a1f1ab7f2669c941c38c65144ff223eb51ececd7ccfc0d623afbc0f729", size = 10815449, upload-time = "2026-03-24T22:32:11.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/c7/0147bfb543df97740b45b222c54ff79ef20fa57f14b9d2c1dab3cd7d3faa/ty-0.0.25-py3-none-win_arm64.whl", hash = "sha256:d80b8cd965cbacbfd887ac2d985f5b6da09b7aa3569371e2894e0b30b26b89cd", size = 10225494, upload-time = "2026-03-24T22:32:30.611Z" },
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/0e/1a/824b3496d66852ed7d5d68d9787711131552b68dce8835ce9410db32e618/ty-0.0.27-py3-none-win_arm64.whl", hash = "sha256:95bf8d01eb96bb2ba3ffc39faff19da595176448e80871a7b362f4d2de58476c", size = 10376689, upload-time = "2026-03-31T19:07:25.732Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1324,7 +1324,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zensical"
|
||||
version = "0.0.30"
|
||||
version = "0.0.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1334,18 +1334,18 @@ dependencies = [
|
||||
{ name = "pymdown-extensions" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/53/5e551f8912718816733a75adcb53a0787b2d2edca5869c156325aaf82e24/zensical-0.0.30.tar.gz", hash = "sha256:408b531683f6bcb6cc5ab928146d2c68afbc16fac4eda87ae3dd20af1498180f", size = 3844287, upload-time = "2026-03-28T17:55:52.836Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/1a/9b6f5285c5aef648db38f9132f49a7059bd2c9d748f68ef0c52ed8afcff3/zensical-0.0.31.tar.gz", hash = "sha256:9c12f07bde70c4bfdb13d6cae1bedf8d18064d257a6e81128a152502b28a8fc3", size = 3891758, upload-time = "2026-04-01T11:30:21.88Z" }
|
||||
wheels = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/a1/59/23b6c7ff062e2b299cc60e333095e853f9d38d1b5abe743c7b94c4ac432c/zensical-0.0.30-cp310-abi3-win_amd64.whl", hash = "sha256:b879dbf4c69d3ea41694bae33e1b948847e635dcbcd6ec8c522920833379dd48", size = 12101867, upload-time = "2026-03-28T17:55:50.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||
]
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
site_name = "FastAPI Toolsets"
|
||||
site_description = "Production-ready utilities for FastAPI applications."
|
||||
site_author = "d3vyce"
|
||||
site_url = "https://fastapi-toolsets.d3vyce.fr"
|
||||
site_url = "https://fastapi-toolsets.d3vyce.fr/"
|
||||
copyright = "Copyright © 2026 d3vyce"
|
||||
repo_url = "https://github.com/d3vyce/fastapi-toolsets"
|
||||
|
||||
[project.extra.version]
|
||||
provider = "mike"
|
||||
default = "stable"
|
||||
alias = true
|
||||
|
||||
[project.theme]
|
||||
custom_dir = "docs/overrides"
|
||||
language = "en"
|
||||
|
||||
Reference in New Issue
Block a user