mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 14:46:24 +02:00
Compare commits
1 Commits
v2.4.1
...
1ee3a3a7e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
1ee3a3a7e2
|
28
.github/workflows/docs.yml
vendored
28
.github/workflows/docs.yml
vendored
@@ -5,7 +5,7 @@ on:
|
|||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
@@ -16,9 +16,14 @@ jobs:
|
|||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/configure-pages@v5
|
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Configure git
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -26,9 +31,22 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
run: uv python install 3.13
|
run: uv python install 3.13
|
||||||
|
|
||||||
- run: uv sync --group dev
|
- run: uv sync --group docs
|
||||||
|
|
||||||
- run: uv run zensical build --clean
|
- name: Install mkdocs shim
|
||||||
|
run: cp scripts/mkdocs .venv/bin/mkdocs && chmod +x .venv/bin/mkdocs
|
||||||
|
|
||||||
|
- name: Deploy docs version
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
MINOR_VERSION="${VERSION%.*}"
|
||||||
|
uv run mike deploy --push --update-aliases "$MINOR_VERSION" latest
|
||||||
|
uv run mike set-default --push latest
|
||||||
|
|
||||||
|
- name: Prepare site artifact
|
||||||
|
run: git worktree add site gh-pages
|
||||||
|
|
||||||
|
- uses: actions/configure-pages@v5
|
||||||
|
|
||||||
- uses: actions/upload-pages-artifact@v4
|
- uses: actions/upload-pages-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ GET /articles/offset?page=2&items_per_page=10&search=fastapi&status=published&or
|
|||||||
],
|
],
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"total_count": 42,
|
"total_count": 42,
|
||||||
"pages": 5,
|
|
||||||
"page": 2,
|
"page": 2,
|
||||||
"items_per_page": 10,
|
"items_per_page": 10,
|
||||||
"has_more": true
|
"has_more": true
|
||||||
@@ -86,8 +85,6 @@ GET /articles/offset?page=2&items_per_page=10&search=fastapi&status=published&or
|
|||||||
|
|
||||||
`filter_attributes` always reflects the values visible **after** applying the active filters. Use it to populate filter dropdowns on the client.
|
`filter_attributes` always reflects the values visible **after** applying the active filters. Use it to populate filter dropdowns on the client.
|
||||||
|
|
||||||
To skip the `COUNT(*)` query for better performance on large tables, pass `include_total=False`. `pagination.total_count` will be `null` in the response, while `has_more` remains accurate.
|
|
||||||
|
|
||||||
### Cursor pagination
|
### Cursor pagination
|
||||||
|
|
||||||
Best for feeds, infinite scroll, or any high-throughput API where offset performance degrades.
|
Best for feeds, infinite scroll, or any high-throughput API where offset performance degrades.
|
||||||
@@ -147,7 +144,7 @@ GET /articles/?pagination_type=offset&page=1&items_per_page=10
|
|||||||
"status": "SUCCESS",
|
"status": "SUCCESS",
|
||||||
"pagination_type": "offset",
|
"pagination_type": "offset",
|
||||||
"data": ["..."],
|
"data": ["..."],
|
||||||
"pagination": { "total_count": 42, "pages": 5, "page": 1, "items_per_page": 10, "has_more": true }
|
"pagination": { "total_count": 42, "page": 1, "items_per_page": 10, "has_more": true }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,6 @@ The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.Async
|
|||||||
"data": ["..."],
|
"data": ["..."],
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"total_count": 100,
|
"total_count": 100,
|
||||||
"pages": 5,
|
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"items_per_page": 20,
|
"items_per_page": 20,
|
||||||
"has_more": true
|
"has_more": true
|
||||||
@@ -190,40 +189,6 @@ The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.Async
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Skipping the COUNT query
|
|
||||||
|
|
||||||
!!! info "Added in `v2.4.1`"
|
|
||||||
|
|
||||||
By default `offset_paginate` runs two queries: one for the page items and one `COUNT(*)` for `total_count`. On large tables the `COUNT` can be expensive. Pass `include_total=False` to skip it:
|
|
||||||
|
|
||||||
```python
|
|
||||||
result = await UserCrud.offset_paginate(
|
|
||||||
session=session,
|
|
||||||
page=page,
|
|
||||||
items_per_page=items_per_page,
|
|
||||||
include_total=False,
|
|
||||||
schema=UserRead,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Pagination params dependency
|
|
||||||
|
|
||||||
!!! info "Added in `v2.4.1`"
|
|
||||||
|
|
||||||
Use [`offset_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_params) to generate a FastAPI dependency that injects `page` and `items_per_page` from query parameters with configurable defaults and a `max_page_size` cap:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import Annotated
|
|
||||||
from fastapi import Depends
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_users(
|
|
||||||
session: SessionDep,
|
|
||||||
params: Annotated[dict, Depends(UserCrud.offset_params(default_page_size=20, max_page_size=100))],
|
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
|
||||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cursor pagination
|
### Cursor pagination
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -273,7 +238,7 @@ The cursor column is set once on [`CrudFactory`](../reference/crud.md#fastapi_to
|
|||||||
!!! note
|
!!! note
|
||||||
`cursor_column` is required. Calling [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) on a CRUD class that has no `cursor_column` configured raises a `ValueError`.
|
`cursor_column` is required. Calling [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) on a CRUD class that has no `cursor_column` configured raises a `ValueError`.
|
||||||
|
|
||||||
The cursor value is URL-safe base64-encoded (no padding) when returned to the client and decoded back to the correct Python type on the next request. The following SQLAlchemy column types are supported:
|
The cursor value is base64-encoded when returned to the client and decoded back to the correct Python type on the next request. The following SQLAlchemy column types are supported:
|
||||||
|
|
||||||
| SQLAlchemy type | Python type |
|
| SQLAlchemy type | Python type |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -291,24 +256,6 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.id)
|
|||||||
PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Pagination params dependency
|
|
||||||
|
|
||||||
!!! info "Added in `v2.4.1`"
|
|
||||||
|
|
||||||
Use [`cursor_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_params) to inject `cursor` and `items_per_page` from query parameters with a `max_page_size` cap:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import Annotated
|
|
||||||
from fastapi import Depends
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_users(
|
|
||||||
session: SessionDep,
|
|
||||||
params: Annotated[dict, Depends(UserCrud.cursor_params(default_page_size=20, max_page_size=100))],
|
|
||||||
) -> CursorPaginatedResponse[UserRead]:
|
|
||||||
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Unified endpoint (both strategies)
|
### Unified endpoint (both strategies)
|
||||||
|
|
||||||
!!! info "Added in `v2.3.0`"
|
!!! info "Added in `v2.3.0`"
|
||||||
@@ -342,24 +289,7 @@ GET /users?pagination_type=offset&page=2&items_per_page=10
|
|||||||
GET /users?pagination_type=cursor&cursor=eyJ2YWx1ZSI6...&items_per_page=10
|
GET /users?pagination_type=cursor&cursor=eyJ2YWx1ZSI6...&items_per_page=10
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Pagination params dependency
|
Both `page` and `cursor` are always accepted by the endpoint — unused parameters are silently ignored by `paginate()`.
|
||||||
|
|
||||||
!!! info "Added in `v2.4.1`"
|
|
||||||
|
|
||||||
Use [`paginate_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate_params) to inject all parameters at once with configurable defaults and a `max_page_size` cap:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import Annotated
|
|
||||||
from fastapi import Depends
|
|
||||||
from fastapi_toolsets.schemas import PaginatedResponse
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_users(
|
|
||||||
session: SessionDep,
|
|
||||||
params: Annotated[dict, Depends(UserCrud.paginate_params(default_page_size=20, max_page_size=100))],
|
|
||||||
) -> PaginatedResponse[UserRead]:
|
|
||||||
return await UserCrud.paginate(session, **params, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Search
|
## Search
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from fastapi_toolsets.crud import OrderByClause
|
from fastapi_toolsets.crud import OrderByClause, PaginationType
|
||||||
from fastapi_toolsets.schemas import (
|
from fastapi_toolsets.schemas import (
|
||||||
CursorPaginatedResponse,
|
CursorPaginatedResponse,
|
||||||
OffsetPaginatedResponse,
|
OffsetPaginatedResponse,
|
||||||
@@ -20,20 +20,19 @@ router = APIRouter(prefix="/articles")
|
|||||||
@router.get("/offset")
|
@router.get("/offset")
|
||||||
async def list_articles_offset(
|
async def list_articles_offset(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[
|
|
||||||
dict,
|
|
||||||
Depends(ArticleCrud.offset_params(default_page_size=20, max_page_size=100)),
|
|
||||||
],
|
|
||||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
||||||
order_by: Annotated[
|
order_by: Annotated[
|
||||||
OrderByClause | None,
|
OrderByClause | None,
|
||||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||||
],
|
],
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
items_per_page: int = Query(20, ge=1, le=100),
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
) -> OffsetPaginatedResponse[ArticleRead]:
|
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||||
return await ArticleCrud.offset_paginate(
|
return await ArticleCrud.offset_paginate(
|
||||||
session=session,
|
session=session,
|
||||||
**params,
|
page=page,
|
||||||
|
items_per_page=items_per_page,
|
||||||
search=search,
|
search=search,
|
||||||
filter_by=filter_by or None,
|
filter_by=filter_by or None,
|
||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
@@ -44,20 +43,19 @@ async def list_articles_offset(
|
|||||||
@router.get("/cursor")
|
@router.get("/cursor")
|
||||||
async def list_articles_cursor(
|
async def list_articles_cursor(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[
|
|
||||||
dict,
|
|
||||||
Depends(ArticleCrud.cursor_params(default_page_size=20, max_page_size=100)),
|
|
||||||
],
|
|
||||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
||||||
order_by: Annotated[
|
order_by: Annotated[
|
||||||
OrderByClause | None,
|
OrderByClause | None,
|
||||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||||
],
|
],
|
||||||
|
cursor: str | None = None,
|
||||||
|
items_per_page: int = Query(20, ge=1, le=100),
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
) -> CursorPaginatedResponse[ArticleRead]:
|
) -> CursorPaginatedResponse[ArticleRead]:
|
||||||
return await ArticleCrud.cursor_paginate(
|
return await ArticleCrud.cursor_paginate(
|
||||||
session=session,
|
session=session,
|
||||||
**params,
|
cursor=cursor,
|
||||||
|
items_per_page=items_per_page,
|
||||||
search=search,
|
search=search,
|
||||||
filter_by=filter_by or None,
|
filter_by=filter_by or None,
|
||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
@@ -68,20 +66,23 @@ async def list_articles_cursor(
|
|||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def list_articles(
|
async def list_articles(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[
|
|
||||||
dict,
|
|
||||||
Depends(ArticleCrud.paginate_params(default_page_size=20, max_page_size=100)),
|
|
||||||
],
|
|
||||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
||||||
order_by: Annotated[
|
order_by: Annotated[
|
||||||
OrderByClause | None,
|
OrderByClause | None,
|
||||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||||
],
|
],
|
||||||
|
pagination_type: PaginationType = PaginationType.OFFSET,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
cursor: str | None = None,
|
||||||
|
items_per_page: int = Query(20, ge=1, le=100),
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
) -> PaginatedResponse[ArticleRead]:
|
) -> PaginatedResponse[ArticleRead]:
|
||||||
return await ArticleCrud.paginate(
|
return await ArticleCrud.paginate(
|
||||||
session,
|
session,
|
||||||
**params,
|
pagination_type=pagination_type,
|
||||||
|
page=page,
|
||||||
|
cursor=cursor,
|
||||||
|
items_per_page=items_per_page,
|
||||||
search=search,
|
search=search,
|
||||||
filter_by=filter_by or None,
|
filter_by=filter_by or None,
|
||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
|
|||||||
5
mkdocs.yml
Normal file
5
mkdocs.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Minimal stub for mike compatibility.
|
||||||
|
# The actual build is handled by zensical (see scripts/mkdocs shim).
|
||||||
|
site_name: FastAPI Toolsets
|
||||||
|
docs_dir: docs
|
||||||
|
site_dir: site
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "2.4.1"
|
version = "2.3.0"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -79,8 +79,9 @@ tests = [
|
|||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
|
"mike>=2.0.0",
|
||||||
"mkdocstrings-python>=2.0.2",
|
"mkdocstrings-python>=2.0.2",
|
||||||
"zensical>=0.0.23",
|
"zensical>=0.0.28",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
60
scripts/mkdocs
Executable file
60
scripts/mkdocs
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""mkdocs shim for mike compatibility.
|
||||||
|
|
||||||
|
mike parses mkdocs.yml (valid YAML stub) for its Python internals, then calls
|
||||||
|
`mkdocs build --config-file <mike-injected-temp.yml>` as a subprocess.
|
||||||
|
|
||||||
|
This shim intercepts that subprocess call, ignores the temp config, and
|
||||||
|
delegates the actual build to `zensical build -f zensical.toml` instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = sys.argv[1:]
|
||||||
|
|
||||||
|
# mike calls `mkdocs --version` to embed in the commit message
|
||||||
|
if args and args[0] == "--version":
|
||||||
|
print("mkdocs, version 1.0.0 (zensical shim)")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not args or args[0] != "build":
|
||||||
|
result = subprocess.run(["python3", "-m", "mkdocs"] + args)
|
||||||
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
|
config_file = "mkdocs.yml"
|
||||||
|
clean = False
|
||||||
|
|
||||||
|
i = 1
|
||||||
|
while i < len(args):
|
||||||
|
if args[i] in ("-f", "--config-file") and i + 1 < len(args):
|
||||||
|
config_file = args[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif args[i] in ("-c", "--clean"):
|
||||||
|
clean = True
|
||||||
|
i += 1
|
||||||
|
elif args[i] == "--dirty":
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# mike creates a temp file prefixed with "mike-mkdocs"; always delegate
|
||||||
|
# the actual build to zensical regardless of which config was passed.
|
||||||
|
del config_file # unused — zensical auto-discovers zensical.toml
|
||||||
|
|
||||||
|
cmd = ["zensical", "build"]
|
||||||
|
if clean:
|
||||||
|
cmd.append("--clean")
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
result = subprocess.run(cmd, env=env)
|
||||||
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.4.1"
|
__version__ = "2.3.0"
|
||||||
|
|||||||
@@ -58,33 +58,18 @@ class _CursorDirection(str, Enum):
|
|||||||
def _encode_cursor(
|
def _encode_cursor(
|
||||||
value: Any, *, direction: _CursorDirection = _CursorDirection.NEXT
|
value: Any, *, direction: _CursorDirection = _CursorDirection.NEXT
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Encode a cursor column value and navigation direction as a URL-safe base64 string."""
|
"""Encode a cursor column value and navigation direction as a base64 string."""
|
||||||
return (
|
return base64.b64encode(
|
||||||
base64.urlsafe_b64encode(
|
json.dumps({"val": str(value), "dir": direction}).encode()
|
||||||
json.dumps({"val": str(value), "dir": direction}).encode()
|
).decode()
|
||||||
)
|
|
||||||
.decode()
|
|
||||||
.rstrip("=")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_cursor(cursor: str) -> tuple[str, _CursorDirection]:
|
def _decode_cursor(cursor: str) -> tuple[str, _CursorDirection]:
|
||||||
"""Decode a URL-safe base64 cursor string into ``(raw_value, direction)``."""
|
"""Decode a cursor base64 string into ``(raw_value, direction)``."""
|
||||||
padded = cursor + "=" * (-len(cursor) % 4)
|
payload = json.loads(base64.b64decode(cursor.encode()).decode())
|
||||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode())
|
|
||||||
return payload["val"], _CursorDirection(payload["dir"])
|
return payload["val"], _CursorDirection(payload["dir"])
|
||||||
|
|
||||||
|
|
||||||
def _page_size_query(default: int, max_size: int) -> int:
|
|
||||||
"""Return a FastAPI ``Query`` for the ``items_per_page`` parameter."""
|
|
||||||
return Query(
|
|
||||||
default,
|
|
||||||
ge=1,
|
|
||||||
le=max_size,
|
|
||||||
description=f"Number of items per page (max {max_size})",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_cursor_value(raw_val: str, col_type: Any) -> Any:
|
def _parse_cursor_value(raw_val: str, col_type: Any) -> Any:
|
||||||
"""Parse a raw cursor string value back into the appropriate Python type."""
|
"""Parse a raw cursor string value back into the appropriate Python type."""
|
||||||
if isinstance(col_type, Integer):
|
if isinstance(col_type, Integer):
|
||||||
@@ -269,7 +254,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
) -> Callable[..., Awaitable[dict[str, list[str]]]]:
|
) -> Callable[..., Awaitable[dict[str, list[str]]]]:
|
||||||
"""Return a FastAPI dependency that collects facet filter values from query parameters.
|
"""Return a FastAPI dependency that collects facet filter values from query parameters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
facet_fields: Override the facet fields for this dependency. Falls back to the
|
facet_fields: Override the facet fields for this dependency. Falls back to the
|
||||||
class-level ``facet_fields`` if not provided.
|
class-level ``facet_fields`` if not provided.
|
||||||
@@ -309,121 +293,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
|
|
||||||
return dependency
|
return dependency
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def offset_params(
|
|
||||||
cls: type[Self],
|
|
||||||
*,
|
|
||||||
default_page_size: int = 20,
|
|
||||||
max_page_size: int = 100,
|
|
||||||
include_total: bool = True,
|
|
||||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
|
||||||
"""Return a FastAPI dependency that collects offset pagination params from query params.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
default_page_size: Default value for the ``items_per_page`` query parameter.
|
|
||||||
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
|
||||||
``le`` on the ``Query``).
|
|
||||||
include_total: Server-side flag forwarded as-is to ``include_total`` in
|
|
||||||
:meth:`offset_paginate`. Not exposed as a query parameter.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An async dependency that resolves to a dict with ``page``,
|
|
||||||
``items_per_page``, and ``include_total`` keys, ready to be
|
|
||||||
unpacked into :meth:`offset_paginate`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def dependency(
|
|
||||||
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
|
||||||
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"page": page,
|
|
||||||
"items_per_page": items_per_page,
|
|
||||||
"include_total": include_total,
|
|
||||||
}
|
|
||||||
|
|
||||||
dependency.__name__ = f"{cls.model.__name__}OffsetParams"
|
|
||||||
return dependency
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def cursor_params(
|
|
||||||
cls: type[Self],
|
|
||||||
*,
|
|
||||||
default_page_size: int = 20,
|
|
||||||
max_page_size: int = 100,
|
|
||||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
|
||||||
"""Return a FastAPI dependency that collects cursor pagination params from query params.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
default_page_size: Default value for the ``items_per_page`` query parameter.
|
|
||||||
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
|
||||||
``le`` on the ``Query``).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An async dependency that resolves to a dict with ``cursor`` and
|
|
||||||
``items_per_page`` keys, ready to be unpacked into
|
|
||||||
:meth:`cursor_paginate`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def dependency(
|
|
||||||
cursor: str | None = Query(
|
|
||||||
None, description="Cursor token from a previous response"
|
|
||||||
),
|
|
||||||
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {"cursor": cursor, "items_per_page": items_per_page}
|
|
||||||
|
|
||||||
dependency.__name__ = f"{cls.model.__name__}CursorParams"
|
|
||||||
return dependency
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def paginate_params(
|
|
||||||
cls: type[Self],
|
|
||||||
*,
|
|
||||||
default_page_size: int = 20,
|
|
||||||
max_page_size: int = 100,
|
|
||||||
default_pagination_type: PaginationType = PaginationType.OFFSET,
|
|
||||||
include_total: bool = True,
|
|
||||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
|
||||||
"""Return a FastAPI dependency that collects all pagination params from query params.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
default_page_size: Default value for the ``items_per_page`` query parameter.
|
|
||||||
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
|
||||||
``le`` on the ``Query``).
|
|
||||||
default_pagination_type: Default pagination strategy.
|
|
||||||
include_total: Server-side flag forwarded as-is to ``include_total`` in
|
|
||||||
:meth:`paginate`. Not exposed as a query parameter.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An async dependency that resolves to a dict with ``pagination_type``,
|
|
||||||
``page``, ``cursor``, ``items_per_page``, and ``include_total`` keys,
|
|
||||||
ready to be unpacked into :meth:`paginate`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def dependency(
|
|
||||||
pagination_type: PaginationType = Query(
|
|
||||||
default_pagination_type, description="Pagination strategy"
|
|
||||||
),
|
|
||||||
page: int = Query(
|
|
||||||
1, ge=1, description="Page number (1-indexed, offset only)"
|
|
||||||
),
|
|
||||||
cursor: str | None = Query(
|
|
||||||
None, description="Cursor token from a previous response (cursor only)"
|
|
||||||
),
|
|
||||||
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"pagination_type": pagination_type,
|
|
||||||
"page": page,
|
|
||||||
"cursor": cursor,
|
|
||||||
"items_per_page": items_per_page,
|
|
||||||
"include_total": include_total,
|
|
||||||
}
|
|
||||||
|
|
||||||
dependency.__name__ = f"{cls.model.__name__}PaginateParams"
|
|
||||||
return dependency
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def order_params(
|
def order_params(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
@@ -1053,7 +922,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
order_by: OrderByClause | None = None,
|
order_by: OrderByClause | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
items_per_page: int = 20,
|
items_per_page: int = 20,
|
||||||
include_total: bool = True,
|
|
||||||
search: str | SearchConfig | None = None,
|
search: str | SearchConfig | None = None,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
@@ -1071,8 +939,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
order_by: Column or list of columns to order by
|
order_by: Column or list of columns to order by
|
||||||
page: Page number (1-indexed)
|
page: Page number (1-indexed)
|
||||||
items_per_page: Number of items per page
|
items_per_page: Number of items per page
|
||||||
include_total: When ``False``, skip the ``COUNT`` query;
|
|
||||||
``pagination.total_count`` will be ``None``.
|
|
||||||
search: Search query string or SearchConfig object
|
search: Search query string or SearchConfig object
|
||||||
search_fields: Fields to search in (overrides class default)
|
search_fields: Fields to search in (overrides class default)
|
||||||
facet_fields: Columns to compute distinct values for (overrides class default)
|
facet_fields: Columns to compute distinct values for (overrides class default)
|
||||||
@@ -1117,39 +983,28 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
if order_by is not None:
|
if order_by is not None:
|
||||||
q = q.order_by(order_by)
|
q = q.order_by(order_by)
|
||||||
|
|
||||||
if include_total:
|
q = q.offset(offset).limit(items_per_page)
|
||||||
q = q.offset(offset).limit(items_per_page)
|
result = await session.execute(q)
|
||||||
result = await session.execute(q)
|
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
||||||
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
|
||||||
|
|
||||||
# Count query (with same joins and filters)
|
|
||||||
pk_col = cls.model.__mapper__.primary_key[0]
|
|
||||||
count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
|
|
||||||
count_q = count_q.select_from(cls.model)
|
|
||||||
|
|
||||||
# Apply explicit joins to count query
|
|
||||||
count_q = _apply_joins(count_q, joins, outer_join)
|
|
||||||
|
|
||||||
# Apply search joins to count query
|
|
||||||
count_q = _apply_search_joins(count_q, search_joins)
|
|
||||||
|
|
||||||
if filters:
|
|
||||||
count_q = count_q.where(and_(*filters))
|
|
||||||
|
|
||||||
count_result = await session.execute(count_q)
|
|
||||||
total_count: int | None = count_result.scalar_one()
|
|
||||||
has_more = page * items_per_page < total_count
|
|
||||||
else:
|
|
||||||
# Fetch one extra row to detect if a next page exists without COUNT
|
|
||||||
q = q.offset(offset).limit(items_per_page + 1)
|
|
||||||
result = await session.execute(q)
|
|
||||||
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
|
||||||
has_more = len(raw_items) > items_per_page
|
|
||||||
raw_items = raw_items[:items_per_page]
|
|
||||||
total_count = None
|
|
||||||
|
|
||||||
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
||||||
|
|
||||||
|
# Count query (with same joins and filters)
|
||||||
|
pk_col = cls.model.__mapper__.primary_key[0]
|
||||||
|
count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
|
||||||
|
count_q = count_q.select_from(cls.model)
|
||||||
|
|
||||||
|
# Apply explicit joins to count query
|
||||||
|
count_q = _apply_joins(count_q, joins, outer_join)
|
||||||
|
|
||||||
|
# Apply search joins to count query
|
||||||
|
count_q = _apply_search_joins(count_q, search_joins)
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
count_q = count_q.where(and_(*filters))
|
||||||
|
|
||||||
|
count_result = await session.execute(count_q)
|
||||||
|
total_count = count_result.scalar_one()
|
||||||
|
|
||||||
filter_attributes = await cls._build_filter_attributes(
|
filter_attributes = await cls._build_filter_attributes(
|
||||||
session, facet_fields, filters, search_joins
|
session, facet_fields, filters, search_joins
|
||||||
)
|
)
|
||||||
@@ -1160,7 +1015,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
total_count=total_count,
|
total_count=total_count,
|
||||||
items_per_page=items_per_page,
|
items_per_page=items_per_page,
|
||||||
page=page,
|
page=page,
|
||||||
has_more=has_more,
|
has_more=page * items_per_page < total_count,
|
||||||
),
|
),
|
||||||
filter_attributes=filter_attributes,
|
filter_attributes=filter_attributes,
|
||||||
)
|
)
|
||||||
@@ -1335,7 +1190,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
page: int = ...,
|
page: int = ...,
|
||||||
cursor: str | None = ...,
|
cursor: str | None = ...,
|
||||||
items_per_page: int = ...,
|
items_per_page: int = ...,
|
||||||
include_total: bool = ...,
|
|
||||||
search: str | SearchConfig | None = ...,
|
search: str | SearchConfig | None = ...,
|
||||||
search_fields: Sequence[SearchFieldType] | None = ...,
|
search_fields: Sequence[SearchFieldType] | None = ...,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||||
@@ -1358,7 +1212,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
page: int = ...,
|
page: int = ...,
|
||||||
cursor: str | None = ...,
|
cursor: str | None = ...,
|
||||||
items_per_page: int = ...,
|
items_per_page: int = ...,
|
||||||
include_total: bool = ...,
|
|
||||||
search: str | SearchConfig | None = ...,
|
search: str | SearchConfig | None = ...,
|
||||||
search_fields: Sequence[SearchFieldType] | None = ...,
|
search_fields: Sequence[SearchFieldType] | None = ...,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||||
@@ -1380,7 +1233,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
items_per_page: int = 20,
|
items_per_page: int = 20,
|
||||||
include_total: bool = True,
|
|
||||||
search: str | SearchConfig | None = None,
|
search: str | SearchConfig | None = None,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
@@ -1406,8 +1258,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
:class:`.CursorPaginatedResponse`. Only used when
|
:class:`.CursorPaginatedResponse`. Only used when
|
||||||
``pagination_type`` is ``CURSOR``.
|
``pagination_type`` is ``CURSOR``.
|
||||||
items_per_page: Number of items per page (default 20).
|
items_per_page: Number of items per page (default 20).
|
||||||
include_total: When ``False``, skip the ``COUNT`` query;
|
|
||||||
only applies when ``pagination_type`` is ``OFFSET``.
|
|
||||||
search: Search query string or :class:`.SearchConfig` object.
|
search: Search query string or :class:`.SearchConfig` object.
|
||||||
search_fields: Fields to search in (overrides class default).
|
search_fields: Fields to search in (overrides class default).
|
||||||
facet_fields: Columns to compute distinct values for (overrides
|
facet_fields: Columns to compute distinct values for (overrides
|
||||||
@@ -1454,7 +1304,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
page=page,
|
page=page,
|
||||||
items_per_page=items_per_page,
|
items_per_page=items_per_page,
|
||||||
include_total=include_total,
|
|
||||||
search=search,
|
search=search,
|
||||||
search_fields=search_fields,
|
search_fields=search_fields,
|
||||||
facet_fields=facet_fields,
|
facet_fields=facet_fields,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from typing import Any, TypeVar
|
|||||||
from sqlalchemy import event
|
from sqlalchemy import event
|
||||||
from sqlalchemy import inspect as sa_inspect
|
from sqlalchemy import inspect as sa_inspect
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value
|
|
||||||
|
|
||||||
from ..logger import get_logger
|
from ..logger import get_logger
|
||||||
|
|
||||||
@@ -54,17 +53,6 @@ def watch(*fields: str) -> Any:
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_column_attrs(obj: Any) -> dict[str, Any]:
|
|
||||||
"""Read currently-loaded column values into a plain dict."""
|
|
||||||
state = sa_inspect(obj) # InstanceState
|
|
||||||
state_dict = state.dict
|
|
||||||
return {
|
|
||||||
prop.key: state_dict[prop.key]
|
|
||||||
for prop in state.mapper.column_attrs
|
|
||||||
if prop.key in state_dict
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _upsert_changes(
|
def _upsert_changes(
|
||||||
pending: dict[int, tuple[Any, dict[str, dict[str, Any]]]],
|
pending: dict[int, tuple[Any, dict[str, dict[str, Any]]]],
|
||||||
obj: Any,
|
obj: Any,
|
||||||
@@ -151,31 +139,16 @@ def _task_error_handler(task: asyncio.Task[Any]) -> None:
|
|||||||
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||||
|
|
||||||
|
|
||||||
def _schedule_with_snapshot(
|
def _call_callback(loop: asyncio.AbstractEventLoop, fn: Any, *args: Any) -> None:
|
||||||
loop: asyncio.AbstractEventLoop, obj: Any, fn: Any, *args: Any
|
"""Dispatch *fn* with *args*, handling both sync and async callables."""
|
||||||
) -> None:
|
try:
|
||||||
"""Snapshot *obj*'s column attrs now (before expire_on_commit wipes them),
|
result = fn(*args)
|
||||||
then schedule a coroutine that restores the snapshot and calls *fn*.
|
except Exception as exc:
|
||||||
"""
|
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||||
snapshot = _snapshot_column_attrs(obj)
|
return
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
async def _run(
|
task = loop.create_task(result)
|
||||||
obj: Any = obj,
|
task.add_done_callback(_task_error_handler)
|
||||||
fn: Any = fn,
|
|
||||||
snapshot: dict[str, Any] = snapshot,
|
|
||||||
args: tuple = args,
|
|
||||||
) -> None:
|
|
||||||
for key, value in snapshot.items():
|
|
||||||
_sa_set_committed_value(obj, key, value)
|
|
||||||
try:
|
|
||||||
result = fn(*args)
|
|
||||||
if asyncio.iscoroutine(result):
|
|
||||||
await result
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
|
||||||
|
|
||||||
task = loop.create_task(_run())
|
|
||||||
task.add_done_callback(_task_error_handler)
|
|
||||||
|
|
||||||
|
|
||||||
@event.listens_for(AsyncSession.sync_session_class, "after_commit")
|
@event.listens_for(AsyncSession.sync_session_class, "after_commit")
|
||||||
@@ -195,13 +168,13 @@ def _after_commit(session: Any) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
for obj in creates:
|
for obj in creates:
|
||||||
_schedule_with_snapshot(loop, obj, obj.on_create)
|
_call_callback(loop, obj.on_create)
|
||||||
|
|
||||||
for obj in deletes:
|
for obj in deletes:
|
||||||
_schedule_with_snapshot(loop, obj, obj.on_delete)
|
_call_callback(loop, obj.on_delete)
|
||||||
|
|
||||||
for obj, changes in field_changes.values():
|
for obj, changes in field_changes.values():
|
||||||
_schedule_with_snapshot(loop, obj, obj.on_update, changes)
|
_call_callback(loop, obj.on_update, changes)
|
||||||
|
|
||||||
|
|
||||||
class WatchedFieldsMixin:
|
class WatchedFieldsMixin:
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"""Base Pydantic schemas for API responses."""
|
"""Base Pydantic schemas for API responses."""
|
||||||
|
|
||||||
import math
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, Union
|
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from .types import DataT
|
from .types import DataT
|
||||||
|
|
||||||
@@ -99,29 +98,17 @@ class OffsetPagination(PydanticBase):
|
|||||||
"""Pagination metadata for offset-based list responses.
|
"""Pagination metadata for offset-based list responses.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
total_count: Total number of items across all pages.
|
total_count: Total number of items across all pages
|
||||||
``None`` when ``include_total=False``.
|
|
||||||
items_per_page: Number of items per page
|
items_per_page: Number of items per page
|
||||||
page: Current page number (1-indexed)
|
page: Current page number (1-indexed)
|
||||||
has_more: Whether there are more pages
|
has_more: Whether there are more pages
|
||||||
pages: Total number of pages
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
total_count: int | None
|
total_count: int
|
||||||
items_per_page: int
|
items_per_page: int
|
||||||
page: int
|
page: int
|
||||||
has_more: bool
|
has_more: bool
|
||||||
|
|
||||||
@computed_field
|
|
||||||
@property
|
|
||||||
def pages(self) -> int | None:
|
|
||||||
"""Total number of pages, or ``None`` when ``total_count`` is unknown."""
|
|
||||||
if self.total_count is None:
|
|
||||||
return None
|
|
||||||
if self.items_per_page == 0:
|
|
||||||
return 0
|
|
||||||
return math.ceil(self.total_count / self.items_per_page)
|
|
||||||
|
|
||||||
|
|
||||||
class CursorPagination(PydanticBase):
|
class CursorPagination(PydanticBase):
|
||||||
"""Pagination metadata for cursor-based list responses.
|
"""Pagination metadata for cursor-based list responses.
|
||||||
|
|||||||
@@ -1759,52 +1759,6 @@ class TestSchemaResponse:
|
|||||||
assert result.data[0].username == "pg_user"
|
assert result.data[0].username == "pg_user"
|
||||||
assert not hasattr(result.data[0], "email")
|
assert not hasattr(result.data[0], "email")
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_include_total_false_skips_count(self, db_session: AsyncSession):
|
|
||||||
"""offset_paginate with include_total=False returns total_count=None."""
|
|
||||||
from fastapi_toolsets.schemas import OffsetPagination
|
|
||||||
|
|
||||||
for i in range(5):
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
|
||||||
|
|
||||||
result = await RoleCrud.offset_paginate(
|
|
||||||
db_session, items_per_page=10, include_total=False, schema=RoleRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count is None
|
|
||||||
assert len(result.data) == 5
|
|
||||||
assert result.pagination.has_more is False
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_include_total_false_has_more_true(self, db_session: AsyncSession):
|
|
||||||
"""offset_paginate with include_total=False sets has_more via extra-row probe."""
|
|
||||||
for i in range(15):
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
|
||||||
|
|
||||||
result = await RoleCrud.offset_paginate(
|
|
||||||
db_session, items_per_page=10, include_total=False, schema=RoleRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.pagination.total_count is None
|
|
||||||
assert result.pagination.has_more is True
|
|
||||||
assert len(result.data) == 10
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_include_total_false_exact_page_boundary(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""offset_paginate with include_total=False: has_more=False when items == page size."""
|
|
||||||
for i in range(10):
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
|
||||||
|
|
||||||
result = await RoleCrud.offset_paginate(
|
|
||||||
db_session, items_per_page=10, include_total=False, schema=RoleRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.pagination.has_more is False
|
|
||||||
assert len(result.data) == 10
|
|
||||||
|
|
||||||
|
|
||||||
class TestCursorPaginate:
|
class TestCursorPaginate:
|
||||||
"""Tests for cursor-based pagination via cursor_paginate()."""
|
"""Tests for cursor-based pagination via cursor_paginate()."""
|
||||||
@@ -2567,20 +2521,3 @@ class TestPaginate:
|
|||||||
pagination_type="unknown",
|
pagination_type="unknown",
|
||||||
schema=RoleRead,
|
schema=RoleRead,
|
||||||
) # type: ignore[no-matching-overload]
|
) # type: ignore[no-matching-overload]
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_offset_include_total_false(self, db_session: AsyncSession):
|
|
||||||
"""paginate() passes include_total=False through to offset_paginate."""
|
|
||||||
from fastapi_toolsets.schemas import OffsetPagination
|
|
||||||
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
|
||||||
|
|
||||||
result = await RoleCrud.paginate(
|
|
||||||
db_session,
|
|
||||||
pagination_type=PaginationType.OFFSET,
|
|
||||||
include_total=False,
|
|
||||||
schema=RoleRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count is None
|
|
||||||
|
|||||||
@@ -14,14 +14,12 @@ from fastapi_toolsets.crud import (
|
|||||||
get_searchable_fields,
|
get_searchable_fields,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.exceptions import InvalidOrderFieldError
|
from fastapi_toolsets.exceptions import InvalidOrderFieldError
|
||||||
from fastapi_toolsets.schemas import OffsetPagination, PaginationType
|
from fastapi_toolsets.schemas import OffsetPagination
|
||||||
|
|
||||||
from .conftest import (
|
from .conftest import (
|
||||||
Role,
|
Role,
|
||||||
RoleCreate,
|
RoleCreate,
|
||||||
RoleCrud,
|
RoleCrud,
|
||||||
RoleCursorCrud,
|
|
||||||
RoleRead,
|
|
||||||
User,
|
User,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserCrud,
|
UserCrud,
|
||||||
@@ -1195,245 +1193,3 @@ class TestOrderParamsSchema:
|
|||||||
|
|
||||||
assert results[0].username == "alice"
|
assert results[0].username == "alice"
|
||||||
assert results[1].username == "charlie"
|
assert results[1].username == "charlie"
|
||||||
|
|
||||||
|
|
||||||
class TestOffsetParamsSchema:
|
|
||||||
"""Tests for AsyncCrud.offset_params()."""
|
|
||||||
|
|
||||||
def test_returns_page_and_items_per_page_params(self):
|
|
||||||
"""Returned dependency has page and items_per_page params only."""
|
|
||||||
dep = RoleCrud.offset_params()
|
|
||||||
param_names = set(inspect.signature(dep).parameters)
|
|
||||||
assert param_names == {"page", "items_per_page"}
|
|
||||||
|
|
||||||
def test_dependency_name_includes_model_name(self):
|
|
||||||
"""Dependency function is named after the model."""
|
|
||||||
dep = RoleCrud.offset_params()
|
|
||||||
assert getattr(dep, "__name__") == "RoleOffsetParams"
|
|
||||||
|
|
||||||
def test_default_page_size_reflected_in_items_per_page_default(self):
|
|
||||||
"""default_page_size is used as the default for items_per_page."""
|
|
||||||
dep = RoleCrud.offset_params(default_page_size=42)
|
|
||||||
sig = inspect.signature(dep)
|
|
||||||
assert sig.parameters["items_per_page"].default.default == 42
|
|
||||||
|
|
||||||
def test_max_page_size_reflected_in_items_per_page_le(self):
|
|
||||||
"""max_page_size is used as le constraint on items_per_page."""
|
|
||||||
dep = RoleCrud.offset_params(max_page_size=50)
|
|
||||||
sig = inspect.signature(dep)
|
|
||||||
le = next(
|
|
||||||
m.le
|
|
||||||
for m in sig.parameters["items_per_page"].default.metadata
|
|
||||||
if hasattr(m, "le")
|
|
||||||
)
|
|
||||||
assert le == 50
|
|
||||||
|
|
||||||
def test_include_total_not_a_query_param(self):
|
|
||||||
"""include_total is not exposed as a query parameter."""
|
|
||||||
dep = RoleCrud.offset_params()
|
|
||||||
param_names = set(inspect.signature(dep).parameters)
|
|
||||||
assert "include_total" not in param_names
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_include_total_true_forwarded_in_result(self):
|
|
||||||
"""include_total=True factory arg appears in the resolved dict."""
|
|
||||||
result = await RoleCrud.offset_params(include_total=True)(
|
|
||||||
page=1, items_per_page=10
|
|
||||||
)
|
|
||||||
assert result["include_total"] is True
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_include_total_false_forwarded_in_result(self):
|
|
||||||
"""include_total=False factory arg appears in the resolved dict."""
|
|
||||||
result = await RoleCrud.offset_params(include_total=False)(
|
|
||||||
page=1, items_per_page=10
|
|
||||||
)
|
|
||||||
assert result["include_total"] is False
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_awaiting_dep_returns_dict(self):
|
|
||||||
"""Awaiting the dependency returns a dict with page, items_per_page, include_total."""
|
|
||||||
dep = RoleCrud.offset_params(include_total=False)
|
|
||||||
result = await dep(page=2, items_per_page=10)
|
|
||||||
assert result == {"page": 2, "items_per_page": 10, "include_total": False}
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_integrates_with_offset_paginate(self, db_session: AsyncSession):
|
|
||||||
"""offset_params output can be unpacked directly into offset_paginate."""
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
|
||||||
dep = RoleCrud.offset_params()
|
|
||||||
params = await dep(page=1, items_per_page=10)
|
|
||||||
result = await RoleCrud.offset_paginate(db_session, **params, schema=RoleRead)
|
|
||||||
assert result.pagination.page == 1
|
|
||||||
assert result.pagination.items_per_page == 10
|
|
||||||
|
|
||||||
|
|
||||||
class TestCursorParamsSchema:
|
|
||||||
"""Tests for AsyncCrud.cursor_params()."""
|
|
||||||
|
|
||||||
def test_returns_cursor_and_items_per_page_params(self):
|
|
||||||
"""Returned dependency has cursor and items_per_page params."""
|
|
||||||
dep = RoleCursorCrud.cursor_params()
|
|
||||||
param_names = set(inspect.signature(dep).parameters)
|
|
||||||
assert param_names == {"cursor", "items_per_page"}
|
|
||||||
|
|
||||||
def test_dependency_name_includes_model_name(self):
|
|
||||||
"""Dependency function is named after the model."""
|
|
||||||
dep = RoleCursorCrud.cursor_params()
|
|
||||||
assert getattr(dep, "__name__") == "RoleCursorParams"
|
|
||||||
|
|
||||||
def test_default_page_size_reflected_in_items_per_page_default(self):
|
|
||||||
"""default_page_size is used as the default for items_per_page."""
|
|
||||||
dep = RoleCursorCrud.cursor_params(default_page_size=15)
|
|
||||||
sig = inspect.signature(dep)
|
|
||||||
assert sig.parameters["items_per_page"].default.default == 15
|
|
||||||
|
|
||||||
def test_max_page_size_reflected_in_items_per_page_le(self):
|
|
||||||
"""max_page_size is used as le constraint on items_per_page."""
|
|
||||||
dep = RoleCursorCrud.cursor_params(max_page_size=75)
|
|
||||||
sig = inspect.signature(dep)
|
|
||||||
le = next(
|
|
||||||
m.le
|
|
||||||
for m in sig.parameters["items_per_page"].default.metadata
|
|
||||||
if hasattr(m, "le")
|
|
||||||
)
|
|
||||||
assert le == 75
|
|
||||||
|
|
||||||
def test_cursor_defaults_to_none(self):
|
|
||||||
"""cursor defaults to None."""
|
|
||||||
dep = RoleCursorCrud.cursor_params()
|
|
||||||
sig = inspect.signature(dep)
|
|
||||||
assert sig.parameters["cursor"].default.default is None
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_awaiting_dep_returns_dict(self):
|
|
||||||
"""Awaiting the dependency returns a dict with cursor and items_per_page."""
|
|
||||||
dep = RoleCursorCrud.cursor_params()
|
|
||||||
result = await dep(cursor=None, items_per_page=5)
|
|
||||||
assert result == {"cursor": None, "items_per_page": 5}
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_integrates_with_cursor_paginate(self, db_session: AsyncSession):
|
|
||||||
"""cursor_params output can be unpacked directly into cursor_paginate."""
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
|
||||||
dep = RoleCursorCrud.cursor_params()
|
|
||||||
params = await dep(cursor=None, items_per_page=10)
|
|
||||||
result = await RoleCursorCrud.cursor_paginate(
|
|
||||||
db_session, **params, schema=RoleRead
|
|
||||||
)
|
|
||||||
assert result.pagination.items_per_page == 10
|
|
||||||
|
|
||||||
|
|
||||||
class TestPaginateParamsSchema:
|
|
||||||
"""Tests for AsyncCrud.paginate_params()."""
|
|
||||||
|
|
||||||
def test_returns_all_params(self):
|
|
||||||
"""Returned dependency has pagination_type, page, cursor, items_per_page (no include_total)."""
|
|
||||||
dep = RoleCursorCrud.paginate_params()
|
|
||||||
param_names = set(inspect.signature(dep).parameters)
|
|
||||||
assert param_names == {"pagination_type", "page", "cursor", "items_per_page"}
|
|
||||||
|
|
||||||
def test_dependency_name_includes_model_name(self):
|
|
||||||
"""Dependency function is named after the model."""
|
|
||||||
dep = RoleCursorCrud.paginate_params()
|
|
||||||
assert getattr(dep, "__name__") == "RolePaginateParams"
|
|
||||||
|
|
||||||
def test_default_pagination_type(self):
|
|
||||||
"""default_pagination_type is reflected in pagination_type default."""
|
|
||||||
from fastapi_toolsets.schemas import PaginationType
|
|
||||||
|
|
||||||
dep = RoleCursorCrud.paginate_params(
|
|
||||||
default_pagination_type=PaginationType.CURSOR
|
|
||||||
)
|
|
||||||
sig = inspect.signature(dep)
|
|
||||||
assert (
|
|
||||||
sig.parameters["pagination_type"].default.default == PaginationType.CURSOR
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_default_page_size(self):
|
|
||||||
"""default_page_size is reflected in items_per_page default."""
|
|
||||||
dep = RoleCursorCrud.paginate_params(default_page_size=15)
|
|
||||||
sig = inspect.signature(dep)
|
|
||||||
assert sig.parameters["items_per_page"].default.default == 15
|
|
||||||
|
|
||||||
def test_max_page_size_le_constraint(self):
|
|
||||||
"""max_page_size is used as le constraint on items_per_page."""
|
|
||||||
dep = RoleCursorCrud.paginate_params(max_page_size=60)
|
|
||||||
sig = inspect.signature(dep)
|
|
||||||
le = next(
|
|
||||||
m.le
|
|
||||||
for m in sig.parameters["items_per_page"].default.metadata
|
|
||||||
if hasattr(m, "le")
|
|
||||||
)
|
|
||||||
assert le == 60
|
|
||||||
|
|
||||||
def test_include_total_not_a_query_param(self):
|
|
||||||
"""include_total is not exposed as a query parameter."""
|
|
||||||
dep = RoleCursorCrud.paginate_params()
|
|
||||||
assert "include_total" not in set(inspect.signature(dep).parameters)
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_include_total_forwarded_in_result(self):
|
|
||||||
"""include_total factory arg appears in the resolved dict."""
|
|
||||||
result_true = await RoleCursorCrud.paginate_params(include_total=True)(
|
|
||||||
pagination_type=PaginationType.OFFSET,
|
|
||||||
page=1,
|
|
||||||
cursor=None,
|
|
||||||
items_per_page=10,
|
|
||||||
)
|
|
||||||
result_false = await RoleCursorCrud.paginate_params(include_total=False)(
|
|
||||||
pagination_type=PaginationType.OFFSET,
|
|
||||||
page=1,
|
|
||||||
cursor=None,
|
|
||||||
items_per_page=10,
|
|
||||||
)
|
|
||||||
assert result_true["include_total"] is True
|
|
||||||
assert result_false["include_total"] is False
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_awaiting_dep_returns_dict(self):
|
|
||||||
"""Awaiting the dependency returns a dict with all pagination keys."""
|
|
||||||
dep = RoleCursorCrud.paginate_params()
|
|
||||||
result = await dep(
|
|
||||||
pagination_type=PaginationType.OFFSET,
|
|
||||||
page=2,
|
|
||||||
cursor=None,
|
|
||||||
items_per_page=10,
|
|
||||||
)
|
|
||||||
assert result == {
|
|
||||||
"pagination_type": PaginationType.OFFSET,
|
|
||||||
"page": 2,
|
|
||||||
"cursor": None,
|
|
||||||
"items_per_page": 10,
|
|
||||||
"include_total": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_integrates_with_paginate_offset(self, db_session: AsyncSession):
|
|
||||||
"""paginate_params output unpacks into paginate() for offset strategy."""
|
|
||||||
from fastapi_toolsets.schemas import OffsetPagination
|
|
||||||
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
|
||||||
params = await RoleCursorCrud.paginate_params()(
|
|
||||||
pagination_type=PaginationType.OFFSET,
|
|
||||||
page=1,
|
|
||||||
cursor=None,
|
|
||||||
items_per_page=10,
|
|
||||||
)
|
|
||||||
result = await RoleCursorCrud.paginate(db_session, **params, schema=RoleRead)
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_integrates_with_paginate_cursor(self, db_session: AsyncSession):
|
|
||||||
"""paginate_params output unpacks into paginate() for cursor strategy."""
|
|
||||||
from fastapi_toolsets.schemas import CursorPagination
|
|
||||||
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
|
||||||
params = await RoleCursorCrud.paginate_params()(
|
|
||||||
pagination_type=PaginationType.CURSOR,
|
|
||||||
page=1,
|
|
||||||
cursor=None,
|
|
||||||
items_per_page=10,
|
|
||||||
)
|
|
||||||
result = await RoleCursorCrud.paginate(db_session, **params, schema=RoleRead)
|
|
||||||
assert isinstance(result.pagination, CursorPagination)
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from fastapi_toolsets.models.watched import (
|
|||||||
_after_flush,
|
_after_flush,
|
||||||
_after_flush_postexec,
|
_after_flush_postexec,
|
||||||
_after_rollback,
|
_after_rollback,
|
||||||
|
_call_callback,
|
||||||
_task_error_handler,
|
_task_error_handler,
|
||||||
_upsert_changes,
|
_upsert_changes,
|
||||||
)
|
)
|
||||||
@@ -127,17 +128,6 @@ class WatchAllModel(MixinBase, UUIDMixin, WatchedFieldsMixin):
|
|||||||
_test_events.append({"event": "update", "obj_id": self.id, "changes": changes})
|
_test_events.append({"event": "update", "obj_id": self.id, "changes": changes})
|
||||||
|
|
||||||
|
|
||||||
class FailingCallbackModel(MixinBase, UUIDMixin, WatchedFieldsMixin):
|
|
||||||
"""Model whose on_create always raises to test exception logging."""
|
|
||||||
|
|
||||||
__tablename__ = "mixin_failing_callback_models"
|
|
||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(50))
|
|
||||||
|
|
||||||
async def on_create(self) -> None:
|
|
||||||
raise RuntimeError("callback intentionally failed")
|
|
||||||
|
|
||||||
|
|
||||||
class NonWatchedModel(MixinBase):
|
class NonWatchedModel(MixinBase):
|
||||||
__tablename__ = "mixin_non_watched_models"
|
__tablename__ = "mixin_non_watched_models"
|
||||||
|
|
||||||
@@ -145,32 +135,6 @@ class NonWatchedModel(MixinBase):
|
|||||||
value: Mapped[str] = mapped_column(String(50))
|
value: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
|
||||||
_attr_access_events: list[dict] = []
|
|
||||||
|
|
||||||
|
|
||||||
class AttrAccessModel(MixinBase, UUIDMixin, WatchedFieldsMixin):
|
|
||||||
"""Model used to verify that self attributes are accessible in every callback."""
|
|
||||||
|
|
||||||
__tablename__ = "mixin_attr_access_models"
|
|
||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(50))
|
|
||||||
|
|
||||||
async def on_create(self) -> None:
|
|
||||||
_attr_access_events.append(
|
|
||||||
{"event": "create", "id": self.id, "name": self.name}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def on_delete(self) -> None:
|
|
||||||
_attr_access_events.append(
|
|
||||||
{"event": "delete", "id": self.id, "name": self.name}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def on_update(self, changes: dict) -> None:
|
|
||||||
_attr_access_events.append(
|
|
||||||
{"event": "update", "id": self.id, "name": self.name}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_sync_events: list[dict] = []
|
_sync_events: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -210,25 +174,6 @@ async def mixin_session():
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
|
||||||
async def mixin_session_expire():
|
|
||||||
"""Session with expire_on_commit=True (the default) to exercise attribute access after commit."""
|
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(MixinBase.metadata.create_all)
|
|
||||||
|
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=True)
|
|
||||||
session = session_factory()
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
finally:
|
|
||||||
await session.close()
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(MixinBase.metadata.drop_all)
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
class TestUUIDMixin:
|
class TestUUIDMixin:
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_uuid_generated_by_db(self, mixin_session):
|
async def test_uuid_generated_by_db(self, mixin_session):
|
||||||
@@ -797,16 +742,6 @@ class TestWatchedFieldsMixin:
|
|||||||
|
|
||||||
assert _test_events == []
|
assert _test_events == []
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_callback_exception_is_logged(self, mixin_session):
|
|
||||||
"""Exceptions raised inside on_create are logged, not propagated."""
|
|
||||||
obj = FailingCallbackModel(name="boom")
|
|
||||||
mixin_session.add(obj)
|
|
||||||
with patch.object(_watched_module._logger, "error") as mock_error:
|
|
||||||
await mixin_session.commit()
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
mock_error.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_non_watched_model_no_callback(self, mixin_session):
|
async def test_non_watched_model_no_callback(self, mixin_session):
|
||||||
"""Dirty objects whose type is not a WatchedFieldsMixin are skipped."""
|
"""Dirty objects whose type is not a WatchedFieldsMixin are skipped."""
|
||||||
@@ -968,66 +903,65 @@ class TestSyncCallbacks:
|
|||||||
assert updates[0]["changes"]["status"] == {"old": "initial", "new": "updated"}
|
assert updates[0]["changes"]["status"] == {"old": "initial", "new": "updated"}
|
||||||
|
|
||||||
|
|
||||||
class TestAttributeAccessInCallbacks:
|
class TestCallCallback:
|
||||||
"""Verify that self attributes are accessible inside every callback type.
|
@pytest.mark.anyio
|
||||||
|
async def test_async_callback_scheduled_as_task(self):
|
||||||
|
"""_call_callback schedules async functions as tasks."""
|
||||||
|
called = []
|
||||||
|
|
||||||
Uses expire_on_commit=True (the SQLAlchemy default) so the tests would fail
|
async def async_fn() -> None:
|
||||||
without the snapshot-restore logic in _schedule_with_snapshot.
|
called.append("async")
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
loop = asyncio.get_running_loop()
|
||||||
def clear_events(self):
|
_call_callback(loop, async_fn)
|
||||||
_attr_access_events.clear()
|
await asyncio.sleep(0)
|
||||||
yield
|
assert called == ["async"]
|
||||||
_attr_access_events.clear()
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_on_create_pk_and_field_accessible(self, mixin_session_expire):
|
async def test_sync_callback_called_directly(self):
|
||||||
"""id (server default) and regular fields are readable inside on_create."""
|
"""_call_callback invokes sync functions immediately."""
|
||||||
obj = AttrAccessModel(name="hello")
|
called = []
|
||||||
mixin_session_expire.add(obj)
|
|
||||||
await mixin_session_expire.commit()
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
events = [e for e in _attr_access_events if e["event"] == "create"]
|
def sync_fn() -> None:
|
||||||
assert len(events) == 1
|
called.append("sync")
|
||||||
assert isinstance(events[0]["id"], uuid.UUID)
|
|
||||||
assert events[0]["name"] == "hello"
|
loop = asyncio.get_running_loop()
|
||||||
|
_call_callback(loop, sync_fn)
|
||||||
|
assert called == ["sync"]
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_on_delete_pk_and_field_accessible(self, mixin_session_expire):
|
async def test_sync_callback_exception_logged(self):
|
||||||
"""id and regular fields are readable inside on_delete."""
|
"""_call_callback logs exceptions from sync callbacks."""
|
||||||
obj = AttrAccessModel(name="to-delete")
|
|
||||||
mixin_session_expire.add(obj)
|
|
||||||
await mixin_session_expire.commit()
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
_attr_access_events.clear()
|
|
||||||
|
|
||||||
await mixin_session_expire.delete(obj)
|
def failing_fn() -> None:
|
||||||
await mixin_session_expire.commit()
|
raise RuntimeError("sync error")
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
events = [e for e in _attr_access_events if e["event"] == "delete"]
|
loop = asyncio.get_running_loop()
|
||||||
assert len(events) == 1
|
with patch.object(_watched_module._logger, "error") as mock_error:
|
||||||
assert isinstance(events[0]["id"], uuid.UUID)
|
_call_callback(loop, failing_fn)
|
||||||
assert events[0]["name"] == "to-delete"
|
mock_error.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_on_update_pk_and_updated_field_accessible(
|
async def test_async_callback_with_args(self):
|
||||||
self, mixin_session_expire
|
"""_call_callback passes arguments to async callbacks."""
|
||||||
):
|
received = []
|
||||||
"""id and the new field value are readable inside on_update."""
|
|
||||||
obj = AttrAccessModel(name="original")
|
|
||||||
mixin_session_expire.add(obj)
|
|
||||||
await mixin_session_expire.commit()
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
_attr_access_events.clear()
|
|
||||||
|
|
||||||
obj.name = "updated"
|
async def async_fn(changes: dict) -> None:
|
||||||
await mixin_session_expire.commit()
|
received.append(changes)
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
events = [e for e in _attr_access_events if e["event"] == "update"]
|
loop = asyncio.get_running_loop()
|
||||||
assert len(events) == 1
|
_call_callback(loop, async_fn, {"status": {"old": "a", "new": "b"}})
|
||||||
assert isinstance(events[0]["id"], uuid.UUID)
|
await asyncio.sleep(0)
|
||||||
assert events[0]["name"] == "updated"
|
assert received == [{"status": {"old": "a", "new": "b"}}]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_sync_callback_with_args(self):
|
||||||
|
"""_call_callback passes arguments to sync callbacks."""
|
||||||
|
received = []
|
||||||
|
|
||||||
|
def sync_fn(changes: dict) -> None:
|
||||||
|
received.append(changes)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
_call_callback(loop, sync_fn, {"x": 1})
|
||||||
|
assert received == [{"x": 1}]
|
||||||
|
|||||||
@@ -201,88 +201,6 @@ class TestOffsetPagination:
|
|||||||
assert data["page"] == 2
|
assert data["page"] == 2
|
||||||
assert data["has_more"] is True
|
assert data["has_more"] is True
|
||||||
|
|
||||||
def test_total_count_can_be_none(self):
|
|
||||||
"""total_count accepts None (include_total=False mode)."""
|
|
||||||
pagination = OffsetPagination(
|
|
||||||
total_count=None,
|
|
||||||
items_per_page=20,
|
|
||||||
page=1,
|
|
||||||
has_more=True,
|
|
||||||
)
|
|
||||||
assert pagination.total_count is None
|
|
||||||
|
|
||||||
def test_serialization_with_none_total_count(self):
|
|
||||||
"""OffsetPagination serializes total_count=None correctly."""
|
|
||||||
pagination = OffsetPagination(
|
|
||||||
total_count=None,
|
|
||||||
items_per_page=20,
|
|
||||||
page=1,
|
|
||||||
has_more=False,
|
|
||||||
)
|
|
||||||
data = pagination.model_dump()
|
|
||||||
assert data["total_count"] is None
|
|
||||||
|
|
||||||
def test_pages_computed(self):
|
|
||||||
"""pages is ceil(total_count / items_per_page)."""
|
|
||||||
pagination = OffsetPagination(
|
|
||||||
total_count=42,
|
|
||||||
items_per_page=10,
|
|
||||||
page=1,
|
|
||||||
has_more=True,
|
|
||||||
)
|
|
||||||
assert pagination.pages == 5
|
|
||||||
|
|
||||||
def test_pages_exact_division(self):
|
|
||||||
"""pages is exact when total_count is evenly divisible."""
|
|
||||||
pagination = OffsetPagination(
|
|
||||||
total_count=40,
|
|
||||||
items_per_page=10,
|
|
||||||
page=1,
|
|
||||||
has_more=False,
|
|
||||||
)
|
|
||||||
assert pagination.pages == 4
|
|
||||||
|
|
||||||
def test_pages_zero_total(self):
|
|
||||||
"""pages is 0 when total_count is 0."""
|
|
||||||
pagination = OffsetPagination(
|
|
||||||
total_count=0,
|
|
||||||
items_per_page=10,
|
|
||||||
page=1,
|
|
||||||
has_more=False,
|
|
||||||
)
|
|
||||||
assert pagination.pages == 0
|
|
||||||
|
|
||||||
def test_pages_zero_items_per_page(self):
|
|
||||||
"""pages is 0 when items_per_page is 0."""
|
|
||||||
pagination = OffsetPagination(
|
|
||||||
total_count=100,
|
|
||||||
items_per_page=0,
|
|
||||||
page=1,
|
|
||||||
has_more=False,
|
|
||||||
)
|
|
||||||
assert pagination.pages == 0
|
|
||||||
|
|
||||||
def test_pages_none_when_total_count_none(self):
|
|
||||||
"""pages is None when total_count is None (include_total=False)."""
|
|
||||||
pagination = OffsetPagination(
|
|
||||||
total_count=None,
|
|
||||||
items_per_page=20,
|
|
||||||
page=1,
|
|
||||||
has_more=True,
|
|
||||||
)
|
|
||||||
assert pagination.pages is None
|
|
||||||
|
|
||||||
def test_pages_in_serialization(self):
|
|
||||||
"""pages appears in model_dump output."""
|
|
||||||
pagination = OffsetPagination(
|
|
||||||
total_count=25,
|
|
||||||
items_per_page=10,
|
|
||||||
page=1,
|
|
||||||
has_more=True,
|
|
||||||
)
|
|
||||||
data = pagination.model_dump()
|
|
||||||
assert data["pages"] == 3
|
|
||||||
|
|
||||||
|
|
||||||
class TestCursorPagination:
|
class TestCursorPagination:
|
||||||
"""Tests for CursorPagination schema."""
|
"""Tests for CursorPagination schema."""
|
||||||
|
|||||||
73
uv.lock
generated
73
uv.lock
generated
@@ -251,7 +251,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "2.4.1"
|
version = "2.3.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
@@ -285,6 +285,7 @@ dev = [
|
|||||||
{ name = "coverage" },
|
{ name = "coverage" },
|
||||||
{ name = "fastapi-toolsets", extra = ["all"] },
|
{ name = "fastapi-toolsets", extra = ["all"] },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "mike" },
|
||||||
{ name = "mkdocstrings-python" },
|
{ name = "mkdocstrings-python" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-anyio" },
|
{ name = "pytest-anyio" },
|
||||||
@@ -295,6 +296,7 @@ dev = [
|
|||||||
{ name = "zensical" },
|
{ name = "zensical" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
|
{ name = "mike" },
|
||||||
{ name = "mkdocstrings-python" },
|
{ name = "mkdocstrings-python" },
|
||||||
{ name = "zensical" },
|
{ name = "zensical" },
|
||||||
]
|
]
|
||||||
@@ -327,6 +329,7 @@ dev = [
|
|||||||
{ name = "coverage", specifier = ">=7.0.0" },
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
{ name = "fastapi-toolsets", extras = ["all"] },
|
{ name = "fastapi-toolsets", extras = ["all"] },
|
||||||
{ name = "httpx", specifier = ">=0.25.0" },
|
{ name = "httpx", specifier = ">=0.25.0" },
|
||||||
|
{ name = "mike", specifier = ">=2.0.0" },
|
||||||
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
||||||
{ name = "pytest", specifier = ">=8.0.0" },
|
{ name = "pytest", specifier = ">=8.0.0" },
|
||||||
{ name = "pytest-anyio", specifier = ">=0.0.0" },
|
{ name = "pytest-anyio", specifier = ">=0.0.0" },
|
||||||
@@ -334,11 +337,12 @@ dev = [
|
|||||||
{ name = "pytest-xdist", specifier = ">=3.0.0" },
|
{ name = "pytest-xdist", specifier = ">=3.0.0" },
|
||||||
{ name = "ruff", specifier = ">=0.1.0" },
|
{ name = "ruff", specifier = ">=0.1.0" },
|
||||||
{ name = "ty", specifier = ">=0.0.1a0" },
|
{ name = "ty", specifier = ">=0.0.1a0" },
|
||||||
{ name = "zensical", specifier = ">=0.0.23" },
|
{ name = "zensical", specifier = ">=0.0.28" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
|
{ name = "mike", specifier = ">=2.0.0" },
|
||||||
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
||||||
{ name = "zensical", specifier = ">=0.0.23" },
|
{ name = "zensical", specifier = ">=0.0.28" },
|
||||||
]
|
]
|
||||||
tests = [
|
tests = [
|
||||||
{ name = "coverage", specifier = ">=7.0.0" },
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
@@ -601,6 +605,23 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mike"
|
||||||
|
version = "2.1.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "mkdocs" },
|
||||||
|
{ name = "pyparsing" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "pyyaml-env-tag" },
|
||||||
|
{ name = "verspec" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ec/09/de1cab0018eb5f1fbd9dcc26b6e61f9453c5ec2eb790949d6ed75e1ffe55/mike-2.1.4.tar.gz", hash = "sha256:75d549420b134603805a65fc67f7dcd9fcd0ad1454fb2c893d9e844cba1aa6e4", size = 38190, upload-time = "2026-03-08T02:46:29.187Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/f7/10f5e101db25741b91e4f4792c5d97b4fa834ead5cf509ae91097d939424/mike-2.1.4-py3-none-any.whl", hash = "sha256:39933e992e155dd70f2297e749a0ed78d8fd7942bc33a3666195d177758a280e", size = 33820, upload-time = "2026-03-08T02:46:28.149Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mkdocs"
|
name = "mkdocs"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
@@ -863,6 +884,15 @@ 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/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyparsing"
|
||||||
|
version = "3.3.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.2"
|
version = "9.0.2"
|
||||||
@@ -1235,6 +1265,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "verspec"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "watchdog"
|
name = "watchdog"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -1264,7 +1303,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.27"
|
version = "0.0.28"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@@ -1274,18 +1313,18 @@ dependencies = [
|
|||||||
{ name = "pymdown-extensions" },
|
{ name = "pymdown-extensions" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/83/969152d927b522a0fed1f20b1730575d86b920ce51530b669d9fad4537de/zensical-0.0.27.tar.gz", hash = "sha256:6d8d74aba4a9f9505e6ba1c43d4c828ba4ff7bb1ff9b005e5174c5b92cf23419", size = 3841776, upload-time = "2026-03-13T17:56:14.494Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/14/0a/ed78749cd30c8b72f6b3f85de7f4da45ddcbbd006222aa63f7d6e27d68db/zensical-0.0.28.tar.gz", hash = "sha256:af7d75a1b297721dfc9b897f729b601e56b3e566990a989e9e3e373a8cd04c40", size = 3842655, upload-time = "2026-03-19T14:28:09.17Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/fe/0335f1a521eb6c0ab96028bf67148390eb1d5c742c23e6a4b0f8381508bd/zensical-0.0.27-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d51ebf4b038f3eea99fd337119b99d92ad92bbe674372d5262e6dbbabbe4e9b5", size = 12262017, upload-time = "2026-03-13T17:55:36.403Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/c5/05e6a8b8ecfc255ff59414c71e1904b1ceaf3ccbc26f14b90ce82aaab16e/zensical-0.0.28-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2db2997dd124dc9361b9d3228925df9e51281af9529c26187a865407588f8abb", size = 12302942, upload-time = "2026-03-19T14:27:32.009Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/cb/ac24334fc7959b49496c97cb9d2bed82a8db8b84eafaf68189048e7fe69a/zensical-0.0.27-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a627cd4599cf2c5a5a5205f0510667227d1fe4579b6f7445adba2d84bab9fbc8", size = 12147361, upload-time = "2026-03-13T17:55:39.736Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/aa/c10fcbee69bcca8a545b1a868e3fec2560b984f68e91cbbce3eaee0814ff/zensical-0.0.28-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:5c6e5ea5c057492a1473a68f0e71359d663057d7d864b32a8fd429c8ea390346", size = 12186436, upload-time = "2026-03-19T14:27:34.866Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/0f/31c981f61006fdaf0460d15bde1248a045178d67307bad61a4588414855d/zensical-0.0.27-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99cbc493022f8749504ef10c71772d360b705b4e2fd1511421393157d07bdccf", size = 12505771, upload-time = "2026-03-13T17:55:42.993Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/ea/d0aaa0f0ed1b7a69aeec5f25ce2ff2ea7b13e581c9115d51a4a50bc7bf57/zensical-0.0.28-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2ee8a1d29b61de61e6b0f9123fa395c06c24c94e509170c7f7f9ccddaeaaad4", size = 12545239, upload-time = "2026-03-19T14:27:37.613Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/1e/f6842c94ec89e5e9184f407dbbab2a497b444b28d4fb5b8df631894be896/zensical-0.0.27-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ecc20a85e8a23ad9ab809b2f268111321be7b2e214021b3b00f138936a87a434", size = 12455689, upload-time = "2026-03-13T17:55:46.055Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/b1/508ea4de8b5c93a2ceb4d536314041a19a520866a5ce61c55d64417afaa9/zensical-0.0.28-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cef68b363c0d3598d37a1090bfc5c6267e36a87a55e9fb6a6f9d7f2768f1dfd", size = 12488943, upload-time = "2026-03-19T14:27:40.663Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/ad/866c3336381cca7528e792469958fbe2e65b9206a2657bef3dd8ed4ac88b/zensical-0.0.27-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da11e0f0861dbd7d3b5e6fe1e3a53b361b2181c53f3abe9fb4cdf2ed0cea47bf", size = 12791263, upload-time = "2026-03-13T17:55:49.193Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/35/9c1878845dfcec655f538ef523c606e585d38b84415d65009b83ebc356b2/zensical-0.0.28-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3175440fd526cf0273859d0de355e769ba43e082e09deb04b6f6afd77af6c91", size = 12840468, upload-time = "2026-03-19T14:27:43.758Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/df/fca5ed6bebdb61aa656dfa65cce4b4d03324a79c75857728230872fbdf7c/zensical-0.0.27-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e11d220181477040a4b22bf2b8678d5b0c878e7aae194fad4133561cb976d69", size = 12549796, upload-time = "2026-03-13T17:55:52.55Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/1f/50f0ca6db76dc7888f9e0f0103c8faaaa6ee25a2c1e3664f2db5cc7bf24b/zensical-0.0.28-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0887436c5fd8fe7008c0d93407876695db67bcf55c8aec9fb36c339d82bb7fce", size = 12591152, upload-time = "2026-03-19T14:27:46.629Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4a/e2/43398b5ec64ed78204a5a5929a3990769fc0f6a3094a30395882bda1399a/zensical-0.0.27-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06b9e308aec8c5db1cd623e2e98e1b25c3f5cab6b25fcc9bac1e16c0c2b93837", size = 12683568, upload-time = "2026-03-13T17:55:56.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/6b/621b7031c24c9fb0d38c2c488d79d73fcc2e645330c27fbab4ecccc06528/zensical-0.0.28-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8a0ca92e04687f71aa20c9ae80fe8b840125545657e6b7c0f83adecd04d512e", size = 12723744, upload-time = "2026-03-19T14:27:50.101Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/3c/5c98f9964c7e30735aacd22a389dacec12bcc5bc8162c58e76b76d20db6e/zensical-0.0.27-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:682085155126965b091cb9f915cd2e4297383ac500122fd4b632cf4511733eb2", size = 12725214, upload-time = "2026-03-13T17:55:59.286Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/89/a8bdd6a8423e0bb4f8792793681cbe101cdfbb1e0c1128b3226afe53af5f/zensical-0.0.28-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:acb31723ca82c367d1c41a6a7b0f52ce1ed87f0ee437de2ee2fc2e284e120e44", size = 12760416, upload-time = "2026-03-19T14:27:52.667Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/0f/ebaa159cac6d64b53bf7134420c2b43399acc7096cb79795be4fb10768fc/zensical-0.0.27-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b367c285157c8e1099ae9e2b36564e07d3124bf891e96194a093bc836f3058d2", size = 12860416, upload-time = "2026-03-13T17:56:02.456Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/07/af4ec58b63a14c0fb6b21c8c875f34effa71d4258530a3e3d301b1c518b9/zensical-0.0.28-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3680b3a75560881e7fa32b450cf6de09895680b84d0dd2b611cb5fa552fdfc49", size = 12907390, upload-time = "2026-03-19T14:27:56.71Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/06/d82bfccbf5a1f43256dbc4d1984e398035a65f84f7c1e48b69ba15ea7281/zensical-0.0.27-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:847c881209e65e1db1291c59a9db77966ac50f7c66bf9a733c3c7832144dbfca", size = 12819533, upload-time = "2026-03-13T17:56:05.487Z" },
|
{ url = "https://files.pythonhosted.org/packages/61/70/1b3f319ac2c05bdcd27ae73ae315a893683eb286a42a746e7e572e2675f6/zensical-0.0.28-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93e1bc47981b50bcd9c4098edc66fb86fd881c5b52b355db92dcef626cc0b468", size = 12864434, upload-time = "2026-03-19T14:28:00.443Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/1f/d25e421d91f063a9404c59dd032f65a67c7c700e9f5f40436ab98e533482/zensical-0.0.27-cp310-abi3-win32.whl", hash = "sha256:f31ec13c700794be3f9c0b7d90f09a7d23575a3a27c464994b9bb441a22d880b", size = 11862822, upload-time = "2026-03-13T17:56:08.933Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/21/be7c94b25e0f4281a6b5fbd471236e33c44b832a830fedad40a6c119f290/zensical-0.0.28-cp310-abi3-win32.whl", hash = "sha256:eee014ca1290463cf8471e3e1b05b7c627ac7afa0881635024d23d4794675980", size = 11888008, upload-time = "2026-03-19T14:28:03.565Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/b5/5b86d126fcc42b96c5dbecde5074d6ea766a1a884e3b25b3524843c5e6a5/zensical-0.0.27-cp310-abi3-win_amd64.whl", hash = "sha256:9d3b1fca7ea99a7b2a8db272dd7f7839587c4ebf4f56b84ff01c97b3893ec9f8", size = 12059658, upload-time = "2026-03-13T17:56:11.859Z" },
|
{ url = "https://files.pythonhosted.org/packages/de/88/5ce79445489edae6c1a3ff9e06b4885bea5d8e8bb8e26e1aa1b24395c337/zensical-0.0.28-cp310-abi3-win_amd64.whl", hash = "sha256:6077a85ee1f0154dbfe542db36789322fe8625d716235a000d4e0a8969b14175", size = 12094496, upload-time = "2026-03-19T14:28:06.311Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user