mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 14:46:24 +02:00
Compare commits
13 Commits
1ee3a3a7e2
...
v2.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
337985ef38
|
|||
|
|
b5e6dfe6fe | ||
|
|
6681b7ade7 | ||
|
|
6981c33dc8 | ||
|
|
0c7a99039c | ||
|
|
bcb5b0bfda | ||
|
100e1c1aa9
|
|||
|
|
db6c7a565f | ||
|
|
768e405554 | ||
|
|
f0223ebde4 | ||
|
|
f8c9bf69fe | ||
|
6d6fae5538
|
|||
|
|
fc9cd1f034 |
28
.github/workflows/docs.yml
vendored
28
.github/workflows/docs.yml
vendored
@@ -5,7 +5,7 @@ on:
|
|||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
@@ -16,14 +16,9 @@ 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/checkout@v6
|
- uses: actions/configure-pages@v5
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Configure git
|
- uses: actions/checkout@v6
|
||||||
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
|
||||||
@@ -31,22 +26,9 @@ 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 docs
|
- run: uv sync --group dev
|
||||||
|
|
||||||
- name: Install mkdocs shim
|
- run: uv run zensical build --clean
|
||||||
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,6 +72,7 @@ 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
|
||||||
@@ -85,6 +86,8 @@ 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.
|
||||||
@@ -144,7 +147,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, "page": 1, "items_per_page": 10, "has_more": true }
|
"pagination": { "total_count": 42, "pages": 5, "page": 1, "items_per_page": 10, "has_more": true }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ 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
|
||||||
@@ -189,6 +190,40 @@ 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
|
||||||
@@ -238,7 +273,7 @@ The cursor column is set once on [`CrudFactory`](../reference/crud.md#fastapi_to
|
|||||||
!!! note
|
!!! note
|
||||||
`cursor_column` is required. Calling [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) on a CRUD class that has no `cursor_column` configured raises a `ValueError`.
|
`cursor_column` is required. Calling [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) on a CRUD class that has no `cursor_column` configured raises a `ValueError`.
|
||||||
|
|
||||||
The cursor value is base64-encoded when returned to the client and decoded back to the correct Python type on the next request. The following SQLAlchemy column types are supported:
|
The cursor value is URL-safe base64-encoded (no padding) when returned to the client and decoded back to the correct Python type on the next request. The following SQLAlchemy column types are supported:
|
||||||
|
|
||||||
| SQLAlchemy type | Python type |
|
| SQLAlchemy type | Python type |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -256,6 +291,24 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.id)
|
|||||||
PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Pagination params dependency
|
||||||
|
|
||||||
|
!!! info "Added in `v2.4.1`"
|
||||||
|
|
||||||
|
Use [`cursor_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_params) to inject `cursor` and `items_per_page` from query parameters with a `max_page_size` cap:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_users(
|
||||||
|
session: SessionDep,
|
||||||
|
params: Annotated[dict, Depends(UserCrud.cursor_params(default_page_size=20, max_page_size=100))],
|
||||||
|
) -> CursorPaginatedResponse[UserRead]:
|
||||||
|
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
||||||
|
```
|
||||||
|
|
||||||
### Unified endpoint (both strategies)
|
### Unified endpoint (both strategies)
|
||||||
|
|
||||||
!!! info "Added in `v2.3.0`"
|
!!! info "Added in `v2.3.0`"
|
||||||
@@ -289,7 +342,24 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
Both `page` and `cursor` are always accepted by the endpoint — unused parameters are silently ignored by `paginate()`.
|
#### Pagination params dependency
|
||||||
|
|
||||||
|
!!! info "Added in `v2.4.1`"
|
||||||
|
|
||||||
|
Use [`paginate_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate_params) to inject all parameters at once with configurable defaults and a `max_page_size` cap:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi_toolsets.schemas import PaginatedResponse
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_users(
|
||||||
|
session: SessionDep,
|
||||||
|
params: Annotated[dict, Depends(UserCrud.paginate_params(default_page_size=20, max_page_size=100))],
|
||||||
|
) -> PaginatedResponse[UserRead]:
|
||||||
|
return await UserCrud.paginate(session, **params, schema=UserRead)
|
||||||
|
```
|
||||||
|
|
||||||
## Search
|
## Search
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,23 @@ Server-side defaults (e.g. `id`, `created_at`) are fully populated in all callba
|
|||||||
| `@watch("status", "role")` | Only fires when `status` or `role` changes |
|
| `@watch("status", "role")` | Only fires when `status` or `role` changes |
|
||||||
| *(no decorator)* | Fires when **any** mapped field changes |
|
| *(no decorator)* | Fires when **any** mapped field changes |
|
||||||
|
|
||||||
|
`@watch` is inherited through the class hierarchy. If a subclass does not declare its own `@watch`, it uses the filter from the nearest decorated parent. Applying `@watch` on the subclass overrides the parent's filter:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@watch("status")
|
||||||
|
class Order(Base, UUIDMixin, WatchedFieldsMixin):
|
||||||
|
...
|
||||||
|
|
||||||
|
class UrgentOrder(Order):
|
||||||
|
# inherits @watch("status") — on_update fires only for status changes
|
||||||
|
...
|
||||||
|
|
||||||
|
@watch("priority")
|
||||||
|
class PriorityOrder(Order):
|
||||||
|
# overrides parent — on_update fires only for priority changes
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
#### Option 1 — catch-all with `on_event`
|
#### Option 1 — catch-all with `on_event`
|
||||||
|
|
||||||
Override `on_event` to handle all event types in one place. The specific methods delegate here by default:
|
Override `on_event` to handle all event types in one place. The specific methods delegate here by default:
|
||||||
@@ -197,6 +214,25 @@ The `changes` dict maps each watched field that changed to `{"old": ..., "new":
|
|||||||
|
|
||||||
!!! warning "Callbacks fire only for ORM-level changes. Rows updated via raw SQL (`UPDATE ... SET ...`) are not detected."
|
!!! warning "Callbacks fire only for ORM-level changes. Rows updated via raw SQL (`UPDATE ... SET ...`) are not detected."
|
||||||
|
|
||||||
|
!!! warning "Callbacks fire after the **outermost** transaction commits."
|
||||||
|
If you create several related objects using `CrudFactory.create` and need
|
||||||
|
callbacks to see all of them (including associations), wrap the whole
|
||||||
|
operation in a single [`get_transaction`](db.md) block. Without it, each
|
||||||
|
`create` call commits independently and `on_create` fires before the
|
||||||
|
remaining objects exist.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import get_transaction
|
||||||
|
|
||||||
|
async with get_transaction(session):
|
||||||
|
order = await OrderCrud.create(session, order_data)
|
||||||
|
item = await ItemCrud.create(session, item_data)
|
||||||
|
await session.refresh(order, attribute_names=["items"])
|
||||||
|
order.items.append(item)
|
||||||
|
# on_create fires here for both order and item,
|
||||||
|
# with the full association already committed.
|
||||||
|
```
|
||||||
|
|
||||||
## Composing mixins
|
## Composing mixins
|
||||||
|
|
||||||
All mixins can be combined in any order. The only constraint is that exactly one primary key must be defined — either via `UUIDMixin` or directly on the model.
|
All mixins can be combined in any order. The only constraint is that exactly one primary key must be defined — either via `UUIDMixin` or directly on the model.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from fastapi_toolsets.crud import OrderByClause, PaginationType
|
from fastapi_toolsets.crud import OrderByClause
|
||||||
from fastapi_toolsets.schemas import (
|
from fastapi_toolsets.schemas import (
|
||||||
CursorPaginatedResponse,
|
CursorPaginatedResponse,
|
||||||
OffsetPaginatedResponse,
|
OffsetPaginatedResponse,
|
||||||
@@ -20,19 +20,20 @@ router = APIRouter(prefix="/articles")
|
|||||||
@router.get("/offset")
|
@router.get("/offset")
|
||||||
async def list_articles_offset(
|
async def list_articles_offset(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
|
params: Annotated[
|
||||||
|
dict,
|
||||||
|
Depends(ArticleCrud.offset_params(default_page_size=20, max_page_size=100)),
|
||||||
|
],
|
||||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
||||||
order_by: Annotated[
|
order_by: Annotated[
|
||||||
OrderByClause | None,
|
OrderByClause | None,
|
||||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||||
],
|
],
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
items_per_page: int = Query(20, ge=1, le=100),
|
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
) -> OffsetPaginatedResponse[ArticleRead]:
|
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||||
return await ArticleCrud.offset_paginate(
|
return await ArticleCrud.offset_paginate(
|
||||||
session=session,
|
session=session,
|
||||||
page=page,
|
**params,
|
||||||
items_per_page=items_per_page,
|
|
||||||
search=search,
|
search=search,
|
||||||
filter_by=filter_by or None,
|
filter_by=filter_by or None,
|
||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
@@ -43,19 +44,20 @@ 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,
|
||||||
cursor=cursor,
|
**params,
|
||||||
items_per_page=items_per_page,
|
|
||||||
search=search,
|
search=search,
|
||||||
filter_by=filter_by or None,
|
filter_by=filter_by or None,
|
||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
@@ -66,23 +68,20 @@ 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,
|
||||||
pagination_type=pagination_type,
|
**params,
|
||||||
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,
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# 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.3.0"
|
version = "2.4.2"
|
||||||
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,9 +79,8 @@ 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.28",
|
"zensical>=0.0.23",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
#!/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.3.0"
|
__version__ = "2.4.2"
|
||||||
|
|||||||
@@ -58,18 +58,33 @@ 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 base64 string."""
|
"""Encode a cursor column value and navigation direction as a URL-safe base64 string."""
|
||||||
return base64.b64encode(
|
return (
|
||||||
json.dumps({"val": str(value), "dir": direction}).encode()
|
base64.urlsafe_b64encode(
|
||||||
).decode()
|
json.dumps({"val": str(value), "dir": direction}).encode()
|
||||||
|
)
|
||||||
|
.decode()
|
||||||
|
.rstrip("=")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _decode_cursor(cursor: str) -> tuple[str, _CursorDirection]:
|
def _decode_cursor(cursor: str) -> tuple[str, _CursorDirection]:
|
||||||
"""Decode a cursor base64 string into ``(raw_value, direction)``."""
|
"""Decode a URL-safe base64 cursor string into ``(raw_value, direction)``."""
|
||||||
payload = json.loads(base64.b64decode(cursor.encode()).decode())
|
padded = cursor + "=" * (-len(cursor) % 4)
|
||||||
|
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):
|
||||||
@@ -254,6 +269,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
) -> Callable[..., Awaitable[dict[str, list[str]]]]:
|
) -> Callable[..., Awaitable[dict[str, list[str]]]]:
|
||||||
"""Return a FastAPI dependency that collects facet filter values from query parameters.
|
"""Return a FastAPI dependency that collects facet filter values from query parameters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
facet_fields: Override the facet fields for this dependency. Falls back to the
|
facet_fields: Override the facet fields for this dependency. Falls back to the
|
||||||
class-level ``facet_fields`` if not provided.
|
class-level ``facet_fields`` if not provided.
|
||||||
@@ -293,6 +309,121 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
|
|
||||||
return dependency
|
return dependency
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def offset_params(
|
||||||
|
cls: type[Self],
|
||||||
|
*,
|
||||||
|
default_page_size: int = 20,
|
||||||
|
max_page_size: int = 100,
|
||||||
|
include_total: bool = True,
|
||||||
|
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||||
|
"""Return a FastAPI dependency that collects offset pagination params from query params.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
default_page_size: Default value for the ``items_per_page`` query parameter.
|
||||||
|
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
||||||
|
``le`` on the ``Query``).
|
||||||
|
include_total: Server-side flag forwarded as-is to ``include_total`` in
|
||||||
|
:meth:`offset_paginate`. Not exposed as a query parameter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An async dependency that resolves to a dict with ``page``,
|
||||||
|
``items_per_page``, and ``include_total`` keys, ready to be
|
||||||
|
unpacked into :meth:`offset_paginate`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dependency(
|
||||||
|
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||||
|
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"page": page,
|
||||||
|
"items_per_page": items_per_page,
|
||||||
|
"include_total": include_total,
|
||||||
|
}
|
||||||
|
|
||||||
|
dependency.__name__ = f"{cls.model.__name__}OffsetParams"
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cursor_params(
|
||||||
|
cls: type[Self],
|
||||||
|
*,
|
||||||
|
default_page_size: int = 20,
|
||||||
|
max_page_size: int = 100,
|
||||||
|
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||||
|
"""Return a FastAPI dependency that collects cursor pagination params from query params.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
default_page_size: Default value for the ``items_per_page`` query parameter.
|
||||||
|
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
||||||
|
``le`` on the ``Query``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An async dependency that resolves to a dict with ``cursor`` and
|
||||||
|
``items_per_page`` keys, ready to be unpacked into
|
||||||
|
:meth:`cursor_paginate`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dependency(
|
||||||
|
cursor: str | None = Query(
|
||||||
|
None, description="Cursor token from a previous response"
|
||||||
|
),
|
||||||
|
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {"cursor": cursor, "items_per_page": items_per_page}
|
||||||
|
|
||||||
|
dependency.__name__ = f"{cls.model.__name__}CursorParams"
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def paginate_params(
|
||||||
|
cls: type[Self],
|
||||||
|
*,
|
||||||
|
default_page_size: int = 20,
|
||||||
|
max_page_size: int = 100,
|
||||||
|
default_pagination_type: PaginationType = PaginationType.OFFSET,
|
||||||
|
include_total: bool = True,
|
||||||
|
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||||
|
"""Return a FastAPI dependency that collects all pagination params from query params.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
default_page_size: Default value for the ``items_per_page`` query parameter.
|
||||||
|
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
||||||
|
``le`` on the ``Query``).
|
||||||
|
default_pagination_type: Default pagination strategy.
|
||||||
|
include_total: Server-side flag forwarded as-is to ``include_total`` in
|
||||||
|
:meth:`paginate`. Not exposed as a query parameter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An async dependency that resolves to a dict with ``pagination_type``,
|
||||||
|
``page``, ``cursor``, ``items_per_page``, and ``include_total`` keys,
|
||||||
|
ready to be unpacked into :meth:`paginate`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dependency(
|
||||||
|
pagination_type: PaginationType = Query(
|
||||||
|
default_pagination_type, description="Pagination strategy"
|
||||||
|
),
|
||||||
|
page: int = Query(
|
||||||
|
1, ge=1, description="Page number (1-indexed, offset only)"
|
||||||
|
),
|
||||||
|
cursor: str | None = Query(
|
||||||
|
None, description="Cursor token from a previous response (cursor only)"
|
||||||
|
),
|
||||||
|
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"pagination_type": pagination_type,
|
||||||
|
"page": page,
|
||||||
|
"cursor": cursor,
|
||||||
|
"items_per_page": items_per_page,
|
||||||
|
"include_total": include_total,
|
||||||
|
}
|
||||||
|
|
||||||
|
dependency.__name__ = f"{cls.model.__name__}PaginateParams"
|
||||||
|
return dependency
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def order_params(
|
def order_params(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
@@ -922,6 +1053,7 @@ 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,
|
||||||
@@ -939,6 +1071,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
order_by: Column or list of columns to order by
|
order_by: Column or list of columns to order by
|
||||||
page: Page number (1-indexed)
|
page: Page number (1-indexed)
|
||||||
items_per_page: Number of items per page
|
items_per_page: Number of items per page
|
||||||
|
include_total: When ``False``, skip the ``COUNT`` query;
|
||||||
|
``pagination.total_count`` will be ``None``.
|
||||||
search: Search query string or SearchConfig object
|
search: Search query string or SearchConfig object
|
||||||
search_fields: Fields to search in (overrides class default)
|
search_fields: Fields to search in (overrides class default)
|
||||||
facet_fields: Columns to compute distinct values for (overrides class default)
|
facet_fields: Columns to compute distinct values for (overrides class default)
|
||||||
@@ -983,28 +1117,39 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
if order_by is not None:
|
if order_by is not None:
|
||||||
q = q.order_by(order_by)
|
q = q.order_by(order_by)
|
||||||
|
|
||||||
q = q.offset(offset).limit(items_per_page)
|
if include_total:
|
||||||
result = await session.execute(q)
|
q = q.offset(offset).limit(items_per_page)
|
||||||
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
result = await session.execute(q)
|
||||||
|
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
||||||
|
|
||||||
|
# Count query (with same joins and filters)
|
||||||
|
pk_col = cls.model.__mapper__.primary_key[0]
|
||||||
|
count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
|
||||||
|
count_q = count_q.select_from(cls.model)
|
||||||
|
|
||||||
|
# Apply explicit joins to count query
|
||||||
|
count_q = _apply_joins(count_q, joins, outer_join)
|
||||||
|
|
||||||
|
# Apply search joins to count query
|
||||||
|
count_q = _apply_search_joins(count_q, search_joins)
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
count_q = count_q.where(and_(*filters))
|
||||||
|
|
||||||
|
count_result = await session.execute(count_q)
|
||||||
|
total_count: int | None = count_result.scalar_one()
|
||||||
|
has_more = page * items_per_page < total_count
|
||||||
|
else:
|
||||||
|
# Fetch one extra row to detect if a next page exists without COUNT
|
||||||
|
q = q.offset(offset).limit(items_per_page + 1)
|
||||||
|
result = await session.execute(q)
|
||||||
|
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
||||||
|
has_more = len(raw_items) > items_per_page
|
||||||
|
raw_items = raw_items[:items_per_page]
|
||||||
|
total_count = None
|
||||||
|
|
||||||
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
||||||
|
|
||||||
# Count query (with same joins and filters)
|
|
||||||
pk_col = cls.model.__mapper__.primary_key[0]
|
|
||||||
count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
|
|
||||||
count_q = count_q.select_from(cls.model)
|
|
||||||
|
|
||||||
# Apply explicit joins to count query
|
|
||||||
count_q = _apply_joins(count_q, joins, outer_join)
|
|
||||||
|
|
||||||
# Apply search joins to count query
|
|
||||||
count_q = _apply_search_joins(count_q, search_joins)
|
|
||||||
|
|
||||||
if filters:
|
|
||||||
count_q = count_q.where(and_(*filters))
|
|
||||||
|
|
||||||
count_result = await session.execute(count_q)
|
|
||||||
total_count = count_result.scalar_one()
|
|
||||||
|
|
||||||
filter_attributes = await cls._build_filter_attributes(
|
filter_attributes = await cls._build_filter_attributes(
|
||||||
session, facet_fields, filters, search_joins
|
session, facet_fields, filters, search_joins
|
||||||
)
|
)
|
||||||
@@ -1015,7 +1160,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=page * items_per_page < total_count,
|
has_more=has_more,
|
||||||
),
|
),
|
||||||
filter_attributes=filter_attributes,
|
filter_attributes=filter_attributes,
|
||||||
)
|
)
|
||||||
@@ -1190,6 +1335,7 @@ 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 = ...,
|
||||||
@@ -1212,6 +1358,7 @@ 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 = ...,
|
||||||
@@ -1233,6 +1380,7 @@ 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,
|
||||||
@@ -1258,6 +1406,8 @@ 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
|
||||||
@@ -1304,6 +1454,7 @@ 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,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Field-change monitoring via SQLAlchemy session events."""
|
"""Field-change monitoring via SQLAlchemy session events."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import weakref
|
import weakref
|
||||||
from collections.abc import Awaitable
|
from collections.abc import Awaitable
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -9,6 +10,7 @@ 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
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ _SESSION_PENDING_NEW = "_ft_pending_new"
|
|||||||
_SESSION_CREATES = "_ft_creates"
|
_SESSION_CREATES = "_ft_creates"
|
||||||
_SESSION_DELETES = "_ft_deletes"
|
_SESSION_DELETES = "_ft_deletes"
|
||||||
_SESSION_UPDATES = "_ft_updates"
|
_SESSION_UPDATES = "_ft_updates"
|
||||||
|
_SESSION_SAVEPOINT_DEPTH = "_ft_sp_depth"
|
||||||
|
|
||||||
|
|
||||||
class ModelEvent(str, Enum):
|
class ModelEvent(str, Enum):
|
||||||
@@ -53,6 +56,25 @@ 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 _get_watched_fields(cls: type) -> list[str] | None:
|
||||||
|
"""Return the watched fields for *cls*, walking the MRO to inherit from parents."""
|
||||||
|
for klass in cls.__mro__:
|
||||||
|
if klass in _WATCHED_FIELDS:
|
||||||
|
return _WATCHED_FIELDS[klass]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _upsert_changes(
|
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,
|
||||||
@@ -71,6 +93,22 @@ def _upsert_changes(
|
|||||||
pending[key] = (obj, changes)
|
pending[key] = (obj, changes)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(AsyncSession.sync_session_class, "after_transaction_create")
|
||||||
|
def _after_transaction_create(session: Any, transaction: Any) -> None:
|
||||||
|
if transaction.nested:
|
||||||
|
session.info[_SESSION_SAVEPOINT_DEPTH] = (
|
||||||
|
session.info.get(_SESSION_SAVEPOINT_DEPTH, 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(AsyncSession.sync_session_class, "after_transaction_end")
|
||||||
|
def _after_transaction_end(session: Any, transaction: Any) -> None:
|
||||||
|
if transaction.nested:
|
||||||
|
depth = session.info.get(_SESSION_SAVEPOINT_DEPTH, 0)
|
||||||
|
if depth > 0: # pragma: no branch
|
||||||
|
session.info[_SESSION_SAVEPOINT_DEPTH] = depth - 1
|
||||||
|
|
||||||
|
|
||||||
@event.listens_for(AsyncSession.sync_session_class, "after_flush")
|
@event.listens_for(AsyncSession.sync_session_class, "after_flush")
|
||||||
def _after_flush(session: Any, flush_context: Any) -> None:
|
def _after_flush(session: Any, flush_context: Any) -> None:
|
||||||
# New objects: capture references while session.new is still populated.
|
# New objects: capture references while session.new is still populated.
|
||||||
@@ -90,7 +128,7 @@ def _after_flush(session: Any, flush_context: Any) -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# None = not in dict = watch all fields; list = specific fields only
|
# None = not in dict = watch all fields; list = specific fields only
|
||||||
watched = _WATCHED_FIELDS.get(type(obj))
|
watched = _get_watched_fields(type(obj))
|
||||||
changes: dict[str, dict[str, Any]] = {}
|
changes: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
attrs = (
|
attrs = (
|
||||||
@@ -139,26 +177,53 @@ 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 _call_callback(loop: asyncio.AbstractEventLoop, fn: Any, *args: Any) -> None:
|
def _schedule_with_snapshot(
|
||||||
"""Dispatch *fn* with *args*, handling both sync and async callables."""
|
loop: asyncio.AbstractEventLoop, obj: Any, fn: Any, *args: Any
|
||||||
try:
|
) -> None:
|
||||||
result = fn(*args)
|
"""Snapshot *obj*'s column attrs now (before expire_on_commit wipes them),
|
||||||
except Exception as exc:
|
then schedule a coroutine that restores the snapshot and calls *fn*.
|
||||||
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
"""
|
||||||
return
|
snapshot = _snapshot_column_attrs(obj)
|
||||||
if asyncio.iscoroutine(result):
|
|
||||||
task = loop.create_task(result)
|
async def _run(
|
||||||
task.add_done_callback(_task_error_handler)
|
obj: Any = obj,
|
||||||
|
fn: Any = fn,
|
||||||
|
snapshot: dict[str, Any] = snapshot,
|
||||||
|
args: tuple = args,
|
||||||
|
) -> None:
|
||||||
|
for key, value in snapshot.items():
|
||||||
|
_sa_set_committed_value(obj, key, value)
|
||||||
|
try:
|
||||||
|
result = fn(*args)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||||
|
|
||||||
|
task = loop.create_task(_run())
|
||||||
|
task.add_done_callback(_task_error_handler)
|
||||||
|
|
||||||
|
|
||||||
@event.listens_for(AsyncSession.sync_session_class, "after_commit")
|
@event.listens_for(AsyncSession.sync_session_class, "after_commit")
|
||||||
def _after_commit(session: Any) -> None:
|
def _after_commit(session: Any) -> None:
|
||||||
|
if session.info.get(_SESSION_SAVEPOINT_DEPTH, 0) > 0:
|
||||||
|
return
|
||||||
|
|
||||||
creates: list[Any] = session.info.pop(_SESSION_CREATES, [])
|
creates: list[Any] = session.info.pop(_SESSION_CREATES, [])
|
||||||
deletes: list[Any] = session.info.pop(_SESSION_DELETES, [])
|
deletes: list[Any] = session.info.pop(_SESSION_DELETES, [])
|
||||||
field_changes: dict[int, tuple[Any, dict[str, dict[str, Any]]]] = session.info.pop(
|
field_changes: dict[int, tuple[Any, dict[str, dict[str, Any]]]] = session.info.pop(
|
||||||
_SESSION_UPDATES, {}
|
_SESSION_UPDATES, {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if creates and deletes:
|
||||||
|
transient_ids = {id(o) for o in creates} & {id(o) for o in deletes}
|
||||||
|
if transient_ids:
|
||||||
|
creates = [o for o in creates if id(o) not in transient_ids]
|
||||||
|
deletes = [o for o in deletes if id(o) not in transient_ids]
|
||||||
|
field_changes = {
|
||||||
|
k: v for k, v in field_changes.items() if k not in transient_ids
|
||||||
|
}
|
||||||
|
|
||||||
if not creates and not deletes and not field_changes:
|
if not creates and not deletes and not field_changes:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -168,13 +233,13 @@ def _after_commit(session: Any) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
for obj in creates:
|
for obj in creates:
|
||||||
_call_callback(loop, obj.on_create)
|
_schedule_with_snapshot(loop, obj, obj.on_create)
|
||||||
|
|
||||||
for obj in deletes:
|
for obj in deletes:
|
||||||
_call_callback(loop, obj.on_delete)
|
_schedule_with_snapshot(loop, obj, obj.on_delete)
|
||||||
|
|
||||||
for obj, changes in field_changes.values():
|
for obj, changes in field_changes.values():
|
||||||
_call_callback(loop, obj.on_update, changes)
|
_schedule_with_snapshot(loop, obj, obj.on_update, changes)
|
||||||
|
|
||||||
|
|
||||||
class WatchedFieldsMixin:
|
class WatchedFieldsMixin:
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""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
|
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
from .types import DataT
|
from .types import DataT
|
||||||
|
|
||||||
@@ -98,17 +99,29 @@ class OffsetPagination(PydanticBase):
|
|||||||
"""Pagination metadata for offset-based list responses.
|
"""Pagination metadata for offset-based list responses.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
total_count: Total number of items across all pages
|
total_count: Total number of items across all pages.
|
||||||
|
``None`` when ``include_total=False``.
|
||||||
items_per_page: Number of items per page
|
items_per_page: Number of items per page
|
||||||
page: Current page number (1-indexed)
|
page: Current page number (1-indexed)
|
||||||
has_more: Whether there are more pages
|
has_more: Whether there are more pages
|
||||||
|
pages: Total number of pages
|
||||||
"""
|
"""
|
||||||
|
|
||||||
total_count: int
|
total_count: int | None
|
||||||
items_per_page: int
|
items_per_page: int
|
||||||
page: int
|
page: int
|
||||||
has_more: bool
|
has_more: bool
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def pages(self) -> int | None:
|
||||||
|
"""Total number of pages, or ``None`` when ``total_count`` is unknown."""
|
||||||
|
if self.total_count is None:
|
||||||
|
return None
|
||||||
|
if self.items_per_page == 0:
|
||||||
|
return 0
|
||||||
|
return math.ceil(self.total_count / self.items_per_page)
|
||||||
|
|
||||||
|
|
||||||
class CursorPagination(PydanticBase):
|
class CursorPagination(PydanticBase):
|
||||||
"""Pagination metadata for cursor-based list responses.
|
"""Pagination metadata for cursor-based list responses.
|
||||||
|
|||||||
@@ -321,30 +321,3 @@ async def db_session(engine):
|
|||||||
# Drop tables after test
|
# Drop tables after test
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_role_data() -> RoleCreate:
|
|
||||||
"""Sample role creation data."""
|
|
||||||
return RoleCreate(name="admin")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_user_data() -> UserCreate:
|
|
||||||
"""Sample user creation data."""
|
|
||||||
return UserCreate(
|
|
||||||
username="testuser",
|
|
||||||
email="test@example.com",
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_post_data() -> PostCreate:
|
|
||||||
"""Sample post creation data."""
|
|
||||||
return PostCreate(
|
|
||||||
title="Test Post",
|
|
||||||
content="Test content",
|
|
||||||
is_published=True,
|
|
||||||
author_id=uuid.uuid4(),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1759,6 +1759,52 @@ class TestSchemaResponse:
|
|||||||
assert result.data[0].username == "pg_user"
|
assert result.data[0].username == "pg_user"
|
||||||
assert not hasattr(result.data[0], "email")
|
assert not hasattr(result.data[0], "email")
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_include_total_false_skips_count(self, db_session: AsyncSession):
|
||||||
|
"""offset_paginate with include_total=False returns total_count=None."""
|
||||||
|
from fastapi_toolsets.schemas import OffsetPagination
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
result = await RoleCrud.offset_paginate(
|
||||||
|
db_session, items_per_page=10, include_total=False, schema=RoleRead
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
|
assert result.pagination.total_count is None
|
||||||
|
assert len(result.data) == 5
|
||||||
|
assert result.pagination.has_more is False
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_include_total_false_has_more_true(self, db_session: AsyncSession):
|
||||||
|
"""offset_paginate with include_total=False sets has_more via extra-row probe."""
|
||||||
|
for i in range(15):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
result = await RoleCrud.offset_paginate(
|
||||||
|
db_session, items_per_page=10, include_total=False, schema=RoleRead
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.pagination.total_count is None
|
||||||
|
assert result.pagination.has_more is True
|
||||||
|
assert len(result.data) == 10
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_include_total_false_exact_page_boundary(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""offset_paginate with include_total=False: has_more=False when items == page size."""
|
||||||
|
for i in range(10):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
result = await RoleCrud.offset_paginate(
|
||||||
|
db_session, items_per_page=10, include_total=False, schema=RoleRead
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.pagination.has_more is False
|
||||||
|
assert len(result.data) == 10
|
||||||
|
|
||||||
|
|
||||||
class TestCursorPaginate:
|
class TestCursorPaginate:
|
||||||
"""Tests for cursor-based pagination via cursor_paginate()."""
|
"""Tests for cursor-based pagination via cursor_paginate()."""
|
||||||
@@ -2521,3 +2567,20 @@ 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,12 +14,14 @@ from fastapi_toolsets.crud import (
|
|||||||
get_searchable_fields,
|
get_searchable_fields,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.exceptions import InvalidOrderFieldError
|
from fastapi_toolsets.exceptions import InvalidOrderFieldError
|
||||||
from fastapi_toolsets.schemas import OffsetPagination
|
from fastapi_toolsets.schemas import OffsetPagination, PaginationType
|
||||||
|
|
||||||
from .conftest import (
|
from .conftest import (
|
||||||
Role,
|
Role,
|
||||||
RoleCreate,
|
RoleCreate,
|
||||||
RoleCrud,
|
RoleCrud,
|
||||||
|
RoleCursorCrud,
|
||||||
|
RoleRead,
|
||||||
User,
|
User,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserCrud,
|
UserCrud,
|
||||||
@@ -1193,3 +1195,245 @@ class TestOrderParamsSchema:
|
|||||||
|
|
||||||
assert results[0].username == "alice"
|
assert results[0].username == "alice"
|
||||||
assert results[1].username == "charlie"
|
assert results[1].username == "charlie"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOffsetParamsSchema:
|
||||||
|
"""Tests for AsyncCrud.offset_params()."""
|
||||||
|
|
||||||
|
def test_returns_page_and_items_per_page_params(self):
|
||||||
|
"""Returned dependency has page and items_per_page params only."""
|
||||||
|
dep = RoleCrud.offset_params()
|
||||||
|
param_names = set(inspect.signature(dep).parameters)
|
||||||
|
assert param_names == {"page", "items_per_page"}
|
||||||
|
|
||||||
|
def test_dependency_name_includes_model_name(self):
|
||||||
|
"""Dependency function is named after the model."""
|
||||||
|
dep = RoleCrud.offset_params()
|
||||||
|
assert getattr(dep, "__name__") == "RoleOffsetParams"
|
||||||
|
|
||||||
|
def test_default_page_size_reflected_in_items_per_page_default(self):
|
||||||
|
"""default_page_size is used as the default for items_per_page."""
|
||||||
|
dep = RoleCrud.offset_params(default_page_size=42)
|
||||||
|
sig = inspect.signature(dep)
|
||||||
|
assert sig.parameters["items_per_page"].default.default == 42
|
||||||
|
|
||||||
|
def test_max_page_size_reflected_in_items_per_page_le(self):
|
||||||
|
"""max_page_size is used as le constraint on items_per_page."""
|
||||||
|
dep = RoleCrud.offset_params(max_page_size=50)
|
||||||
|
sig = inspect.signature(dep)
|
||||||
|
le = next(
|
||||||
|
m.le
|
||||||
|
for m in sig.parameters["items_per_page"].default.metadata
|
||||||
|
if hasattr(m, "le")
|
||||||
|
)
|
||||||
|
assert le == 50
|
||||||
|
|
||||||
|
def test_include_total_not_a_query_param(self):
|
||||||
|
"""include_total is not exposed as a query parameter."""
|
||||||
|
dep = RoleCrud.offset_params()
|
||||||
|
param_names = set(inspect.signature(dep).parameters)
|
||||||
|
assert "include_total" not in param_names
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_include_total_true_forwarded_in_result(self):
|
||||||
|
"""include_total=True factory arg appears in the resolved dict."""
|
||||||
|
result = await RoleCrud.offset_params(include_total=True)(
|
||||||
|
page=1, items_per_page=10
|
||||||
|
)
|
||||||
|
assert result["include_total"] is True
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_include_total_false_forwarded_in_result(self):
|
||||||
|
"""include_total=False factory arg appears in the resolved dict."""
|
||||||
|
result = await RoleCrud.offset_params(include_total=False)(
|
||||||
|
page=1, items_per_page=10
|
||||||
|
)
|
||||||
|
assert result["include_total"] is False
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_awaiting_dep_returns_dict(self):
|
||||||
|
"""Awaiting the dependency returns a dict with page, items_per_page, include_total."""
|
||||||
|
dep = RoleCrud.offset_params(include_total=False)
|
||||||
|
result = await dep(page=2, items_per_page=10)
|
||||||
|
assert result == {"page": 2, "items_per_page": 10, "include_total": False}
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_integrates_with_offset_paginate(self, db_session: AsyncSession):
|
||||||
|
"""offset_params output can be unpacked directly into offset_paginate."""
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
dep = RoleCrud.offset_params()
|
||||||
|
params = await dep(page=1, items_per_page=10)
|
||||||
|
result = await RoleCrud.offset_paginate(db_session, **params, schema=RoleRead)
|
||||||
|
assert result.pagination.page == 1
|
||||||
|
assert result.pagination.items_per_page == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestCursorParamsSchema:
|
||||||
|
"""Tests for AsyncCrud.cursor_params()."""
|
||||||
|
|
||||||
|
def test_returns_cursor_and_items_per_page_params(self):
|
||||||
|
"""Returned dependency has cursor and items_per_page params."""
|
||||||
|
dep = RoleCursorCrud.cursor_params()
|
||||||
|
param_names = set(inspect.signature(dep).parameters)
|
||||||
|
assert param_names == {"cursor", "items_per_page"}
|
||||||
|
|
||||||
|
def test_dependency_name_includes_model_name(self):
|
||||||
|
"""Dependency function is named after the model."""
|
||||||
|
dep = RoleCursorCrud.cursor_params()
|
||||||
|
assert getattr(dep, "__name__") == "RoleCursorParams"
|
||||||
|
|
||||||
|
def test_default_page_size_reflected_in_items_per_page_default(self):
|
||||||
|
"""default_page_size is used as the default for items_per_page."""
|
||||||
|
dep = RoleCursorCrud.cursor_params(default_page_size=15)
|
||||||
|
sig = inspect.signature(dep)
|
||||||
|
assert sig.parameters["items_per_page"].default.default == 15
|
||||||
|
|
||||||
|
def test_max_page_size_reflected_in_items_per_page_le(self):
|
||||||
|
"""max_page_size is used as le constraint on items_per_page."""
|
||||||
|
dep = RoleCursorCrud.cursor_params(max_page_size=75)
|
||||||
|
sig = inspect.signature(dep)
|
||||||
|
le = next(
|
||||||
|
m.le
|
||||||
|
for m in sig.parameters["items_per_page"].default.metadata
|
||||||
|
if hasattr(m, "le")
|
||||||
|
)
|
||||||
|
assert le == 75
|
||||||
|
|
||||||
|
def test_cursor_defaults_to_none(self):
|
||||||
|
"""cursor defaults to None."""
|
||||||
|
dep = RoleCursorCrud.cursor_params()
|
||||||
|
sig = inspect.signature(dep)
|
||||||
|
assert sig.parameters["cursor"].default.default is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_awaiting_dep_returns_dict(self):
|
||||||
|
"""Awaiting the dependency returns a dict with cursor and items_per_page."""
|
||||||
|
dep = RoleCursorCrud.cursor_params()
|
||||||
|
result = await dep(cursor=None, items_per_page=5)
|
||||||
|
assert result == {"cursor": None, "items_per_page": 5}
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_integrates_with_cursor_paginate(self, db_session: AsyncSession):
|
||||||
|
"""cursor_params output can be unpacked directly into cursor_paginate."""
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
dep = RoleCursorCrud.cursor_params()
|
||||||
|
params = await dep(cursor=None, items_per_page=10)
|
||||||
|
result = await RoleCursorCrud.cursor_paginate(
|
||||||
|
db_session, **params, schema=RoleRead
|
||||||
|
)
|
||||||
|
assert result.pagination.items_per_page == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaginateParamsSchema:
|
||||||
|
"""Tests for AsyncCrud.paginate_params()."""
|
||||||
|
|
||||||
|
def test_returns_all_params(self):
|
||||||
|
"""Returned dependency has pagination_type, page, cursor, items_per_page (no include_total)."""
|
||||||
|
dep = RoleCursorCrud.paginate_params()
|
||||||
|
param_names = set(inspect.signature(dep).parameters)
|
||||||
|
assert param_names == {"pagination_type", "page", "cursor", "items_per_page"}
|
||||||
|
|
||||||
|
def test_dependency_name_includes_model_name(self):
|
||||||
|
"""Dependency function is named after the model."""
|
||||||
|
dep = RoleCursorCrud.paginate_params()
|
||||||
|
assert getattr(dep, "__name__") == "RolePaginateParams"
|
||||||
|
|
||||||
|
def test_default_pagination_type(self):
|
||||||
|
"""default_pagination_type is reflected in pagination_type default."""
|
||||||
|
from fastapi_toolsets.schemas import PaginationType
|
||||||
|
|
||||||
|
dep = RoleCursorCrud.paginate_params(
|
||||||
|
default_pagination_type=PaginationType.CURSOR
|
||||||
|
)
|
||||||
|
sig = inspect.signature(dep)
|
||||||
|
assert (
|
||||||
|
sig.parameters["pagination_type"].default.default == PaginationType.CURSOR
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_page_size(self):
|
||||||
|
"""default_page_size is reflected in items_per_page default."""
|
||||||
|
dep = RoleCursorCrud.paginate_params(default_page_size=15)
|
||||||
|
sig = inspect.signature(dep)
|
||||||
|
assert sig.parameters["items_per_page"].default.default == 15
|
||||||
|
|
||||||
|
def test_max_page_size_le_constraint(self):
|
||||||
|
"""max_page_size is used as le constraint on items_per_page."""
|
||||||
|
dep = RoleCursorCrud.paginate_params(max_page_size=60)
|
||||||
|
sig = inspect.signature(dep)
|
||||||
|
le = next(
|
||||||
|
m.le
|
||||||
|
for m in sig.parameters["items_per_page"].default.metadata
|
||||||
|
if hasattr(m, "le")
|
||||||
|
)
|
||||||
|
assert le == 60
|
||||||
|
|
||||||
|
def test_include_total_not_a_query_param(self):
|
||||||
|
"""include_total is not exposed as a query parameter."""
|
||||||
|
dep = RoleCursorCrud.paginate_params()
|
||||||
|
assert "include_total" not in set(inspect.signature(dep).parameters)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_include_total_forwarded_in_result(self):
|
||||||
|
"""include_total factory arg appears in the resolved dict."""
|
||||||
|
result_true = await RoleCursorCrud.paginate_params(include_total=True)(
|
||||||
|
pagination_type=PaginationType.OFFSET,
|
||||||
|
page=1,
|
||||||
|
cursor=None,
|
||||||
|
items_per_page=10,
|
||||||
|
)
|
||||||
|
result_false = await RoleCursorCrud.paginate_params(include_total=False)(
|
||||||
|
pagination_type=PaginationType.OFFSET,
|
||||||
|
page=1,
|
||||||
|
cursor=None,
|
||||||
|
items_per_page=10,
|
||||||
|
)
|
||||||
|
assert result_true["include_total"] is True
|
||||||
|
assert result_false["include_total"] is False
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_awaiting_dep_returns_dict(self):
|
||||||
|
"""Awaiting the dependency returns a dict with all pagination keys."""
|
||||||
|
dep = RoleCursorCrud.paginate_params()
|
||||||
|
result = await dep(
|
||||||
|
pagination_type=PaginationType.OFFSET,
|
||||||
|
page=2,
|
||||||
|
cursor=None,
|
||||||
|
items_per_page=10,
|
||||||
|
)
|
||||||
|
assert result == {
|
||||||
|
"pagination_type": PaginationType.OFFSET,
|
||||||
|
"page": 2,
|
||||||
|
"cursor": None,
|
||||||
|
"items_per_page": 10,
|
||||||
|
"include_total": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_integrates_with_paginate_offset(self, db_session: AsyncSession):
|
||||||
|
"""paginate_params output unpacks into paginate() for offset strategy."""
|
||||||
|
from fastapi_toolsets.schemas import OffsetPagination
|
||||||
|
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
params = await RoleCursorCrud.paginate_params()(
|
||||||
|
pagination_type=PaginationType.OFFSET,
|
||||||
|
page=1,
|
||||||
|
cursor=None,
|
||||||
|
items_per_page=10,
|
||||||
|
)
|
||||||
|
result = await RoleCursorCrud.paginate(db_session, **params, schema=RoleRead)
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_integrates_with_paginate_cursor(self, db_session: AsyncSession):
|
||||||
|
"""paginate_params output unpacks into paginate() for cursor strategy."""
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
params = await RoleCursorCrud.paginate_params()(
|
||||||
|
pagination_type=PaginationType.CURSOR,
|
||||||
|
page=1,
|
||||||
|
cursor=None,
|
||||||
|
items_per_page=10,
|
||||||
|
)
|
||||||
|
result = await RoleCursorCrud.paginate(db_session, **params, schema=RoleRead)
|
||||||
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import datetime
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from docs_src.examples.pagination_search.db import get_db
|
from docs_src.examples.pagination_search.db import get_db
|
||||||
from docs_src.examples.pagination_search.models import Article, Base, Category
|
from docs_src.examples.pagination_search.models import Article, Base, Category
|
||||||
from docs_src.examples.pagination_search.routes import router
|
from docs_src.examples.pagination_search.routes import router
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||||
|
from fastapi_toolsets.pytest import create_db_session
|
||||||
|
|
||||||
from .conftest import DATABASE_URL
|
from .conftest import DATABASE_URL
|
||||||
|
|
||||||
@@ -35,20 +36,8 @@ def build_app(session: AsyncSession) -> FastAPI:
|
|||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
async def ex_db_session():
|
async def ex_db_session():
|
||||||
"""Isolated session for the example models (separate tables from conftest)."""
|
"""Isolated session for the example models (separate tables from conftest)."""
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
async with create_db_session(DATABASE_URL, Base) as session:
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
|
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
session = session_factory()
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield session
|
yield session
|
||||||
finally:
|
|
||||||
await session.close()
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from fastapi_toolsets.fixtures import (
|
|||||||
|
|
||||||
from fastapi_toolsets.fixtures.utils import _get_primary_key
|
from fastapi_toolsets.fixtures.utils import _get_primary_key
|
||||||
|
|
||||||
from .conftest import IntRole, Permission, Role, User
|
from .conftest import IntRole, Permission, Role, RoleCrud, User, UserCrud
|
||||||
|
|
||||||
|
|
||||||
class TestContext:
|
class TestContext:
|
||||||
@@ -447,8 +447,6 @@ class TestLoadFixtures:
|
|||||||
assert "roles" in result
|
assert "roles" in result
|
||||||
assert len(result["roles"]) == 2
|
assert len(result["roles"]) == 2
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
@@ -479,8 +477,6 @@ class TestLoadFixtures:
|
|||||||
assert "roles" in result
|
assert "roles" in result
|
||||||
assert "users" in result
|
assert "users" in result
|
||||||
|
|
||||||
from .conftest import RoleCrud, UserCrud
|
|
||||||
|
|
||||||
assert await RoleCrud.count(db_session) == 1
|
assert await RoleCrud.count(db_session) == 1
|
||||||
assert await UserCrud.count(db_session) == 1
|
assert await UserCrud.count(db_session) == 1
|
||||||
|
|
||||||
@@ -497,8 +493,6 @@ class TestLoadFixtures:
|
|||||||
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
||||||
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
@@ -526,8 +520,6 @@ class TestLoadFixtures:
|
|||||||
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
|
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||||
)
|
)
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
role = await RoleCrud.first(db_session, [Role.id == role_id])
|
role = await RoleCrud.first(db_session, [Role.id == role_id])
|
||||||
assert role is not None
|
assert role is not None
|
||||||
assert role.name == "original"
|
assert role.name == "original"
|
||||||
@@ -553,8 +545,6 @@ class TestLoadFixtures:
|
|||||||
assert "roles" in result
|
assert "roles" in result
|
||||||
assert len(result["roles"]) == 2
|
assert len(result["roles"]) == 2
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
@@ -594,8 +584,6 @@ class TestLoadFixtures:
|
|||||||
assert "roles" in result
|
assert "roles" in result
|
||||||
assert "other_roles" in result
|
assert "other_roles" in result
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
@@ -660,8 +648,6 @@ class TestLoadFixturesByContext:
|
|||||||
|
|
||||||
await load_fixtures_by_context(db_session, registry, Context.BASE)
|
await load_fixtures_by_context(db_session, registry, Context.BASE)
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
@@ -688,8 +674,6 @@ class TestLoadFixturesByContext:
|
|||||||
db_session, registry, Context.BASE, Context.TESTING
|
db_session, registry, Context.BASE, Context.TESTING
|
||||||
)
|
)
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
@@ -717,8 +701,6 @@ class TestLoadFixturesByContext:
|
|||||||
|
|
||||||
await load_fixtures_by_context(db_session, registry, Context.TESTING)
|
await load_fixtures_by_context(db_session, registry, Context.TESTING)
|
||||||
|
|
||||||
from .conftest import RoleCrud, UserCrud
|
|
||||||
|
|
||||||
assert await RoleCrud.count(db_session) == 1
|
assert await RoleCrud.count(db_session) == 1
|
||||||
assert await UserCrud.count(db_session) == 1
|
assert await UserCrud.count(db_session) == 1
|
||||||
|
|
||||||
|
|||||||
@@ -171,8 +171,15 @@ class TestPytestImportGuard:
|
|||||||
class TestCliImportGuard:
|
class TestCliImportGuard:
|
||||||
"""Tests for CLI module import guard when typer is missing."""
|
"""Tests for CLI module import guard when typer is missing."""
|
||||||
|
|
||||||
def test_import_raises_without_typer(self):
|
@pytest.mark.parametrize(
|
||||||
"""Importing cli.app raises when typer is missing."""
|
"expected_match",
|
||||||
|
[
|
||||||
|
"typer",
|
||||||
|
r"pip install fastapi-toolsets\[cli\]",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_import_raises_without_typer(self, expected_match):
|
||||||
|
"""Importing cli.app raises when typer is missing, with an informative error message."""
|
||||||
saved, blocking_import = _reload_without_package(
|
saved, blocking_import = _reload_without_package(
|
||||||
"fastapi_toolsets.cli.app", ["typer"]
|
"fastapi_toolsets.cli.app", ["typer"]
|
||||||
)
|
)
|
||||||
@@ -186,33 +193,7 @@ class TestCliImportGuard:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with patch("builtins.__import__", side_effect=blocking_import):
|
with patch("builtins.__import__", side_effect=blocking_import):
|
||||||
with pytest.raises(ImportError, match="typer"):
|
with pytest.raises(ImportError, match=expected_match):
|
||||||
importlib.import_module("fastapi_toolsets.cli.app")
|
|
||||||
finally:
|
|
||||||
for key in list(sys.modules):
|
|
||||||
if key.startswith("fastapi_toolsets.cli.app") or key.startswith(
|
|
||||||
"fastapi_toolsets.cli.config"
|
|
||||||
):
|
|
||||||
sys.modules.pop(key, None)
|
|
||||||
sys.modules.update(saved)
|
|
||||||
|
|
||||||
def test_error_message_suggests_cli_extra(self):
|
|
||||||
"""Error message suggests installing the cli extra."""
|
|
||||||
saved, blocking_import = _reload_without_package(
|
|
||||||
"fastapi_toolsets.cli.app", ["typer"]
|
|
||||||
)
|
|
||||||
config_keys = [
|
|
||||||
k for k in sys.modules if k.startswith("fastapi_toolsets.cli.config")
|
|
||||||
]
|
|
||||||
for key in config_keys:
|
|
||||||
if key not in saved:
|
|
||||||
saved[key] = sys.modules.pop(key)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with patch("builtins.__import__", side_effect=blocking_import):
|
|
||||||
with pytest.raises(
|
|
||||||
ImportError, match=r"pip install fastapi-toolsets\[cli\]"
|
|
||||||
):
|
|
||||||
importlib.import_module("fastapi_toolsets.cli.app")
|
importlib.import_module("fastapi_toolsets.cli.app")
|
||||||
finally:
|
finally:
|
||||||
for key in list(sys.modules):
|
for key in list(sys.modules):
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for fastapi_toolsets.metrics module."""
|
"""Tests for fastapi_toolsets.metrics module."""
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
@@ -287,6 +286,16 @@ class TestIncludeRegistry:
|
|||||||
class TestInitMetrics:
|
class TestInitMetrics:
|
||||||
"""Tests for init_metrics function."""
|
"""Tests for init_metrics function."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def metrics_client(self):
|
||||||
|
"""Create a FastAPI app with MetricsRegistry and return a TestClient."""
|
||||||
|
app = FastAPI()
|
||||||
|
registry = MetricsRegistry()
|
||||||
|
init_metrics(app, registry)
|
||||||
|
client = TestClient(app)
|
||||||
|
yield client
|
||||||
|
client.close()
|
||||||
|
|
||||||
def test_returns_app(self):
|
def test_returns_app(self):
|
||||||
"""Returns the FastAPI app."""
|
"""Returns the FastAPI app."""
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -294,26 +303,14 @@ class TestInitMetrics:
|
|||||||
result = init_metrics(app, registry)
|
result = init_metrics(app, registry)
|
||||||
assert result is app
|
assert result is app
|
||||||
|
|
||||||
def test_metrics_endpoint_responds(self):
|
def test_metrics_endpoint_responds(self, metrics_client):
|
||||||
"""The /metrics endpoint returns 200."""
|
"""The /metrics endpoint returns 200."""
|
||||||
app = FastAPI()
|
response = metrics_client.get("/metrics")
|
||||||
registry = MetricsRegistry()
|
|
||||||
init_metrics(app, registry)
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
response = client.get("/metrics")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
def test_metrics_endpoint_content_type(self):
|
def test_metrics_endpoint_content_type(self, metrics_client):
|
||||||
"""The /metrics endpoint returns prometheus content type."""
|
"""The /metrics endpoint returns prometheus content type."""
|
||||||
app = FastAPI()
|
response = metrics_client.get("/metrics")
|
||||||
registry = MetricsRegistry()
|
|
||||||
init_metrics(app, registry)
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
response = client.get("/metrics")
|
|
||||||
|
|
||||||
assert "text/plain" in response.headers["content-type"]
|
assert "text/plain" in response.headers["content-type"]
|
||||||
|
|
||||||
def test_custom_path(self):
|
def test_custom_path(self):
|
||||||
@@ -445,36 +442,33 @@ class TestInitMetrics:
|
|||||||
class TestMultiProcessMode:
|
class TestMultiProcessMode:
|
||||||
"""Tests for multi-process Prometheus mode."""
|
"""Tests for multi-process Prometheus mode."""
|
||||||
|
|
||||||
def test_multiprocess_with_env_var(self):
|
def test_multiprocess_with_env_var(self, monkeypatch):
|
||||||
"""Multi-process mode works when PROMETHEUS_MULTIPROC_DIR is set."""
|
"""Multi-process mode works when PROMETHEUS_MULTIPROC_DIR is set."""
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
os.environ["PROMETHEUS_MULTIPROC_DIR"] = tmpdir
|
monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", tmpdir)
|
||||||
try:
|
# Use a separate registry to avoid conflicts with default
|
||||||
# Use a separate registry to avoid conflicts with default
|
prom_registry = CollectorRegistry()
|
||||||
prom_registry = CollectorRegistry()
|
app = FastAPI()
|
||||||
app = FastAPI()
|
registry = MetricsRegistry()
|
||||||
registry = MetricsRegistry()
|
|
||||||
|
|
||||||
@registry.register
|
@registry.register
|
||||||
def mp_counter():
|
def mp_counter():
|
||||||
return Counter(
|
return Counter(
|
||||||
"mp_test_counter",
|
"mp_test_counter",
|
||||||
"A multiprocess counter",
|
"A multiprocess counter",
|
||||||
registry=prom_registry,
|
registry=prom_registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
init_metrics(app, registry)
|
init_metrics(app, registry)
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
response = client.get("/metrics")
|
response = client.get("/metrics")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
finally:
|
|
||||||
del os.environ["PROMETHEUS_MULTIPROC_DIR"]
|
|
||||||
|
|
||||||
def test_single_process_without_env_var(self):
|
def test_single_process_without_env_var(self, monkeypatch):
|
||||||
"""Single-process mode when PROMETHEUS_MULTIPROC_DIR is not set."""
|
"""Single-process mode when PROMETHEUS_MULTIPROC_DIR is not set."""
|
||||||
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
|
monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR", raising=False)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
registry = MetricsRegistry()
|
registry = MetricsRegistry()
|
||||||
|
|||||||
@@ -6,32 +6,32 @@ from contextlib import suppress
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import fastapi_toolsets.models.watched as _watched_module
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import String
|
from sqlalchemy import String
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
from fastapi_toolsets.pytest import create_db_session
|
||||||
|
|
||||||
|
import fastapi_toolsets.models.watched as _watched_module
|
||||||
from fastapi_toolsets.models import (
|
from fastapi_toolsets.models import (
|
||||||
CreatedAtMixin,
|
CreatedAtMixin,
|
||||||
ModelEvent,
|
ModelEvent,
|
||||||
TimestampMixin,
|
TimestampMixin,
|
||||||
|
UpdatedAtMixin,
|
||||||
UUIDMixin,
|
UUIDMixin,
|
||||||
UUIDv7Mixin,
|
UUIDv7Mixin,
|
||||||
UpdatedAtMixin,
|
|
||||||
WatchedFieldsMixin,
|
WatchedFieldsMixin,
|
||||||
watch,
|
watch,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.models.watched import (
|
from fastapi_toolsets.models.watched import (
|
||||||
_SESSION_CREATES,
|
_SESSION_CREATES,
|
||||||
_SESSION_DELETES,
|
_SESSION_DELETES,
|
||||||
_SESSION_UPDATES,
|
|
||||||
_SESSION_PENDING_NEW,
|
_SESSION_PENDING_NEW,
|
||||||
|
_SESSION_UPDATES,
|
||||||
_after_commit,
|
_after_commit,
|
||||||
_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,
|
||||||
)
|
)
|
||||||
@@ -82,8 +82,6 @@ class FullMixinModel(MixinBase, UUIDMixin, UpdatedAtMixin):
|
|||||||
name: Mapped[str] = mapped_column(String(50))
|
name: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
|
||||||
# --- WatchedFieldsMixin test models ---
|
|
||||||
|
|
||||||
_test_events: list[dict] = []
|
_test_events: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +126,17 @@ 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"
|
||||||
|
|
||||||
@@ -135,7 +144,94 @@ class NonWatchedModel(MixinBase):
|
|||||||
value: Mapped[str] = mapped_column(String(50))
|
value: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
|
||||||
|
_poly_events: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PolyAnimal(MixinBase, UUIDMixin, WatchedFieldsMixin):
|
||||||
|
"""Base class for STI polymorphism tests."""
|
||||||
|
|
||||||
|
__tablename__ = "mixin_poly_animals"
|
||||||
|
__mapper_args__ = {"polymorphic_on": "kind", "polymorphic_identity": "animal"}
|
||||||
|
|
||||||
|
kind: Mapped[str] = mapped_column(String(50))
|
||||||
|
name: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
async def on_create(self) -> None:
|
||||||
|
_poly_events.append(
|
||||||
|
{"event": "create", "type": type(self).__name__, "obj_id": self.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_delete(self) -> None:
|
||||||
|
_poly_events.append(
|
||||||
|
{"event": "delete", "type": type(self).__name__, "obj_id": self.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PolyDog(PolyAnimal):
|
||||||
|
"""STI subclass — shares the same table as PolyAnimal."""
|
||||||
|
|
||||||
|
__mapper_args__ = {"polymorphic_identity": "dog"}
|
||||||
|
|
||||||
|
|
||||||
|
_watch_inherit_events: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
@watch("status")
|
||||||
|
class WatchParent(MixinBase, UUIDMixin, WatchedFieldsMixin):
|
||||||
|
"""Base class with @watch("status") — subclasses should inherit this filter."""
|
||||||
|
|
||||||
|
__tablename__ = "mixin_watch_parent"
|
||||||
|
__mapper_args__ = {"polymorphic_on": "kind", "polymorphic_identity": "parent"}
|
||||||
|
|
||||||
|
kind: Mapped[str] = mapped_column(String(50))
|
||||||
|
status: Mapped[str] = mapped_column(String(50))
|
||||||
|
other: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
async def on_update(self, changes: dict) -> None:
|
||||||
|
_watch_inherit_events.append({"type": type(self).__name__, "changes": changes})
|
||||||
|
|
||||||
|
|
||||||
|
class WatchChild(WatchParent):
|
||||||
|
"""STI subclass that does NOT redeclare @watch — should inherit parent's filter."""
|
||||||
|
|
||||||
|
__mapper_args__ = {"polymorphic_identity": "child"}
|
||||||
|
|
||||||
|
|
||||||
|
@watch("other")
|
||||||
|
class WatchOverride(WatchParent):
|
||||||
|
"""STI subclass that overrides @watch with a different field."""
|
||||||
|
|
||||||
|
__mapper_args__ = {"polymorphic_identity": "override"}
|
||||||
|
|
||||||
|
|
||||||
|
_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] = []
|
||||||
|
_future_events: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
@watch("status")
|
@watch("status")
|
||||||
@@ -156,22 +252,33 @@ class SyncCallbackModel(MixinBase, UUIDMixin, WatchedFieldsMixin):
|
|||||||
_sync_events.append({"event": "update", "changes": changes})
|
_sync_events.append({"event": "update", "changes": changes})
|
||||||
|
|
||||||
|
|
||||||
|
class FutureCallbackModel(MixinBase, UUIDMixin, WatchedFieldsMixin):
|
||||||
|
"""Model whose on_create returns an asyncio.Task (awaitable, not a coroutine)."""
|
||||||
|
|
||||||
|
__tablename__ = "mixin_future_callback_models"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
def on_create(self) -> "asyncio.Task[None]":
|
||||||
|
async def _work() -> None:
|
||||||
|
_future_events.append("created")
|
||||||
|
|
||||||
|
return asyncio.ensure_future(_work())
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
async def mixin_session():
|
async def mixin_session():
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
async with create_db_session(DATABASE_URL, MixinBase) as session:
|
||||||
async with engine.begin() as conn:
|
yield session
|
||||||
await conn.run_sync(MixinBase.metadata.create_all)
|
|
||||||
|
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
@pytest.fixture(scope="function")
|
||||||
session = session_factory()
|
async def mixin_session_expire():
|
||||||
|
"""Session with expire_on_commit=True (the default) to exercise attribute access after commit."""
|
||||||
try:
|
async with create_db_session(
|
||||||
|
DATABASE_URL, MixinBase, expire_on_commit=True
|
||||||
|
) as session:
|
||||||
yield session
|
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:
|
||||||
@@ -418,6 +525,67 @@ class TestWatchDecorator:
|
|||||||
watch()
|
watch()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWatchInheritance:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_events(self):
|
||||||
|
_watch_inherit_events.clear()
|
||||||
|
yield
|
||||||
|
_watch_inherit_events.clear()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_child_inherits_parent_watch_filter(self, mixin_session):
|
||||||
|
"""Subclass without @watch inherits the parent's field filter."""
|
||||||
|
obj = WatchChild(status="initial", other="x")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
obj.other = "changed" # not watched by parent's @watch("status")
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert _watch_inherit_events == []
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_child_triggers_on_watched_field(self, mixin_session):
|
||||||
|
"""Subclass without @watch triggers on_update for the parent's watched field."""
|
||||||
|
obj = WatchChild(status="initial", other="x")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
obj.status = "updated"
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(_watch_inherit_events) == 1
|
||||||
|
assert _watch_inherit_events[0]["type"] == "WatchChild"
|
||||||
|
assert "status" in _watch_inherit_events[0]["changes"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_subclass_override_takes_precedence(self, mixin_session):
|
||||||
|
"""Subclass @watch overrides the parent's field filter."""
|
||||||
|
obj = WatchOverride(status="initial", other="x")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
obj.status = (
|
||||||
|
"changed" # watched by parent but overridden by child's @watch("other")
|
||||||
|
)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert _watch_inherit_events == []
|
||||||
|
|
||||||
|
obj.other = "changed"
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(_watch_inherit_events) == 1
|
||||||
|
assert "other" in _watch_inherit_events[0]["changes"]
|
||||||
|
|
||||||
|
|
||||||
class TestUpsertChanges:
|
class TestUpsertChanges:
|
||||||
def test_inserts_new_entry(self):
|
def test_inserts_new_entry(self):
|
||||||
"""New key is inserted with the full changes dict."""
|
"""New key is inserted with the full changes dict."""
|
||||||
@@ -742,6 +910,16 @@ 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."""
|
||||||
@@ -806,6 +984,119 @@ class TestWatchedFieldsMixin:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestTransientObject:
|
||||||
|
"""Create + delete within the same transaction should fire no events."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_events(self):
|
||||||
|
_test_events.clear()
|
||||||
|
yield
|
||||||
|
_test_events.clear()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_no_events_when_created_and_deleted_in_same_transaction(
|
||||||
|
self, mixin_session
|
||||||
|
):
|
||||||
|
"""Neither on_create nor on_delete fires when the object never survives a commit."""
|
||||||
|
obj = WatchedModel(status="active", other="x")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.delete(obj)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert _test_events == []
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_other_objects_unaffected(self, mixin_session):
|
||||||
|
"""on_create still fires for objects that are not deleted in the same transaction."""
|
||||||
|
survivor = WatchedModel(status="active", other="x")
|
||||||
|
transient = WatchedModel(status="gone", other="y")
|
||||||
|
mixin_session.add(survivor)
|
||||||
|
mixin_session.add(transient)
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.delete(transient)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
creates = [e for e in _test_events if e["event"] == "create"]
|
||||||
|
deletes = [e for e in _test_events if e["event"] == "delete"]
|
||||||
|
assert len(creates) == 1
|
||||||
|
assert creates[0]["obj_id"] == survivor.id
|
||||||
|
assert deletes == []
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_distinct_create_and_delete_both_fire(self, mixin_session):
|
||||||
|
"""on_create and on_delete both fire when different objects are created and deleted."""
|
||||||
|
existing = WatchedModel(status="old", other="x")
|
||||||
|
mixin_session.add(existing)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
_test_events.clear()
|
||||||
|
|
||||||
|
new_obj = WatchedModel(status="new", other="y")
|
||||||
|
mixin_session.add(new_obj)
|
||||||
|
await mixin_session.delete(existing)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
creates = [e for e in _test_events if e["event"] == "create"]
|
||||||
|
deletes = [e for e in _test_events if e["event"] == "delete"]
|
||||||
|
assert len(creates) == 1
|
||||||
|
assert len(deletes) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestPolymorphism:
|
||||||
|
"""WatchedFieldsMixin with STI (Single Table Inheritance)."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_events(self):
|
||||||
|
_poly_events.clear()
|
||||||
|
yield
|
||||||
|
_poly_events.clear()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_on_create_fires_once_for_subclass(self, mixin_session):
|
||||||
|
"""on_create fires exactly once for a STI subclass instance."""
|
||||||
|
dog = PolyDog(name="Rex")
|
||||||
|
mixin_session.add(dog)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(_poly_events) == 1
|
||||||
|
assert _poly_events[0]["event"] == "create"
|
||||||
|
assert _poly_events[0]["type"] == "PolyDog"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_on_delete_fires_for_subclass(self, mixin_session):
|
||||||
|
"""on_delete fires for a STI subclass instance."""
|
||||||
|
dog = PolyDog(name="Rex")
|
||||||
|
mixin_session.add(dog)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
_poly_events.clear()
|
||||||
|
|
||||||
|
await mixin_session.delete(dog)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(_poly_events) == 1
|
||||||
|
assert _poly_events[0]["event"] == "delete"
|
||||||
|
assert _poly_events[0]["type"] == "PolyDog"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_transient_subclass_fires_no_events(self, mixin_session):
|
||||||
|
"""Create + delete of a STI subclass in one transaction fires no events."""
|
||||||
|
dog = PolyDog(name="Rex")
|
||||||
|
mixin_session.add(dog)
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.delete(dog)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert _poly_events == []
|
||||||
|
|
||||||
|
|
||||||
class TestWatchAll:
|
class TestWatchAll:
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_events(self):
|
def clear_events(self):
|
||||||
@@ -903,65 +1194,88 @@ class TestSyncCallbacks:
|
|||||||
assert updates[0]["changes"]["status"] == {"old": "initial", "new": "updated"}
|
assert updates[0]["changes"]["status"] == {"old": "initial", "new": "updated"}
|
||||||
|
|
||||||
|
|
||||||
class TestCallCallback:
|
class TestFutureCallbacks:
|
||||||
|
"""Callbacks returning a non-coroutine awaitable (asyncio.Task / Future)."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_events(self):
|
||||||
|
_future_events.clear()
|
||||||
|
yield
|
||||||
|
_future_events.clear()
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_async_callback_scheduled_as_task(self):
|
async def test_task_callback_is_awaited(self, mixin_session):
|
||||||
"""_call_callback schedules async functions as tasks."""
|
"""on_create returning an asyncio.Task is awaited and its work completes."""
|
||||||
called = []
|
obj = FutureCallbackModel(name="test")
|
||||||
|
mixin_session.add(obj)
|
||||||
async def async_fn() -> None:
|
await mixin_session.commit()
|
||||||
called.append("async")
|
# Two turns: one for _run() to execute, one for the inner _work() task.
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
_call_callback(loop, async_fn)
|
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
assert called == ["async"]
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_sync_callback_called_directly(self):
|
|
||||||
"""_call_callback invokes sync functions immediately."""
|
|
||||||
called = []
|
|
||||||
|
|
||||||
def sync_fn() -> None:
|
|
||||||
called.append("sync")
|
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
_call_callback(loop, sync_fn)
|
|
||||||
assert called == ["sync"]
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_sync_callback_exception_logged(self):
|
|
||||||
"""_call_callback logs exceptions from sync callbacks."""
|
|
||||||
|
|
||||||
def failing_fn() -> None:
|
|
||||||
raise RuntimeError("sync error")
|
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
with patch.object(_watched_module._logger, "error") as mock_error:
|
|
||||||
_call_callback(loop, failing_fn)
|
|
||||||
mock_error.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_async_callback_with_args(self):
|
|
||||||
"""_call_callback passes arguments to async callbacks."""
|
|
||||||
received = []
|
|
||||||
|
|
||||||
async def async_fn(changes: dict) -> None:
|
|
||||||
received.append(changes)
|
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
_call_callback(loop, async_fn, {"status": {"old": "a", "new": "b"}})
|
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
assert received == [{"status": {"old": "a", "new": "b"}}]
|
|
||||||
|
assert _future_events == ["created"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAttributeAccessInCallbacks:
|
||||||
|
"""Verify that self attributes are accessible inside every callback type.
|
||||||
|
|
||||||
|
Uses expire_on_commit=True (the SQLAlchemy default) so the tests would fail
|
||||||
|
without the snapshot-restore logic in _schedule_with_snapshot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_events(self):
|
||||||
|
_attr_access_events.clear()
|
||||||
|
yield
|
||||||
|
_attr_access_events.clear()
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_sync_callback_with_args(self):
|
async def test_on_create_pk_and_field_accessible(self, mixin_session_expire):
|
||||||
"""_call_callback passes arguments to sync callbacks."""
|
"""id (server default) and regular fields are readable inside on_create."""
|
||||||
received = []
|
obj = AttrAccessModel(name="hello")
|
||||||
|
mixin_session_expire.add(obj)
|
||||||
|
await mixin_session_expire.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
def sync_fn(changes: dict) -> None:
|
events = [e for e in _attr_access_events if e["event"] == "create"]
|
||||||
received.append(changes)
|
assert len(events) == 1
|
||||||
|
assert isinstance(events[0]["id"], uuid.UUID)
|
||||||
|
assert events[0]["name"] == "hello"
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
@pytest.mark.anyio
|
||||||
_call_callback(loop, sync_fn, {"x": 1})
|
async def test_on_delete_pk_and_field_accessible(self, mixin_session_expire):
|
||||||
assert received == [{"x": 1}]
|
"""id and regular fields are readable inside on_delete."""
|
||||||
|
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)
|
||||||
|
await mixin_session_expire.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
events = [e for e in _attr_access_events if e["event"] == "delete"]
|
||||||
|
assert len(events) == 1
|
||||||
|
assert isinstance(events[0]["id"], uuid.UUID)
|
||||||
|
assert events[0]["name"] == "to-delete"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_on_update_pk_and_updated_field_accessible(
|
||||||
|
self, mixin_session_expire
|
||||||
|
):
|
||||||
|
"""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"
|
||||||
|
await mixin_session_expire.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
events = [e for e in _attr_access_events if e["event"] == "update"]
|
||||||
|
assert len(events) == 1
|
||||||
|
assert isinstance(events[0]["id"], uuid.UUID)
|
||||||
|
assert events[0]["name"] == "updated"
|
||||||
|
|||||||
@@ -201,6 +201,88 @@ class TestOffsetPagination:
|
|||||||
assert data["page"] == 2
|
assert data["page"] == 2
|
||||||
assert data["has_more"] is True
|
assert data["has_more"] is True
|
||||||
|
|
||||||
|
def test_total_count_can_be_none(self):
|
||||||
|
"""total_count accepts None (include_total=False mode)."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=None,
|
||||||
|
items_per_page=20,
|
||||||
|
page=1,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
assert pagination.total_count is None
|
||||||
|
|
||||||
|
def test_serialization_with_none_total_count(self):
|
||||||
|
"""OffsetPagination serializes total_count=None correctly."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=None,
|
||||||
|
items_per_page=20,
|
||||||
|
page=1,
|
||||||
|
has_more=False,
|
||||||
|
)
|
||||||
|
data = pagination.model_dump()
|
||||||
|
assert data["total_count"] is None
|
||||||
|
|
||||||
|
def test_pages_computed(self):
|
||||||
|
"""pages is ceil(total_count / items_per_page)."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=42,
|
||||||
|
items_per_page=10,
|
||||||
|
page=1,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
assert pagination.pages == 5
|
||||||
|
|
||||||
|
def test_pages_exact_division(self):
|
||||||
|
"""pages is exact when total_count is evenly divisible."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=40,
|
||||||
|
items_per_page=10,
|
||||||
|
page=1,
|
||||||
|
has_more=False,
|
||||||
|
)
|
||||||
|
assert pagination.pages == 4
|
||||||
|
|
||||||
|
def test_pages_zero_total(self):
|
||||||
|
"""pages is 0 when total_count is 0."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=0,
|
||||||
|
items_per_page=10,
|
||||||
|
page=1,
|
||||||
|
has_more=False,
|
||||||
|
)
|
||||||
|
assert pagination.pages == 0
|
||||||
|
|
||||||
|
def test_pages_zero_items_per_page(self):
|
||||||
|
"""pages is 0 when items_per_page is 0."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=100,
|
||||||
|
items_per_page=0,
|
||||||
|
page=1,
|
||||||
|
has_more=False,
|
||||||
|
)
|
||||||
|
assert pagination.pages == 0
|
||||||
|
|
||||||
|
def test_pages_none_when_total_count_none(self):
|
||||||
|
"""pages is None when total_count is None (include_total=False)."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=None,
|
||||||
|
items_per_page=20,
|
||||||
|
page=1,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
assert pagination.pages is None
|
||||||
|
|
||||||
|
def test_pages_in_serialization(self):
|
||||||
|
"""pages appears in model_dump output."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=25,
|
||||||
|
items_per_page=10,
|
||||||
|
page=1,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
data = pagination.model_dump()
|
||||||
|
assert data["pages"] == 3
|
||||||
|
|
||||||
|
|
||||||
class TestCursorPagination:
|
class TestCursorPagination:
|
||||||
"""Tests for CursorPagination schema."""
|
"""Tests for CursorPagination schema."""
|
||||||
|
|||||||
73
uv.lock
generated
73
uv.lock
generated
@@ -251,7 +251,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "2.3.0"
|
version = "2.4.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
@@ -285,7 +285,6 @@ 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" },
|
||||||
@@ -296,7 +295,6 @@ dev = [
|
|||||||
{ name = "zensical" },
|
{ name = "zensical" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
{ name = "mike" },
|
|
||||||
{ name = "mkdocstrings-python" },
|
{ name = "mkdocstrings-python" },
|
||||||
{ name = "zensical" },
|
{ name = "zensical" },
|
||||||
]
|
]
|
||||||
@@ -329,7 +327,6 @@ 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" },
|
||||||
@@ -337,12 +334,11 @@ 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.28" },
|
{ name = "zensical", specifier = ">=0.0.23" },
|
||||||
]
|
]
|
||||||
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.28" },
|
{ name = "zensical", specifier = ">=0.0.23" },
|
||||||
]
|
]
|
||||||
tests = [
|
tests = [
|
||||||
{ name = "coverage", specifier = ">=7.0.0" },
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
@@ -605,23 +601,6 @@ 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"
|
||||||
@@ -884,15 +863,6 @@ 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"
|
||||||
@@ -1265,15 +1235,6 @@ 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"
|
||||||
@@ -1303,7 +1264,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.28"
|
version = "0.0.27"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@@ -1313,18 +1274,18 @@ dependencies = [
|
|||||||
{ name = "pymdown-extensions" },
|
{ name = "pymdown-extensions" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user