mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 14:46:24 +02:00
Compare commits
21 Commits
04afef7e33
...
v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbe63edc46 | ||
|
|
0b17c77dee | ||
|
|
bce71bfd42 | ||
|
|
2f1eb4d468 | ||
|
|
1f06eab11d | ||
|
|
fac9aa6f60 | ||
|
|
f310466697 | ||
|
|
32059dcb02 | ||
|
|
f027981e80 | ||
|
|
5c1487c24a | ||
|
|
ebaa61525f | ||
|
|
4829cfba73 | ||
|
|
9ca2da4213 | ||
|
|
0b3f097012 | ||
|
|
1890d696bf | ||
|
|
104285c6e5 | ||
|
|
f5afbbe37f | ||
|
|
f4698bea8a | ||
|
|
5215b921ae | ||
|
9dad59e25d
|
|||
|
|
29326ab532 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -93,7 +93,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
if: matrix.python-version == '3.14'
|
if: matrix.python-version == '3.14'
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
report_type: coverage
|
report_type: coverage
|
||||||
@@ -102,7 +102,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
if: matrix.python-version == '3.14'
|
if: matrix.python-version == '3.14'
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
report_type: test_results
|
report_type: test_results
|
||||||
|
|||||||
52
.github/workflows/docs.yml
vendored
52
.github/workflows/docs.yml
vendored
@@ -5,20 +5,15 @@ on:
|
|||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/configure-pages@v5
|
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -28,11 +23,40 @@ jobs:
|
|||||||
|
|
||||||
- run: uv sync --group dev
|
- run: uv sync --group dev
|
||||||
|
|
||||||
- run: uv run zensical build --clean
|
- name: Configure git
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
- uses: actions/upload-pages-artifact@v4
|
- name: Deploy documentation
|
||||||
with:
|
run: |
|
||||||
path: site
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||||
|
DEPLOY_VERSION="v$(echo "$VERSION" | cut -d. -f1-2)"
|
||||||
|
|
||||||
- uses: actions/deploy-pages@v5
|
# On new major: consolidate previous major's feature versions into vX
|
||||||
id: deployment
|
PREV_MAJOR=$((MAJOR - 1))
|
||||||
|
OLD_FEATURE_VERSIONS=$(uv run mike list 2>/dev/null | grep -oE "^v${PREV_MAJOR}\.[0-9]+" || true)
|
||||||
|
|
||||||
|
if [ -n "$OLD_FEATURE_VERSIONS" ]; then
|
||||||
|
LATEST_PREV_TAG=$(git tag -l "v${PREV_MAJOR}.*" | sort -V | tail -1)
|
||||||
|
|
||||||
|
if [ -n "$LATEST_PREV_TAG" ]; then
|
||||||
|
git checkout "$LATEST_PREV_TAG" -- docs/ docs_src/ src/ zensical.toml
|
||||||
|
if ! grep -q '\[project\.extra\.version\]' zensical.toml; then
|
||||||
|
printf '\n[project.extra.version]\nprovider = "mike"\ndefault = "stable"\nalias = true\n' >> zensical.toml
|
||||||
|
fi
|
||||||
|
uv run mike deploy "v${PREV_MAJOR}"
|
||||||
|
git checkout HEAD -- docs/ docs_src/ src/ zensical.toml
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete old feature versions
|
||||||
|
echo "$OLD_FEATURE_VERSIONS" | while read -r OLD_V; do
|
||||||
|
echo "Deleting $OLD_V"
|
||||||
|
uv run mike delete "$OLD_V"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
uv run mike deploy --update-aliases "$DEPLOY_VERSION" stable
|
||||||
|
uv run mike set-default stable
|
||||||
|
git push origin gh-pages
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ uv add "fastapi-toolsets[all]"
|
|||||||
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
||||||
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
||||||
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
||||||
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`) and lifecycle callbacks (`WatchedFieldsMixin`, `@watch`) that fire after commit for insert, update, and delete events
|
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
|
||||||
|
- **Lifecycle Events**: Post-commit event system (`EventSession`, `listens_for`) that dispatches async/sync callbacks for insert, update, and delete operations
|
||||||
- **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
|
- **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
|
||||||
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
||||||
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
||||||
|
|||||||
@@ -43,16 +43,16 @@ Declare `searchable_fields`, `facet_fields`, and `order_fields` once on [`CrudFa
|
|||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
```python title="routes.py:1:17"
|
```python title="routes.py:1:16"
|
||||||
--8<-- "docs_src/examples/pagination_search/routes.py:1:17"
|
--8<-- "docs_src/examples/pagination_search/routes.py:1:16"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Offset pagination
|
### Offset pagination
|
||||||
|
|
||||||
Best for admin panels or any UI that needs a total item count and numbered pages.
|
Best for admin panels or any UI that needs a total item count and numbered pages.
|
||||||
|
|
||||||
```python title="routes.py:20:40"
|
```python title="routes.py:19:37"
|
||||||
--8<-- "docs_src/examples/pagination_search/routes.py:20:40"
|
--8<-- "docs_src/examples/pagination_search/routes.py:19:37"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example request**
|
**Example request**
|
||||||
@@ -92,8 +92,8 @@ To skip the `COUNT(*)` query for better performance on large tables, pass `inclu
|
|||||||
|
|
||||||
Best for feeds, infinite scroll, or any high-throughput API where offset performance degrades.
|
Best for feeds, infinite scroll, or any high-throughput API where offset performance degrades.
|
||||||
|
|
||||||
```python title="routes.py:43:63"
|
```python title="routes.py:40:58"
|
||||||
--8<-- "docs_src/examples/pagination_search/routes.py:43:63"
|
--8<-- "docs_src/examples/pagination_search/routes.py:40:58"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example request**
|
**Example request**
|
||||||
@@ -132,8 +132,8 @@ Pass `next_cursor` as the `cursor` query parameter on the next request to advanc
|
|||||||
|
|
||||||
[`paginate()`](../module/crud.md#unified-paginate--both-strategies-on-one-endpoint) lets a single endpoint support both strategies via a `pagination_type` query parameter. The `pagination_type` field in the response acts as a discriminator for frontend tooling.
|
[`paginate()`](../module/crud.md#unified-paginate--both-strategies-on-one-endpoint) lets a single endpoint support both strategies via a `pagination_type` query parameter. The `pagination_type` field in the response acts as a discriminator for frontend tooling.
|
||||||
|
|
||||||
```python title="routes.py:66:90"
|
```python title="routes.py:61:79"
|
||||||
--8<-- "docs_src/examples/pagination_search/routes.py:66:90"
|
--8<-- "docs_src/examples/pagination_search/routes.py:61:79"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Offset request** (default)
|
**Offset request** (default)
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ uv add "fastapi-toolsets[all]"
|
|||||||
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
||||||
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
||||||
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
||||||
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`) and lifecycle callbacks (`WatchedFieldsMixin`) that fire after commit for insert, update, and delete events.
|
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`).
|
||||||
|
- **Lifecycle Events**: Post-commit event system (`EventSession`, `listens_for`) that dispatches async/sync callbacks for insert, update, and delete operations.
|
||||||
- **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
|
- **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
|
||||||
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
||||||
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
||||||
|
|||||||
180
docs/migration/v3.md
Normal file
180
docs/migration/v3.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Migrating to v3.0
|
||||||
|
|
||||||
|
This page covers every breaking change introduced in **v3.0** and the steps required to update your code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRUD
|
||||||
|
|
||||||
|
### Facet keys now always use the full relationship chain
|
||||||
|
|
||||||
|
In `v2`, relationship facet fields used only the terminal column key (e.g. `"name"` for `Role.name`) and only prepended the relationship name when two facet fields shared the same column key. In `v3`, facet keys **always** include the full relationship chain joined by `__`, regardless of collisions.
|
||||||
|
|
||||||
|
=== "Before (`v2`)"
|
||||||
|
|
||||||
|
```
|
||||||
|
User.status -> status
|
||||||
|
(User.role, Role.name) -> name
|
||||||
|
(User.role, Role.permission, Permission.name) -> name
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v3`)"
|
||||||
|
|
||||||
|
```
|
||||||
|
User.status -> status
|
||||||
|
(User.role, Role.name) -> role__name
|
||||||
|
(User.role, Role.permission, Permission.name) -> role__permission__name
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `*_params` dependencies consolidated into per-paginate methods
|
||||||
|
|
||||||
|
The six individual dependency methods (`offset_params`, `cursor_params`, `paginate_params`, `filter_params`, `search_params`, `order_params`) have been **removed** and replaced by three consolidated methods that bundle pagination, search, filter, and order into a single `Depends()` call.
|
||||||
|
|
||||||
|
| Removed | Replacement |
|
||||||
|
|---|---|
|
||||||
|
| `offset_params()` + `filter_params()` + `search_params()` + `order_params()` | `offset_paginate_params()` |
|
||||||
|
| `cursor_params()` + `filter_params()` + `search_params()` + `order_params()` | `cursor_paginate_params()` |
|
||||||
|
| `paginate_params()` + `filter_params()` + `search_params()` + `order_params()` | `paginate_params()` |
|
||||||
|
|
||||||
|
Each new method accepts `search`, `filter`, and `order` boolean toggles (all `True` by default) to disable features you don't need.
|
||||||
|
|
||||||
|
=== "Before (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.crud import OrderByClause
|
||||||
|
|
||||||
|
@router.get("/offset")
|
||||||
|
async def list_articles_offset(
|
||||||
|
session: SessionDep,
|
||||||
|
params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))],
|
||||||
|
filter_by: Annotated[dict, Depends(ArticleCrud.filter_params())],
|
||||||
|
order_by: Annotated[OrderByClause | None, Depends(ArticleCrud.order_params(default_field=Article.created_at))],
|
||||||
|
search: str | None = None,
|
||||||
|
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||||
|
return await ArticleCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
**params,
|
||||||
|
search=search,
|
||||||
|
filter_by=filter_by or None,
|
||||||
|
order_by=order_by,
|
||||||
|
schema=ArticleRead,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v3`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/offset")
|
||||||
|
async def list_articles_offset(
|
||||||
|
session: SessionDep,
|
||||||
|
params: Annotated[
|
||||||
|
dict,
|
||||||
|
Depends(
|
||||||
|
ArticleCrud.offset_paginate_params(
|
||||||
|
default_page_size=20,
|
||||||
|
default_order_field=Article.created_at,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||||
|
return await ArticleCrud.offset_paginate(session=session, **params, schema=ArticleRead)
|
||||||
|
```
|
||||||
|
|
||||||
|
The same pattern applies to `cursor_paginate_params()` and `paginate_params()`. To disable a feature, pass the toggle:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# No search or ordering, only pagination + filtering
|
||||||
|
ArticleCrud.offset_paginate_params(search=False, order=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
The lifecycle event system has been rewritten. Callbacks are now registered with a module-level [`listens_for`](../reference/models.md#fastapi_toolsets.models.listens_for) decorator and dispatched by [`EventSession`](../reference/models.md#fastapi_toolsets.models.EventSession), replacing the mixin-based approach from `v2`.
|
||||||
|
|
||||||
|
### `WatchedFieldsMixin` and `@watch` removed
|
||||||
|
|
||||||
|
Importing `WatchedFieldsMixin` or `watch` will raise `ImportError`.
|
||||||
|
|
||||||
|
Model method callbacks (`on_create`, `on_delete`, `on_update`) and the `@watch` decorator are replaced by:
|
||||||
|
|
||||||
|
1. **`__watched_fields__`** — a plain class attribute to restrict which field changes trigger `UPDATE` events (replaces `@watch`).
|
||||||
|
2. **`@listens_for`** — a module-level decorator to register callbacks for one or more [`ModelEvent`](../reference/models.md#fastapi_toolsets.models.ModelEvent) types (replaces `on_create` / `on_delete` / `on_update` methods).
|
||||||
|
|
||||||
|
=== "Before (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import WatchedFieldsMixin, watch
|
||||||
|
|
||||||
|
@watch("status")
|
||||||
|
class Order(Base, UUIDMixin, WatchedFieldsMixin):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
|
||||||
|
status: Mapped[str]
|
||||||
|
|
||||||
|
async def on_create(self):
|
||||||
|
await notify_new_order(self.id)
|
||||||
|
|
||||||
|
async def on_update(self, changes):
|
||||||
|
if "status" in changes:
|
||||||
|
await notify_status_change(self.id, changes["status"])
|
||||||
|
|
||||||
|
async def on_delete(self):
|
||||||
|
await notify_order_cancelled(self.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v3`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
|
||||||
|
|
||||||
|
class Order(Base, UUIDMixin):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
__watched_fields__ = ("status",)
|
||||||
|
|
||||||
|
status: Mapped[str]
|
||||||
|
|
||||||
|
@listens_for(Order, [ModelEvent.CREATE])
|
||||||
|
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
|
||||||
|
await notify_new_order(order.id)
|
||||||
|
|
||||||
|
@listens_for(Order, [ModelEvent.UPDATE])
|
||||||
|
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
|
||||||
|
if "status" in changes:
|
||||||
|
await notify_status_change(order.id, changes["status"])
|
||||||
|
|
||||||
|
@listens_for(Order, [ModelEvent.DELETE])
|
||||||
|
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
|
||||||
|
await notify_order_cancelled(order.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `EventSession` now required
|
||||||
|
|
||||||
|
Without `EventSession`, lifecycle callbacks will silently stop firing.
|
||||||
|
|
||||||
|
Callbacks are now dispatched inside `EventSession.commit()` rather than via background tasks. Pass it as the session class when creating your session factory:
|
||||||
|
|
||||||
|
=== "Before (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
engine = create_async_engine("postgresql+asyncpg://...")
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v3`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from fastapi_toolsets.models import EventSession
|
||||||
|
|
||||||
|
engine = create_async_engine("postgresql+asyncpg://...")
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=EventSession)
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
If you use `create_db_session` from `fastapi_toolsets.pytest`, the session already uses `EventSession` — no changes needed in tests.
|
||||||
@@ -159,18 +159,15 @@ Three pagination methods are available. All return a typed response whose `pagi
|
|||||||
### Offset pagination
|
### Offset pagination
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_users(
|
async def get_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
items_per_page: int = 50,
|
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
||||||
page: int = 1,
|
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.offset_paginate(
|
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||||
session=session,
|
|
||||||
items_per_page=items_per_page,
|
|
||||||
page=page,
|
|
||||||
schema=UserRead,
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) method returns an [`OffsetPaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPaginatedResponse):
|
The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) method returns an [`OffsetPaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPaginatedResponse):
|
||||||
@@ -194,32 +191,13 @@ The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.Async
|
|||||||
|
|
||||||
!!! info "Added in `v2.4.1`"
|
!!! info "Added in `v2.4.1`"
|
||||||
|
|
||||||
By default `offset_paginate` runs two queries: one for the page items and one `COUNT(*)` for `total_count`. On large tables the `COUNT` can be expensive. Pass `include_total=False` to skip it:
|
By default `offset_paginate` runs two queries: one for the page items and one `COUNT(*)` for `total_count`. On large tables the `COUNT` can be expensive. Pass `include_total=False` to `offset_paginate_params()` to skip it:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
result = await UserCrud.offset_paginate(
|
|
||||||
session=session,
|
|
||||||
page=page,
|
|
||||||
items_per_page=items_per_page,
|
|
||||||
include_total=False,
|
|
||||||
schema=UserRead,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Pagination params dependency
|
|
||||||
|
|
||||||
!!! info "Added in `v2.4.1`"
|
|
||||||
|
|
||||||
Use [`offset_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_params) to generate a FastAPI dependency that injects `page` and `items_per_page` from query parameters with configurable defaults and a `max_page_size` cap:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import Annotated
|
|
||||||
from fastapi import Depends
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(
|
async def get_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[dict, Depends(UserCrud.offset_params(default_page_size=20, max_page_size=100))],
|
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(include_total=False))],
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||||
```
|
```
|
||||||
@@ -230,15 +208,9 @@ async def list_users(
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
cursor: str | None = None,
|
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
|
||||||
items_per_page: int = 20,
|
|
||||||
) -> CursorPaginatedResponse[UserRead]:
|
) -> CursorPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.cursor_paginate(
|
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
||||||
session=session,
|
|
||||||
cursor=cursor,
|
|
||||||
items_per_page=items_per_page,
|
|
||||||
schema=UserRead,
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) method returns a [`CursorPaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPaginatedResponse):
|
The [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) method returns a [`CursorPaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPaginatedResponse):
|
||||||
@@ -291,24 +263,6 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.id)
|
|||||||
PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Pagination params dependency
|
|
||||||
|
|
||||||
!!! info "Added in `v2.4.1`"
|
|
||||||
|
|
||||||
Use [`cursor_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_params) to inject `cursor` and `items_per_page` from query parameters with a `max_page_size` cap:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import Annotated
|
|
||||||
from fastapi import Depends
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_users(
|
|
||||||
session: SessionDep,
|
|
||||||
params: Annotated[dict, Depends(UserCrud.cursor_params(default_page_size=20, max_page_size=100))],
|
|
||||||
) -> CursorPaginatedResponse[UserRead]:
|
|
||||||
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Unified endpoint (both strategies)
|
### Unified endpoint (both strategies)
|
||||||
|
|
||||||
!!! info "Added in `v2.3.0`"
|
!!! info "Added in `v2.3.0`"
|
||||||
@@ -316,25 +270,14 @@ async def list_users(
|
|||||||
[`paginate()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate) dispatches to `offset_paginate` or `cursor_paginate` based on a `pagination_type` query parameter, letting you expose **one endpoint** that supports both strategies. The `pagination_type` field in the response tells clients which strategy was used, enabling frontend discriminated-union typing.
|
[`paginate()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate) dispatches to `offset_paginate` or `cursor_paginate` based on a `pagination_type` query parameter, letting you expose **one endpoint** that supports both strategies. The `pagination_type` field in the response tells clients which strategy was used, enabling frontend discriminated-union typing.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.crud import PaginationType
|
|
||||||
from fastapi_toolsets.schemas import PaginatedResponse
|
from fastapi_toolsets.schemas import PaginatedResponse
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
pagination_type: PaginationType = PaginationType.OFFSET,
|
params: Annotated[dict, Depends(UserCrud.paginate_params())],
|
||||||
page: int = Query(1, ge=1, description="Current page (offset only)"),
|
|
||||||
cursor: str | None = Query(None, description="Cursor token (cursor only)"),
|
|
||||||
items_per_page: int = Query(20, ge=1, le=100),
|
|
||||||
) -> PaginatedResponse[UserRead]:
|
) -> PaginatedResponse[UserRead]:
|
||||||
return await UserCrud.paginate(
|
return await UserCrud.paginate(session, **params, schema=UserRead)
|
||||||
session,
|
|
||||||
pagination_type=pagination_type,
|
|
||||||
page=page,
|
|
||||||
cursor=cursor,
|
|
||||||
items_per_page=items_per_page,
|
|
||||||
schema=UserRead,
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -342,25 +285,6 @@ GET /users?pagination_type=offset&page=2&items_per_page=10
|
|||||||
GET /users?pagination_type=cursor&cursor=eyJ2YWx1ZSI6...&items_per_page=10
|
GET /users?pagination_type=cursor&cursor=eyJ2YWx1ZSI6...&items_per_page=10
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Pagination params dependency
|
|
||||||
|
|
||||||
!!! info "Added in `v2.4.1`"
|
|
||||||
|
|
||||||
Use [`paginate_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate_params) to inject all parameters at once with configurable defaults and a `max_page_size` cap:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import Annotated
|
|
||||||
from fastapi import Depends
|
|
||||||
from fastapi_toolsets.schemas import PaginatedResponse
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_users(
|
|
||||||
session: SessionDep,
|
|
||||||
params: Annotated[dict, Depends(UserCrud.paginate_params(default_page_size=20, max_page_size=100))],
|
|
||||||
) -> PaginatedResponse[UserRead]:
|
|
||||||
return await UserCrud.paginate(session, **params, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Search
|
## Search
|
||||||
|
|
||||||
Two search strategies are available, both compatible with [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate).
|
Two search strategies are available, both compatible with [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate).
|
||||||
@@ -406,34 +330,18 @@ This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_users(
|
async def get_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
items_per_page: int = 50,
|
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
||||||
page: int = 1,
|
|
||||||
search: str | None = None,
|
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.offset_paginate(
|
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||||
session=session,
|
|
||||||
items_per_page=items_per_page,
|
|
||||||
page=page,
|
|
||||||
search=search,
|
|
||||||
schema=UserRead,
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_users(
|
async def get_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
cursor: str | None = None,
|
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
|
||||||
items_per_page: int = 50,
|
|
||||||
search: str | None = None,
|
|
||||||
) -> CursorPaginatedResponse[UserRead]:
|
) -> CursorPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.cursor_paginate(
|
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
||||||
session=session,
|
|
||||||
items_per_page=items_per_page,
|
|
||||||
cursor=cursor,
|
|
||||||
search=search,
|
|
||||||
schema=UserRead,
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Faceted search
|
### Faceted search
|
||||||
@@ -474,7 +382,7 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated
|
|||||||
"filter_attributes": {
|
"filter_attributes": {
|
||||||
"status": ["active", "inactive"],
|
"status": ["active", "inactive"],
|
||||||
"country": ["DE", "FR", "US"],
|
"country": ["DE", "FR", "US"],
|
||||||
"name": ["admin", "editor", "viewer"]
|
"role__name": ["admin", "editor", "viewer"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -482,11 +390,11 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated
|
|||||||
Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError).
|
Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError).
|
||||||
|
|
||||||
!!! info "The keys in `filter_by` are the same keys the client received in `filter_attributes`."
|
!!! info "The keys in `filter_by` are the same keys the client received in `filter_attributes`."
|
||||||
Keys are normally the terminal `column.key` (e.g. `"name"` for `Role.name`). When two facet fields share the same column key (e.g. `(Build.project, Project.name)` and `(Build.os, Os.name)`), the relationship name is prepended automatically: `"project__name"` and `"os__name"`.
|
Keys use `__` as a separator for the full relationship chain. A direct column `User.status` produces `"status"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`. A deeper chain `(User.role, Role.permission, Permission.name)` produces `"role__permission__name"`.
|
||||||
|
|
||||||
`filter_by` and `filters` can be combined — both are applied with AND logic.
|
`filter_by` and `filters` can be combined — both are applied with AND logic.
|
||||||
|
|
||||||
Use [`filter_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.filter_params) to generate a dict with the facet filter values from the query parameters:
|
Facet filtering is built into the consolidated params dependencies. When `filter=True` (the default), facet fields are exposed as query parameters and collected into `filter_by` automatically:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
@@ -501,13 +409,11 @@ UserCrud = CrudFactory(
|
|||||||
@router.get("", response_model_exclude_none=True)
|
@router.get("", response_model_exclude_none=True)
|
||||||
async def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
page: int = 1,
|
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
||||||
filter_by: Annotated[dict[str, list[str]], Depends(UserCrud.filter_params())],
|
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.offset_paginate(
|
return await UserCrud.offset_paginate(
|
||||||
session=session,
|
session=session,
|
||||||
page=page,
|
**params,
|
||||||
filter_by=filter_by,
|
|
||||||
schema=UserRead,
|
schema=UserRead,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -515,9 +421,9 @@ async def list_users(
|
|||||||
Both single-value and multi-value query parameters work:
|
Both single-value and multi-value query parameters work:
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /users?status=active → filter_by={"status": ["active"]}
|
GET /users?status=active → filter_by={"status": ["active"]}
|
||||||
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
|
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
|
||||||
GET /users?role=admin&role=editor → filter_by={"role": ["admin", "editor"]} (IN clause)
|
GET /users?role__name=admin&role__name=editor → filter_by={"role__name": ["admin", "editor"]} (IN clause)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sorting
|
## Sorting
|
||||||
@@ -536,20 +442,21 @@ UserCrud = CrudFactory(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Call [`order_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.order_params) to generate a FastAPI dependency that maps the query parameters to an [`OrderByClause`](../reference/crud.md#fastapi_toolsets.crud.factory.OrderByClause) expression:
|
Ordering is built into the consolidated params dependencies. When `order=True` (the default), `order_by` and `order` query parameters are exposed and resolved into an `OrderByClause` automatically:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from fastapi_toolsets.crud import OrderByClause
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
order_by: Annotated[OrderByClause | None, Depends(UserCrud.order_params())],
|
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(
|
||||||
|
default_order_field=User.created_at,
|
||||||
|
))],
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.offset_paginate(session=session, order_by=order_by, schema=UserRead)
|
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||||
```
|
```
|
||||||
|
|
||||||
The dependency adds two query parameters to the endpoint:
|
The dependency adds two query parameters to the endpoint:
|
||||||
@@ -566,10 +473,10 @@ GET /users?order_by=name&order=desc → ORDER BY users.name DESC
|
|||||||
|
|
||||||
An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
|
An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
|
||||||
|
|
||||||
You can also pass `order_fields` directly to `order_params()` to override the class-level defaults without modifying them:
|
You can also pass `order_fields` directly to override the class-level defaults:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
UserOrderParams = UserCrud.order_params(order_fields=[User.name])
|
params = UserCrud.offset_paginate_params(order_fields=[User.name])
|
||||||
```
|
```
|
||||||
|
|
||||||
## Relationship loading
|
## Relationship loading
|
||||||
@@ -656,12 +563,11 @@ async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(session: SessionDep, page: int = 1) -> OffsetPaginatedResponse[UserRead]:
|
async def list_users(
|
||||||
return await crud.UserCrud.offset_paginate(
|
session: SessionDep,
|
||||||
session=session,
|
params: Annotated[dict, Depends(crud.UserCrud.offset_paginate_params())],
|
||||||
page=page,
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
schema=UserRead,
|
return await crud.UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.
|
The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.
|
||||||
|
|||||||
@@ -117,139 +117,118 @@ class Article(Base, UUIDMixin, TimestampMixin):
|
|||||||
title: Mapped[str]
|
title: Mapped[str]
|
||||||
```
|
```
|
||||||
|
|
||||||
### [`WatchedFieldsMixin`](../reference/models.md#fastapi_toolsets.models.WatchedFieldsMixin)
|
## Lifecycle events
|
||||||
|
|
||||||
!!! info "Added in `v2.4`"
|
The event system provides lifecycle callbacks that fire **after commit**. If the transaction rolls back, no callback fires.
|
||||||
|
|
||||||
`WatchedFieldsMixin` provides lifecycle callbacks that fire **after commit** — meaning the row is durably persisted when your callback runs. If the transaction rolls back, no callback fires.
|
### Setup
|
||||||
|
|
||||||
Three callbacks are available, each corresponding to a [`ModelEvent`](../reference/models.md#fastapi_toolsets.models.ModelEvent) value:
|
Event dispatch requires [`EventSession`](../reference/models.md#fastapi_toolsets.models.EventSession). Pass it as the session class when creating your session factory:
|
||||||
|
|
||||||
| Callback | Event | Trigger |
|
|
||||||
|---|---|---|
|
|
||||||
| `on_create()` | `ModelEvent.CREATE` | After `INSERT` |
|
|
||||||
| `on_delete()` | `ModelEvent.DELETE` | After `DELETE` |
|
|
||||||
| `on_update(changes)` | `ModelEvent.UPDATE` | After `UPDATE` on a watched field |
|
|
||||||
|
|
||||||
Server-side defaults (e.g. `id`, `created_at`) are fully populated in all callbacks. All callbacks support both `async def` and plain `def`. Use `@watch` to restrict which fields trigger `on_update`:
|
|
||||||
|
|
||||||
| Decorator | `on_update` behaviour |
|
|
||||||
|---|---|
|
|
||||||
| `@watch("status", "role")` | Only fires when `status` or `role` 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
|
```python
|
||||||
@watch("status")
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
class Order(Base, UUIDMixin, WatchedFieldsMixin):
|
from fastapi_toolsets.models import EventSession
|
||||||
|
|
||||||
|
engine = create_async_engine("postgresql+asyncpg://...")
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=EventSession)
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! info "Callbacks fire on `session.commit()` only — not on savepoints."
|
||||||
|
Savepoints created by [`get_transaction`](db.md) or `begin_nested()` do **not**
|
||||||
|
trigger callbacks. All events accumulated across flushes are dispatched once
|
||||||
|
when the outermost `commit()` is called.
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
Three event types are available, each corresponding to a [`ModelEvent`](../reference/models.md#fastapi_toolsets.models.ModelEvent) value:
|
||||||
|
|
||||||
|
| Event | Trigger |
|
||||||
|
|---|---|
|
||||||
|
| `ModelEvent.CREATE` | After `INSERT` commit |
|
||||||
|
| `ModelEvent.DELETE` | After `DELETE` commit |
|
||||||
|
| `ModelEvent.UPDATE` | After `UPDATE` commit on a watched field |
|
||||||
|
|
||||||
|
!!! warning "Callbacks fire only for ORM-level changes. Rows updated via raw SQL (`UPDATE ... SET ...`) are not detected."
|
||||||
|
|
||||||
|
### Watched fields
|
||||||
|
|
||||||
|
Set `__watched_fields__` on the model to restrict which field changes trigger `UPDATE` events. It must be a `tuple[str, ...]` — any other type raises `TypeError`:
|
||||||
|
|
||||||
|
| Class attribute | `UPDATE` behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `__watched_fields__ = ("status", "role")` | Only fires when `status` or `role` changes |
|
||||||
|
| *(not set)* | Fires when **any** mapped field changes |
|
||||||
|
|
||||||
|
`__watched_fields__` is inherited through the class hierarchy via normal Python MRO. A subclass can override it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Order(Base, UUIDMixin):
|
||||||
|
__watched_fields__ = ("status",)
|
||||||
...
|
...
|
||||||
|
|
||||||
class UrgentOrder(Order):
|
class UrgentOrder(Order):
|
||||||
# inherits @watch("status") — on_update fires only for status changes
|
# inherits __watched_fields__ = ("status",)
|
||||||
...
|
...
|
||||||
|
|
||||||
@watch("priority")
|
|
||||||
class PriorityOrder(Order):
|
class PriorityOrder(Order):
|
||||||
# overrides parent — on_update fires only for priority changes
|
__watched_fields__ = ("priority",)
|
||||||
|
# overrides parent — UPDATE fires only for priority changes
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Option 1 — catch-all with `on_event`
|
### Registering handlers
|
||||||
|
|
||||||
Override `on_event` to handle all event types in one place. The specific methods delegate here by default:
|
Register handlers with the [`listens_for`](../reference/models.md#fastapi_toolsets.models.listens_for) decorator. Every callback receives three arguments: the model instance, the [`ModelEvent`](../reference/models.md#fastapi_toolsets.models.ModelEvent) that triggered it, and a `changes` dict (`None` for `CREATE` and `DELETE`):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.models import ModelEvent, UUIDMixin, WatchedFieldsMixin, watch
|
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
|
||||||
|
|
||||||
@watch("status")
|
class Order(Base, UUIDMixin):
|
||||||
class Order(Base, UUIDMixin, WatchedFieldsMixin):
|
|
||||||
__tablename__ = "orders"
|
__tablename__ = "orders"
|
||||||
|
__watched_fields__ = ("status",)
|
||||||
|
|
||||||
status: Mapped[str]
|
status: Mapped[str]
|
||||||
|
|
||||||
async def on_event(self, event: ModelEvent, changes: dict | None = None) -> None:
|
@listens_for(Order, [ModelEvent.CREATE])
|
||||||
if event == ModelEvent.CREATE:
|
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
|
||||||
await notify_new_order(self.id)
|
await notify_new_order(order.id)
|
||||||
elif event == ModelEvent.DELETE:
|
|
||||||
await notify_order_cancelled(self.id)
|
@listens_for(Order, [ModelEvent.DELETE])
|
||||||
elif event == ModelEvent.UPDATE:
|
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
|
||||||
await notify_status_change(self.id, changes["status"])
|
await notify_order_cancelled(order.id)
|
||||||
|
|
||||||
|
@listens_for(Order, [ModelEvent.UPDATE])
|
||||||
|
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
|
||||||
|
if "status" in changes:
|
||||||
|
await notify_status_change(order.id, changes["status"])
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Option 2 — targeted overrides
|
Multiple handlers can be registered for the same model and event. Handlers registered on a parent class also fire for subclass instances.
|
||||||
|
|
||||||
Override individual methods for more focused logic:
|
A single handler can listen for multiple events at once. When `event_types` is omitted, the handler fires for all events:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@watch("status")
|
@listens_for(Order, [ModelEvent.CREATE, ModelEvent.UPDATE])
|
||||||
class Order(Base, UUIDMixin, WatchedFieldsMixin):
|
async def on_order_changed(order: Order, event_type: ModelEvent, changes: dict | None):
|
||||||
__tablename__ = "orders"
|
await invalidate_cache(order.id)
|
||||||
|
|
||||||
status: Mapped[str]
|
@listens_for(Order) # all events
|
||||||
|
async def on_any_order_event(order: Order, event_type: ModelEvent, changes: dict | None):
|
||||||
async def on_create(self) -> None:
|
await audit_log(order.id, event_type)
|
||||||
await notify_new_order(self.id)
|
|
||||||
|
|
||||||
async def on_delete(self) -> None:
|
|
||||||
await notify_order_cancelled(self.id)
|
|
||||||
|
|
||||||
async def on_update(self, changes: dict) -> None:
|
|
||||||
if "status" in changes:
|
|
||||||
old = changes["status"]["old"]
|
|
||||||
new = changes["status"]["new"]
|
|
||||||
await notify_status_change(self.id, old, new)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Field changes format
|
### Field changes format
|
||||||
|
|
||||||
The `changes` dict maps each watched field that changed to `{"old": ..., "new": ...}`. Only fields that actually changed are included:
|
The `changes` dict maps each watched field that changed to `{"old": ..., "new": ...}`. Only fields that actually changed are included. For `CREATE` and `DELETE` events, `changes` is `None`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
# CREATE / DELETE → changes is None
|
||||||
# status changed → {"status": {"old": "pending", "new": "shipped"}}
|
# status changed → {"status": {"old": "pending", "new": "shipped"}}
|
||||||
# two fields changed → {"status": {...}, "assigned_to": {...}}
|
# two fields changed → {"status": {...}, "assigned_to": {...}}
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! info "Multiple flushes in one transaction are merged: the earliest `old` and latest `new` are preserved, and `on_update` fires only once per commit."
|
!!! info "Multiple flushes in one transaction are merged: the earliest `old` and latest `new` are preserved, and `on_update` fires only once per commit."
|
||||||
|
|
||||||
!!! warning "Callbacks fire only for ORM-level changes. Rows updated via raw SQL (`UPDATE ... SET ...`) are not detected."
|
|
||||||
|
|
||||||
!!! warning "Callbacks fire when the **outermost active context** (savepoint or 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) or [`lock_tables`](db.md)
|
|
||||||
block. Without it, each `create` call commits its own savepoint 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
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
|
||||||
|
|
||||||
class Event(Base, UUIDMixin, TimestampMixin):
|
|
||||||
__tablename__ = "events"
|
|
||||||
name: Mapped[str]
|
|
||||||
|
|
||||||
class Counter(Base, UpdatedAtMixin):
|
|
||||||
__tablename__ = "counters"
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
||||||
value: Mapped[int]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/models.md)
|
[:material-api: API Reference](../reference/models.md)
|
||||||
|
|||||||
@@ -79,9 +79,6 @@ The examples above are already compatible with parallel test execution with `pyt
|
|||||||
|
|
||||||
## Cleaning up tables
|
## Cleaning up tables
|
||||||
|
|
||||||
!!! warning
|
|
||||||
Since `V2.1.0` `cleanup_tables` now live in `fastapi_toolsets.db`. For backward compatibility the function is still available in `fastapi_toolsets.pytest`, but this will be remove in `V3.0.0`.
|
|
||||||
|
|
||||||
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables), this will truncate all tables between tests for fast isolation:
|
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables), this will truncate all tables between tests for fast isolation:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -6,17 +6,19 @@ You can import them directly from `fastapi_toolsets.models`:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.models import (
|
from fastapi_toolsets.models import (
|
||||||
|
EventSession,
|
||||||
ModelEvent,
|
ModelEvent,
|
||||||
UUIDMixin,
|
UUIDMixin,
|
||||||
UUIDv7Mixin,
|
UUIDv7Mixin,
|
||||||
CreatedAtMixin,
|
CreatedAtMixin,
|
||||||
UpdatedAtMixin,
|
UpdatedAtMixin,
|
||||||
TimestampMixin,
|
TimestampMixin,
|
||||||
WatchedFieldsMixin,
|
listens_for,
|
||||||
watch,
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.models.EventSession
|
||||||
|
|
||||||
## ::: fastapi_toolsets.models.ModelEvent
|
## ::: fastapi_toolsets.models.ModelEvent
|
||||||
|
|
||||||
## ::: fastapi_toolsets.models.UUIDMixin
|
## ::: fastapi_toolsets.models.UUIDMixin
|
||||||
@@ -29,6 +31,4 @@ from fastapi_toolsets.models import (
|
|||||||
|
|
||||||
## ::: fastapi_toolsets.models.TimestampMixin
|
## ::: fastapi_toolsets.models.TimestampMixin
|
||||||
|
|
||||||
## ::: fastapi_toolsets.models.WatchedFieldsMixin
|
## ::: fastapi_toolsets.models.listens_for
|
||||||
|
|
||||||
## ::: fastapi_toolsets.models.watch
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from fastapi_toolsets.crud import OrderByClause
|
|
||||||
from fastapi_toolsets.schemas import (
|
from fastapi_toolsets.schemas import (
|
||||||
CursorPaginatedResponse,
|
CursorPaginatedResponse,
|
||||||
OffsetPaginatedResponse,
|
OffsetPaginatedResponse,
|
||||||
@@ -22,21 +21,18 @@ async def list_articles_offset(
|
|||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[
|
params: Annotated[
|
||||||
dict,
|
dict,
|
||||||
Depends(ArticleCrud.offset_params(default_page_size=20, max_page_size=100)),
|
Depends(
|
||||||
|
ArticleCrud.offset_paginate_params(
|
||||||
|
default_page_size=20,
|
||||||
|
max_page_size=100,
|
||||||
|
default_order_field=Article.created_at,
|
||||||
|
)
|
||||||
|
),
|
||||||
],
|
],
|
||||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
|
||||||
order_by: Annotated[
|
|
||||||
OrderByClause | None,
|
|
||||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
|
||||||
],
|
|
||||||
search: str | None = None,
|
|
||||||
) -> OffsetPaginatedResponse[ArticleRead]:
|
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||||
return await ArticleCrud.offset_paginate(
|
return await ArticleCrud.offset_paginate(
|
||||||
session=session,
|
session=session,
|
||||||
**params,
|
**params,
|
||||||
search=search,
|
|
||||||
filter_by=filter_by or None,
|
|
||||||
order_by=order_by,
|
|
||||||
schema=ArticleRead,
|
schema=ArticleRead,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,21 +42,18 @@ async def list_articles_cursor(
|
|||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[
|
params: Annotated[
|
||||||
dict,
|
dict,
|
||||||
Depends(ArticleCrud.cursor_params(default_page_size=20, max_page_size=100)),
|
Depends(
|
||||||
|
ArticleCrud.cursor_paginate_params(
|
||||||
|
default_page_size=20,
|
||||||
|
max_page_size=100,
|
||||||
|
default_order_field=Article.created_at,
|
||||||
|
)
|
||||||
|
),
|
||||||
],
|
],
|
||||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
|
||||||
order_by: Annotated[
|
|
||||||
OrderByClause | None,
|
|
||||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
|
||||||
],
|
|
||||||
search: str | None = None,
|
|
||||||
) -> CursorPaginatedResponse[ArticleRead]:
|
) -> CursorPaginatedResponse[ArticleRead]:
|
||||||
return await ArticleCrud.cursor_paginate(
|
return await ArticleCrud.cursor_paginate(
|
||||||
session=session,
|
session=session,
|
||||||
**params,
|
**params,
|
||||||
search=search,
|
|
||||||
filter_by=filter_by or None,
|
|
||||||
order_by=order_by,
|
|
||||||
schema=ArticleRead,
|
schema=ArticleRead,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,20 +63,17 @@ async def list_articles(
|
|||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[
|
params: Annotated[
|
||||||
dict,
|
dict,
|
||||||
Depends(ArticleCrud.paginate_params(default_page_size=20, max_page_size=100)),
|
Depends(
|
||||||
|
ArticleCrud.paginate_params(
|
||||||
|
default_page_size=20,
|
||||||
|
max_page_size=100,
|
||||||
|
default_order_field=Article.created_at,
|
||||||
|
)
|
||||||
|
),
|
||||||
],
|
],
|
||||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
|
||||||
order_by: Annotated[
|
|
||||||
OrderByClause | None,
|
|
||||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
|
||||||
],
|
|
||||||
search: str | None = None,
|
|
||||||
) -> PaginatedResponse[ArticleRead]:
|
) -> PaginatedResponse[ArticleRead]:
|
||||||
return await ArticleCrud.paginate(
|
return await ArticleCrud.paginate(
|
||||||
session,
|
session,
|
||||||
**params,
|
**params,
|
||||||
search=search,
|
|
||||||
filter_by=filter_by or None,
|
|
||||||
order_by=order_by,
|
|
||||||
schema=ArticleRead,
|
schema=ArticleRead,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "2.4.3"
|
version = "3.0.0"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -80,8 +80,9 @@ tests = [
|
|||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
|
"mike",
|
||||||
"mkdocstrings-python>=2.0.2",
|
"mkdocstrings-python>=2.0.2",
|
||||||
"zensical>=0.0.23",
|
"zensical>=0.0.30",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
@@ -104,3 +105,6 @@ exclude_lines = [
|
|||||||
"if TYPE_CHECKING:",
|
"if TYPE_CHECKING:",
|
||||||
"raise NotImplementedError",
|
"raise NotImplementedError",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
mike = { git = "https://github.com/squidfunk/mike.git", tag = "2.2.0+zensical-0.1.0" }
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.4.3"
|
__version__ = "3.0.0"
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"""Generic async CRUD operations for SQLAlchemy models."""
|
"""Generic async CRUD operations for SQLAlchemy models."""
|
||||||
|
|
||||||
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
from ..exceptions import (
|
||||||
|
InvalidFacetFilterError,
|
||||||
|
InvalidSearchColumnError,
|
||||||
|
NoSearchableFieldsError,
|
||||||
|
UnsupportedFacetTypeError,
|
||||||
|
)
|
||||||
from ..schemas import PaginationType
|
from ..schemas import PaginationType
|
||||||
from ..types import (
|
from ..types import (
|
||||||
FacetFieldType,
|
FacetFieldType,
|
||||||
@@ -18,6 +23,7 @@ __all__ = [
|
|||||||
"FacetFieldType",
|
"FacetFieldType",
|
||||||
"get_searchable_fields",
|
"get_searchable_fields",
|
||||||
"InvalidFacetFilterError",
|
"InvalidFacetFilterError",
|
||||||
|
"InvalidSearchColumnError",
|
||||||
"JoinType",
|
"JoinType",
|
||||||
"M2MFieldType",
|
"M2MFieldType",
|
||||||
"NoSearchableFieldsError",
|
"NoSearchableFieldsError",
|
||||||
@@ -25,4 +31,5 @@ __all__ = [
|
|||||||
"PaginationType",
|
"PaginationType",
|
||||||
"SearchConfig",
|
"SearchConfig",
|
||||||
"SearchFieldType",
|
"SearchFieldType",
|
||||||
|
"UnsupportedFacetTypeError",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ from .search import (
|
|||||||
build_filter_by,
|
build_filter_by,
|
||||||
build_search_filters,
|
build_search_filters,
|
||||||
facet_keys,
|
facet_keys,
|
||||||
|
search_field_keys,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,8 +116,12 @@ def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
|
|||||||
|
|
||||||
def _apply_search_joins(q: Any, search_joins: list[Any]) -> Any:
|
def _apply_search_joins(q: Any, search_joins: list[Any]) -> Any:
|
||||||
"""Apply relationship-based outer joins (from search/filter_by) to a query."""
|
"""Apply relationship-based outer joins (from search/filter_by) to a query."""
|
||||||
|
seen: set[str] = set()
|
||||||
for join_rel in search_joins:
|
for join_rel in search_joins:
|
||||||
q = q.outerjoin(join_rel)
|
key = str(join_rel)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
q = q.outerjoin(join_rel)
|
||||||
return q
|
return q
|
||||||
|
|
||||||
|
|
||||||
@@ -263,118 +268,285 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def filter_params(
|
def _resolve_search_columns(
|
||||||
|
cls: type[Self],
|
||||||
|
search_fields: Sequence[SearchFieldType] | None,
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""Return search column keys, or None if no searchable fields configured."""
|
||||||
|
fields = search_fields if search_fields is not None else cls.searchable_fields
|
||||||
|
if not fields:
|
||||||
|
return None
|
||||||
|
return search_field_keys(fields)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_paginate_params(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
*,
|
*,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
pagination_params: list[inspect.Parameter],
|
||||||
) -> Callable[..., Awaitable[dict[str, list[str]]]]:
|
pagination_fixed: dict[str, Any],
|
||||||
"""Return a FastAPI dependency that collects facet filter values from query parameters.
|
dep_name: str,
|
||||||
|
search: bool,
|
||||||
|
filter: bool,
|
||||||
|
order: bool,
|
||||||
|
search_fields: Sequence[SearchFieldType] | None,
|
||||||
|
facet_fields: Sequence[FacetFieldType] | None,
|
||||||
|
order_fields: Sequence[QueryableAttribute[Any]] | None,
|
||||||
|
default_order_field: QueryableAttribute[Any] | None,
|
||||||
|
default_order: Literal["asc", "desc"],
|
||||||
|
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||||
|
"""Build a consolidated FastAPI dependency that merges pagination, search, filter, and order params."""
|
||||||
|
all_params: list[inspect.Parameter] = list(pagination_params)
|
||||||
|
pagination_param_names = tuple(p.name for p in pagination_params)
|
||||||
|
reserved_names: set[str] = set(pagination_param_names)
|
||||||
|
|
||||||
Args:
|
search_keys: list[str] | None = None
|
||||||
facet_fields: Override the facet fields for this dependency. Falls back to the
|
if search:
|
||||||
class-level ``facet_fields`` if not provided.
|
search_keys = cls._resolve_search_columns(search_fields)
|
||||||
|
if search_keys:
|
||||||
Returns:
|
all_params.extend(
|
||||||
An async dependency function named ``{Model}FilterParams`` that resolves to a
|
[
|
||||||
``dict[str, list[str]]`` containing only the keys that were supplied in the
|
inspect.Parameter(
|
||||||
request (absent/``None`` parameters are excluded).
|
"search",
|
||||||
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
Raises:
|
annotation=str | None,
|
||||||
ValueError: If no facet fields are configured on this CRUD class and none are
|
default=Query(
|
||||||
provided via ``facet_fields``.
|
default=None, description="Search query string"
|
||||||
"""
|
),
|
||||||
fields = cls._resolve_facet_fields(facet_fields)
|
),
|
||||||
if not fields:
|
inspect.Parameter(
|
||||||
raise ValueError(
|
"search_column",
|
||||||
f"{cls.__name__} has no facet_fields configured. "
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
"Pass facet_fields= or set them on CrudFactory."
|
annotation=str | None,
|
||||||
)
|
default=Query(
|
||||||
keys = facet_keys(fields)
|
default=None,
|
||||||
|
description="Restrict search to a single column",
|
||||||
async def dependency(**kwargs: Any) -> dict[str, list[str]]:
|
enum=search_keys,
|
||||||
return {k: v for k, v in kwargs.items() if v is not None}
|
),
|
||||||
|
),
|
||||||
dependency.__name__ = f"{cls.model.__name__}FilterParams"
|
]
|
||||||
dependency.__signature__ = inspect.Signature( # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
|
|
||||||
parameters=[
|
|
||||||
inspect.Parameter(
|
|
||||||
k,
|
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
|
||||||
annotation=list[str] | None,
|
|
||||||
default=Query(default=None),
|
|
||||||
)
|
)
|
||||||
for k in keys
|
reserved_names.update({"search", "search_column"})
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
filter_keys: list[str] | None = None
|
||||||
|
if filter:
|
||||||
|
resolved_facets = cls._resolve_facet_fields(facet_fields)
|
||||||
|
if resolved_facets:
|
||||||
|
filter_keys = facet_keys(resolved_facets)
|
||||||
|
for k in filter_keys:
|
||||||
|
if k in reserved_names:
|
||||||
|
raise ValueError(
|
||||||
|
f"Facet field key {k!r} conflicts with a reserved "
|
||||||
|
f"parameter name. Reserved names: {sorted(reserved_names)}"
|
||||||
|
)
|
||||||
|
all_params.extend(
|
||||||
|
inspect.Parameter(
|
||||||
|
k,
|
||||||
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=list[str] | None,
|
||||||
|
default=Query(default=None),
|
||||||
|
)
|
||||||
|
for k in filter_keys
|
||||||
|
)
|
||||||
|
reserved_names.update(filter_keys)
|
||||||
|
|
||||||
|
order_field_map: dict[str, QueryableAttribute[Any]] | None = None
|
||||||
|
order_valid_keys: list[str] | None = None
|
||||||
|
if order:
|
||||||
|
resolved_order = (
|
||||||
|
order_fields if order_fields is not None else cls.order_fields
|
||||||
|
)
|
||||||
|
if resolved_order:
|
||||||
|
order_field_map = {f.key: f for f in resolved_order}
|
||||||
|
order_valid_keys = sorted(order_field_map.keys())
|
||||||
|
all_params.extend(
|
||||||
|
[
|
||||||
|
inspect.Parameter(
|
||||||
|
"order_by",
|
||||||
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=str | None,
|
||||||
|
default=Query(
|
||||||
|
None,
|
||||||
|
description=f"Field to order by. Valid values: {order_valid_keys}",
|
||||||
|
enum=order_valid_keys,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inspect.Parameter(
|
||||||
|
"order",
|
||||||
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=Literal["asc", "desc"],
|
||||||
|
default=Query(default_order, description="Sort direction"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def dependency(**kwargs: Any) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = dict(pagination_fixed)
|
||||||
|
for name in pagination_param_names:
|
||||||
|
result[name] = kwargs[name]
|
||||||
|
|
||||||
|
if search_keys is not None:
|
||||||
|
search_val = kwargs.get("search")
|
||||||
|
if search_val is not None:
|
||||||
|
result["search"] = search_val
|
||||||
|
search_col_val = kwargs.get("search_column")
|
||||||
|
if search_col_val is not None:
|
||||||
|
result["search_column"] = search_col_val
|
||||||
|
|
||||||
|
if filter_keys is not None:
|
||||||
|
filter_by = {
|
||||||
|
k: kwargs[k] for k in filter_keys if kwargs.get(k) is not None
|
||||||
|
}
|
||||||
|
result["filter_by"] = filter_by or None
|
||||||
|
|
||||||
|
if order_field_map is not None:
|
||||||
|
order_by_val = kwargs.get("order_by")
|
||||||
|
order_dir = kwargs.get("order", default_order)
|
||||||
|
if order_by_val is None:
|
||||||
|
field = default_order_field
|
||||||
|
elif order_by_val not in order_field_map:
|
||||||
|
raise InvalidOrderFieldError(order_by_val, order_valid_keys or [])
|
||||||
|
else:
|
||||||
|
field = order_field_map[order_by_val]
|
||||||
|
if field is not None:
|
||||||
|
result["order_by"] = (
|
||||||
|
field.asc() if order_dir == "asc" else field.desc()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result["order_by"] = None
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
dependency.__name__ = dep_name
|
||||||
|
dependency.__signature__ = inspect.Signature( # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
|
||||||
|
parameters=all_params,
|
||||||
|
)
|
||||||
return dependency
|
return dependency
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def offset_params(
|
def offset_paginate_params(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
*,
|
*,
|
||||||
default_page_size: int = 20,
|
default_page_size: int = 20,
|
||||||
max_page_size: int = 100,
|
max_page_size: int = 100,
|
||||||
include_total: bool = True,
|
include_total: bool = True,
|
||||||
|
search: bool = True,
|
||||||
|
filter: bool = True,
|
||||||
|
order: bool = True,
|
||||||
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||||
|
default_order_field: QueryableAttribute[Any] | None = None,
|
||||||
|
default_order: Literal["asc", "desc"] = "asc",
|
||||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||||
"""Return a FastAPI dependency that collects offset pagination params from query params.
|
"""Return a FastAPI dependency that collects all params for :meth:`offset_paginate`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
default_page_size: Default value for the ``items_per_page`` query parameter.
|
default_page_size: Default ``items_per_page`` value.
|
||||||
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
max_page_size: Maximum ``items_per_page`` value.
|
||||||
``le`` on the ``Query``).
|
include_total: Whether to include total count (not a query param).
|
||||||
include_total: Server-side flag forwarded as-is to ``include_total`` in
|
search: Enable search query parameters.
|
||||||
:meth:`offset_paginate`. Not exposed as a query parameter.
|
filter: Enable facet filter query parameters.
|
||||||
|
order: Enable order query parameters.
|
||||||
|
search_fields: Override searchable fields.
|
||||||
|
facet_fields: Override facet fields.
|
||||||
|
order_fields: Override order fields.
|
||||||
|
default_order_field: Default field to order by when ``order_by`` is absent.
|
||||||
|
default_order: Default sort direction.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
An async dependency that resolves to a dict with ``page``,
|
An async dependency that resolves to a dict ready to be unpacked
|
||||||
``items_per_page``, and ``include_total`` keys, ready to be
|
into :meth:`offset_paginate`.
|
||||||
unpacked into :meth:`offset_paginate`.
|
|
||||||
"""
|
"""
|
||||||
|
pagination_params = [
|
||||||
async def dependency(
|
inspect.Parameter(
|
||||||
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
"page",
|
||||||
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
) -> dict[str, Any]:
|
annotation=int,
|
||||||
return {
|
default=Query(1, ge=1, description="Page number (1-indexed)"),
|
||||||
"page": page,
|
),
|
||||||
"items_per_page": items_per_page,
|
inspect.Parameter(
|
||||||
"include_total": include_total,
|
"items_per_page",
|
||||||
}
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=int,
|
||||||
dependency.__name__ = f"{cls.model.__name__}OffsetParams"
|
default=_page_size_query(default_page_size, max_page_size),
|
||||||
return dependency
|
),
|
||||||
|
]
|
||||||
|
return cls._build_paginate_params(
|
||||||
|
pagination_params=pagination_params,
|
||||||
|
pagination_fixed={"include_total": include_total},
|
||||||
|
dep_name=f"{cls.model.__name__}OffsetPaginateParams",
|
||||||
|
search=search,
|
||||||
|
filter=filter,
|
||||||
|
order=order,
|
||||||
|
search_fields=search_fields,
|
||||||
|
facet_fields=facet_fields,
|
||||||
|
order_fields=order_fields,
|
||||||
|
default_order_field=default_order_field,
|
||||||
|
default_order=default_order,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def cursor_params(
|
def cursor_paginate_params(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
*,
|
*,
|
||||||
default_page_size: int = 20,
|
default_page_size: int = 20,
|
||||||
max_page_size: int = 100,
|
max_page_size: int = 100,
|
||||||
|
search: bool = True,
|
||||||
|
filter: bool = True,
|
||||||
|
order: bool = True,
|
||||||
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||||
|
default_order_field: QueryableAttribute[Any] | None = None,
|
||||||
|
default_order: Literal["asc", "desc"] = "asc",
|
||||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||||
"""Return a FastAPI dependency that collects cursor pagination params from query params.
|
"""Return a FastAPI dependency that collects all params for :meth:`cursor_paginate`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
default_page_size: Default value for the ``items_per_page`` query parameter.
|
default_page_size: Default ``items_per_page`` value.
|
||||||
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
max_page_size: Maximum ``items_per_page`` value.
|
||||||
``le`` on the ``Query``).
|
search: Enable search query parameters.
|
||||||
|
filter: Enable facet filter query parameters.
|
||||||
|
order: Enable order query parameters.
|
||||||
|
search_fields: Override searchable fields.
|
||||||
|
facet_fields: Override facet fields.
|
||||||
|
order_fields: Override order fields.
|
||||||
|
default_order_field: Default field to order by when ``order_by`` is absent.
|
||||||
|
default_order: Default sort direction.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
An async dependency that resolves to a dict with ``cursor`` and
|
An async dependency that resolves to a dict ready to be unpacked
|
||||||
``items_per_page`` keys, ready to be unpacked into
|
into :meth:`cursor_paginate`.
|
||||||
:meth:`cursor_paginate`.
|
|
||||||
"""
|
"""
|
||||||
|
pagination_params = [
|
||||||
async def dependency(
|
inspect.Parameter(
|
||||||
cursor: str | None = Query(
|
"cursor",
|
||||||
None, description="Cursor token from a previous response"
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=str | None,
|
||||||
|
default=Query(
|
||||||
|
None, description="Cursor token from a previous response"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
inspect.Parameter(
|
||||||
) -> dict[str, Any]:
|
"items_per_page",
|
||||||
return {"cursor": cursor, "items_per_page": items_per_page}
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=int,
|
||||||
dependency.__name__ = f"{cls.model.__name__}CursorParams"
|
default=_page_size_query(default_page_size, max_page_size),
|
||||||
return dependency
|
),
|
||||||
|
]
|
||||||
|
return cls._build_paginate_params(
|
||||||
|
pagination_params=pagination_params,
|
||||||
|
pagination_fixed={},
|
||||||
|
dep_name=f"{cls.model.__name__}CursorPaginateParams",
|
||||||
|
search=search,
|
||||||
|
filter=filter,
|
||||||
|
order=order,
|
||||||
|
search_fields=search_fields,
|
||||||
|
facet_fields=facet_fields,
|
||||||
|
order_fields=order_fields,
|
||||||
|
default_order_field=default_order_field,
|
||||||
|
default_order=default_order,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def paginate_params(
|
def paginate_params(
|
||||||
@@ -384,102 +556,81 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
max_page_size: int = 100,
|
max_page_size: int = 100,
|
||||||
default_pagination_type: PaginationType = PaginationType.OFFSET,
|
default_pagination_type: PaginationType = PaginationType.OFFSET,
|
||||||
include_total: bool = True,
|
include_total: bool = True,
|
||||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
search: bool = True,
|
||||||
"""Return a FastAPI dependency that collects all pagination params from query params.
|
filter: bool = True,
|
||||||
|
order: bool = True,
|
||||||
Args:
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
default_page_size: Default value for the ``items_per_page`` query parameter.
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
|
||||||
``le`` on the ``Query``).
|
|
||||||
default_pagination_type: Default pagination strategy.
|
|
||||||
include_total: Server-side flag forwarded as-is to ``include_total`` in
|
|
||||||
:meth:`paginate`. Not exposed as a query parameter.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An async dependency that resolves to a dict with ``pagination_type``,
|
|
||||||
``page``, ``cursor``, ``items_per_page``, and ``include_total`` keys,
|
|
||||||
ready to be unpacked into :meth:`paginate`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def dependency(
|
|
||||||
pagination_type: PaginationType = Query(
|
|
||||||
default_pagination_type, description="Pagination strategy"
|
|
||||||
),
|
|
||||||
page: int = Query(
|
|
||||||
1, ge=1, description="Page number (1-indexed, offset only)"
|
|
||||||
),
|
|
||||||
cursor: str | None = Query(
|
|
||||||
None, description="Cursor token from a previous response (cursor only)"
|
|
||||||
),
|
|
||||||
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"pagination_type": pagination_type,
|
|
||||||
"page": page,
|
|
||||||
"cursor": cursor,
|
|
||||||
"items_per_page": items_per_page,
|
|
||||||
"include_total": include_total,
|
|
||||||
}
|
|
||||||
|
|
||||||
dependency.__name__ = f"{cls.model.__name__}PaginateParams"
|
|
||||||
return dependency
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def order_params(
|
|
||||||
cls: type[Self],
|
|
||||||
*,
|
|
||||||
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||||
default_field: QueryableAttribute[Any] | None = None,
|
default_order_field: QueryableAttribute[Any] | None = None,
|
||||||
default_order: Literal["asc", "desc"] = "asc",
|
default_order: Literal["asc", "desc"] = "asc",
|
||||||
) -> Callable[..., Awaitable[OrderByClause | None]]:
|
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||||
"""Return a FastAPI dependency that resolves order query params into an order_by clause.
|
"""Return a FastAPI dependency that collects all params for :meth:`paginate`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
order_fields: Override the allowed order fields. Falls back to the class-level
|
default_page_size: Default ``items_per_page`` value.
|
||||||
``order_fields`` if not provided.
|
max_page_size: Maximum ``items_per_page`` value.
|
||||||
default_field: Field to order by when ``order_by`` query param is absent.
|
default_pagination_type: Default pagination strategy.
|
||||||
If ``None`` and no ``order_by`` is provided, no ordering is applied.
|
include_total: Whether to include total count (not a query param).
|
||||||
default_order: Default order direction when ``order`` is absent
|
search: Enable search query parameters.
|
||||||
(``"asc"`` or ``"desc"``).
|
filter: Enable facet filter query parameters.
|
||||||
|
order: Enable order query parameters.
|
||||||
|
search_fields: Override searchable fields.
|
||||||
|
facet_fields: Override facet fields.
|
||||||
|
order_fields: Override order fields.
|
||||||
|
default_order_field: Default field to order by when ``order_by`` is absent.
|
||||||
|
default_order: Default sort direction.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
An async dependency function named ``{Model}OrderParams`` that resolves to an
|
An async dependency that resolves to a dict ready to be unpacked
|
||||||
``OrderByClause`` (or ``None``). Pass it to ``Depends()`` in your route.
|
into :meth:`paginate`.
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If no order fields are configured on this CRUD class and none are
|
|
||||||
provided via ``order_fields``.
|
|
||||||
InvalidOrderFieldError: When the request provides an unknown ``order_by`` value.
|
|
||||||
"""
|
"""
|
||||||
fields = order_fields if order_fields is not None else cls.order_fields
|
pagination_params = [
|
||||||
if not fields:
|
inspect.Parameter(
|
||||||
raise ValueError(
|
"pagination_type",
|
||||||
f"{cls.__name__} has no order_fields configured. "
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
"Pass order_fields= or set them on CrudFactory."
|
annotation=PaginationType,
|
||||||
)
|
default=Query(
|
||||||
field_map: dict[str, QueryableAttribute[Any]] = {f.key: f for f in fields}
|
default_pagination_type, description="Pagination strategy"
|
||||||
valid_keys = sorted(field_map.keys())
|
),
|
||||||
|
|
||||||
async def dependency(
|
|
||||||
order_by: str | None = Query(
|
|
||||||
None, description=f"Field to order by. Valid values: {valid_keys}"
|
|
||||||
),
|
),
|
||||||
order: Literal["asc", "desc"] = Query(
|
inspect.Parameter(
|
||||||
default_order, description="Sort direction"
|
"page",
|
||||||
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=int,
|
||||||
|
default=Query(
|
||||||
|
1, ge=1, description="Page number (1-indexed, offset only)"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
) -> OrderByClause | None:
|
inspect.Parameter(
|
||||||
if order_by is None:
|
"cursor",
|
||||||
if default_field is None:
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
return None
|
annotation=str | None,
|
||||||
field = default_field
|
default=Query(
|
||||||
elif order_by not in field_map:
|
None,
|
||||||
raise InvalidOrderFieldError(order_by, valid_keys)
|
description="Cursor token from a previous response (cursor only)",
|
||||||
else:
|
),
|
||||||
field = field_map[order_by]
|
),
|
||||||
return field.asc() if order == "asc" else field.desc()
|
inspect.Parameter(
|
||||||
|
"items_per_page",
|
||||||
dependency.__name__ = f"{cls.model.__name__}OrderParams"
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
return dependency
|
annotation=int,
|
||||||
|
default=_page_size_query(default_page_size, max_page_size),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return cls._build_paginate_params(
|
||||||
|
pagination_params=pagination_params,
|
||||||
|
pagination_fixed={"include_total": include_total},
|
||||||
|
dep_name=f"{cls.model.__name__}PaginateParams",
|
||||||
|
search=search,
|
||||||
|
filter=filter,
|
||||||
|
order=order,
|
||||||
|
search_fields=search_fields,
|
||||||
|
facet_fields=facet_fields,
|
||||||
|
order_fields=order_fields,
|
||||||
|
default_order_field=default_order_field,
|
||||||
|
default_order=default_order,
|
||||||
|
)
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1056,6 +1207,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
include_total: bool = True,
|
include_total: bool = True,
|
||||||
search: str | SearchConfig | None = None,
|
search: str | SearchConfig | None = None,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
search_column: str | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
@@ -1075,6 +1227,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
``pagination.total_count`` will be ``None``.
|
``pagination.total_count`` will be ``None``.
|
||||||
search: Search query string or SearchConfig object
|
search: Search query string or SearchConfig object
|
||||||
search_fields: Fields to search in (overrides class default)
|
search_fields: Fields to search in (overrides class default)
|
||||||
|
search_column: Restrict search to a single column key.
|
||||||
facet_fields: Columns to compute distinct values for (overrides class default)
|
facet_fields: Columns to compute distinct values for (overrides class default)
|
||||||
filter_by: Dict of {column_key: value} to filter by declared facet fields.
|
filter_by: Dict of {column_key: value} to filter by declared facet fields.
|
||||||
Keys must match the column.key of a facet field. Scalar → equality,
|
Keys must match the column.key of a facet field. Scalar → equality,
|
||||||
@@ -1097,6 +1250,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search,
|
search,
|
||||||
search_fields=search_fields,
|
search_fields=search_fields,
|
||||||
default_fields=cls.searchable_fields,
|
default_fields=cls.searchable_fields,
|
||||||
|
search_column=search_column,
|
||||||
)
|
)
|
||||||
filters.extend(search_filters)
|
filters.extend(search_filters)
|
||||||
search_joins.extend(new_search_joins)
|
search_joins.extend(new_search_joins)
|
||||||
@@ -1153,6 +1307,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filter_attributes = await cls._build_filter_attributes(
|
filter_attributes = await cls._build_filter_attributes(
|
||||||
session, facet_fields, filters, search_joins
|
session, facet_fields, filters, search_joins
|
||||||
)
|
)
|
||||||
|
search_columns = cls._resolve_search_columns(search_fields)
|
||||||
|
|
||||||
return OffsetPaginatedResponse(
|
return OffsetPaginatedResponse(
|
||||||
data=items,
|
data=items,
|
||||||
@@ -1163,6 +1318,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
has_more=has_more,
|
has_more=has_more,
|
||||||
),
|
),
|
||||||
filter_attributes=filter_attributes,
|
filter_attributes=filter_attributes,
|
||||||
|
search_columns=search_columns,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1179,6 +1335,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
items_per_page: int = 20,
|
items_per_page: int = 20,
|
||||||
search: str | SearchConfig | None = None,
|
search: str | SearchConfig | None = None,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
search_column: str | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
@@ -1199,6 +1356,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
items_per_page: Number of items per page (default 20).
|
items_per_page: Number of items per page (default 20).
|
||||||
search: Search query string or SearchConfig object.
|
search: Search query string or SearchConfig object.
|
||||||
search_fields: Fields to search in (overrides class default).
|
search_fields: Fields to search in (overrides class default).
|
||||||
|
search_column: Restrict search to a single column key.
|
||||||
facet_fields: Columns to compute distinct values for (overrides class default).
|
facet_fields: Columns to compute distinct values for (overrides class default).
|
||||||
filter_by: Dict of {column_key: value} to filter by declared facet fields.
|
filter_by: Dict of {column_key: value} to filter by declared facet fields.
|
||||||
Keys must match the column.key of a facet field. Scalar → equality,
|
Keys must match the column.key of a facet field. Scalar → equality,
|
||||||
@@ -1238,6 +1396,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search,
|
search,
|
||||||
search_fields=search_fields,
|
search_fields=search_fields,
|
||||||
default_fields=cls.searchable_fields,
|
default_fields=cls.searchable_fields,
|
||||||
|
search_column=search_column,
|
||||||
)
|
)
|
||||||
filters.extend(search_filters)
|
filters.extend(search_filters)
|
||||||
search_joins.extend(new_search_joins)
|
search_joins.extend(new_search_joins)
|
||||||
@@ -1308,6 +1467,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filter_attributes = await cls._build_filter_attributes(
|
filter_attributes = await cls._build_filter_attributes(
|
||||||
session, facet_fields, filters, search_joins
|
session, facet_fields, filters, search_joins
|
||||||
)
|
)
|
||||||
|
search_columns = cls._resolve_search_columns(search_fields)
|
||||||
|
|
||||||
return CursorPaginatedResponse(
|
return CursorPaginatedResponse(
|
||||||
data=items,
|
data=items,
|
||||||
@@ -1318,6 +1478,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
has_more=has_more,
|
has_more=has_more,
|
||||||
),
|
),
|
||||||
filter_attributes=filter_attributes,
|
filter_attributes=filter_attributes,
|
||||||
|
search_columns=search_columns,
|
||||||
)
|
)
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -1338,6 +1499,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
include_total: bool = ...,
|
include_total: bool = ...,
|
||||||
search: str | SearchConfig | None = ...,
|
search: str | SearchConfig | None = ...,
|
||||||
search_fields: Sequence[SearchFieldType] | None = ...,
|
search_fields: Sequence[SearchFieldType] | None = ...,
|
||||||
|
search_column: str | None = ...,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = ...,
|
filter_by: dict[str, Any] | BaseModel | None = ...,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
@@ -1361,6 +1523,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
include_total: bool = ...,
|
include_total: bool = ...,
|
||||||
search: str | SearchConfig | None = ...,
|
search: str | SearchConfig | None = ...,
|
||||||
search_fields: Sequence[SearchFieldType] | None = ...,
|
search_fields: Sequence[SearchFieldType] | None = ...,
|
||||||
|
search_column: str | None = ...,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = ...,
|
filter_by: dict[str, Any] | BaseModel | None = ...,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
@@ -1383,6 +1546,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
include_total: bool = True,
|
include_total: bool = True,
|
||||||
search: str | SearchConfig | None = None,
|
search: str | SearchConfig | None = None,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
search_column: str | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
@@ -1410,6 +1574,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
only applies when ``pagination_type`` is ``OFFSET``.
|
only applies when ``pagination_type`` is ``OFFSET``.
|
||||||
search: Search query string or :class:`.SearchConfig` object.
|
search: Search query string or :class:`.SearchConfig` object.
|
||||||
search_fields: Fields to search in (overrides class default).
|
search_fields: Fields to search in (overrides class default).
|
||||||
|
search_column: Restrict search to a single column key.
|
||||||
facet_fields: Columns to compute distinct values for (overrides
|
facet_fields: Columns to compute distinct values for (overrides
|
||||||
class default).
|
class default).
|
||||||
filter_by: Dict of ``{column_key: value}`` to filter by declared
|
filter_by: Dict of ``{column_key: value}`` to filter by declared
|
||||||
@@ -1438,6 +1603,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
items_per_page=items_per_page,
|
items_per_page=items_per_page,
|
||||||
search=search,
|
search=search,
|
||||||
search_fields=search_fields,
|
search_fields=search_fields,
|
||||||
|
search_column=search_column,
|
||||||
facet_fields=facet_fields,
|
facet_fields=facet_fields,
|
||||||
filter_by=filter_by,
|
filter_by=filter_by,
|
||||||
schema=schema,
|
schema=schema,
|
||||||
@@ -1457,6 +1623,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
include_total=include_total,
|
include_total=include_total,
|
||||||
search=search,
|
search=search,
|
||||||
search_fields=search_fields,
|
search_fields=search_fields,
|
||||||
|
search_column=search_column,
|
||||||
facet_fields=facet_fields,
|
facet_fields=facet_fields,
|
||||||
filter_by=filter_by,
|
filter_by=filter_by,
|
||||||
schema=schema,
|
schema=schema,
|
||||||
@@ -1488,7 +1655,7 @@ def CrudFactory(
|
|||||||
responses. Supports direct columns (``User.status``) and relationship tuples
|
responses. Supports direct columns (``User.status``) and relationship tuples
|
||||||
(``(User.role, Role.name)``). Can be overridden per call.
|
(``(User.role, Role.name)``). Can be overridden per call.
|
||||||
order_fields: Optional list of model attributes that callers are allowed to order by
|
order_fields: Optional list of model attributes that callers are allowed to order by
|
||||||
via ``order_params()``. Can be overridden per call.
|
via ``offset_paginate_params()``. Can be overridden per call.
|
||||||
m2m_fields: Optional mapping for many-to-many relationships.
|
m2m_fields: Optional mapping for many-to-many relationships.
|
||||||
Maps schema field names (containing lists of IDs) to
|
Maps schema field names (containing lists of IDs) to
|
||||||
SQLAlchemy relationship attributes.
|
SQLAlchemy relationship attributes.
|
||||||
|
|||||||
@@ -2,17 +2,32 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import functools
|
import functools
|
||||||
from collections import Counter
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
from sqlalchemy import String, and_, or_, select
|
from sqlalchemy import String, and_, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
|
from sqlalchemy.types import (
|
||||||
|
ARRAY,
|
||||||
|
Boolean,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Enum,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
Time,
|
||||||
|
Uuid,
|
||||||
|
)
|
||||||
|
|
||||||
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
from ..exceptions import (
|
||||||
|
InvalidFacetFilterError,
|
||||||
|
InvalidSearchColumnError,
|
||||||
|
NoSearchableFieldsError,
|
||||||
|
UnsupportedFacetTypeError,
|
||||||
|
)
|
||||||
from ..types import FacetFieldType, SearchFieldType
|
from ..types import FacetFieldType, SearchFieldType
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -82,6 +97,7 @@ def build_search_filters(
|
|||||||
search: str | SearchConfig,
|
search: str | SearchConfig,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
default_fields: Sequence[SearchFieldType] | None = None,
|
default_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
search_column: str | None = None,
|
||||||
) -> tuple[list["ColumnElement[bool]"], list[InstrumentedAttribute[Any]]]:
|
) -> tuple[list["ColumnElement[bool]"], list[InstrumentedAttribute[Any]]]:
|
||||||
"""Build SQLAlchemy filter conditions for search.
|
"""Build SQLAlchemy filter conditions for search.
|
||||||
|
|
||||||
@@ -90,6 +106,8 @@ def build_search_filters(
|
|||||||
search: Search string or SearchConfig
|
search: Search string or SearchConfig
|
||||||
search_fields: Fields specified per-call (takes priority)
|
search_fields: Fields specified per-call (takes priority)
|
||||||
default_fields: Default fields (from ClassVar)
|
default_fields: Default fields (from ClassVar)
|
||||||
|
search_column: Optional key to narrow search to a single field.
|
||||||
|
Must match one of the resolved search field keys.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (filter_conditions, joins_needed)
|
Tuple of (filter_conditions, joins_needed)
|
||||||
@@ -116,6 +134,14 @@ def build_search_filters(
|
|||||||
if not fields:
|
if not fields:
|
||||||
raise NoSearchableFieldsError(model)
|
raise NoSearchableFieldsError(model)
|
||||||
|
|
||||||
|
# Narrow to a single column when search_column is specified
|
||||||
|
if search_column is not None:
|
||||||
|
keys = search_field_keys(fields)
|
||||||
|
index = {k: f for k, f in zip(keys, fields)}
|
||||||
|
if search_column not in index:
|
||||||
|
raise InvalidSearchColumnError(search_column, sorted(index))
|
||||||
|
fields = [index[search_column]]
|
||||||
|
|
||||||
query = config.query.strip()
|
query = config.query.strip()
|
||||||
filters: list[ColumnElement[bool]] = []
|
filters: list[ColumnElement[bool]] = []
|
||||||
joins: list[InstrumentedAttribute[Any]] = []
|
joins: list[InstrumentedAttribute[Any]] = []
|
||||||
@@ -150,8 +176,13 @@ def build_search_filters(
|
|||||||
return filters, joins
|
return filters, joins
|
||||||
|
|
||||||
|
|
||||||
|
def search_field_keys(fields: Sequence[SearchFieldType]) -> list[str]:
|
||||||
|
"""Return a human-readable key for each search field."""
|
||||||
|
return facet_keys(fields)
|
||||||
|
|
||||||
|
|
||||||
def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
|
def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
|
||||||
"""Return a key for each facet field, disambiguating duplicate column keys.
|
"""Return a key for each facet field.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
facet_fields: Sequence of facet fields — either direct columns or
|
facet_fields: Sequence of facet fields — either direct columns or
|
||||||
@@ -160,22 +191,12 @@ def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
|
|||||||
Returns:
|
Returns:
|
||||||
A list of string keys, one per facet field, in the same order.
|
A list of string keys, one per facet field, in the same order.
|
||||||
"""
|
"""
|
||||||
raw: list[tuple[str, str | None]] = []
|
keys: list[str] = []
|
||||||
for field in facet_fields:
|
for field in facet_fields:
|
||||||
if isinstance(field, tuple):
|
if isinstance(field, tuple):
|
||||||
rel = field[-2]
|
keys.append("__".join(el.key for el in field))
|
||||||
column = field[-1]
|
|
||||||
raw.append((column.key, rel.key))
|
|
||||||
else:
|
else:
|
||||||
raw.append((field.key, None))
|
keys.append(field.key)
|
||||||
|
|
||||||
counts = Counter(col_key for col_key, _ in raw)
|
|
||||||
keys: list[str] = []
|
|
||||||
for col_key, rel_key in raw:
|
|
||||||
if counts[col_key] > 1 and rel_key is not None:
|
|
||||||
keys.append(f"{rel_key}__{col_key}")
|
|
||||||
else:
|
|
||||||
keys.append(col_key)
|
|
||||||
return keys
|
return keys
|
||||||
|
|
||||||
|
|
||||||
@@ -212,21 +233,37 @@ async def build_facets(
|
|||||||
rels = ()
|
rels = ()
|
||||||
column = field
|
column = field
|
||||||
|
|
||||||
q = select(column).select_from(model).distinct()
|
col_type = column.property.columns[0].type
|
||||||
|
is_array = isinstance(col_type, ARRAY)
|
||||||
|
|
||||||
# Apply base joins (already done on main query, but needed here independently)
|
if is_array:
|
||||||
|
unnested = func.unnest(column).label(column.key)
|
||||||
|
q = select(unnested).select_from(model).distinct()
|
||||||
|
else:
|
||||||
|
q = select(column).select_from(model).distinct()
|
||||||
|
|
||||||
|
# Apply base joins (deduplicated) — needed here independently
|
||||||
|
seen_joins: set[str] = set()
|
||||||
for rel in base_joins or []:
|
for rel in base_joins or []:
|
||||||
q = q.outerjoin(rel)
|
rel_key = str(rel)
|
||||||
|
if rel_key not in seen_joins:
|
||||||
|
seen_joins.add(rel_key)
|
||||||
|
q = q.outerjoin(rel)
|
||||||
|
|
||||||
# Add any extra joins required by this facet field that aren't already in base_joins
|
# Add any extra joins required by this facet field that aren't already applied
|
||||||
for rel in rels:
|
for rel in rels:
|
||||||
if str(rel) not in existing_join_keys:
|
rel_key = str(rel)
|
||||||
|
if rel_key not in existing_join_keys and rel_key not in seen_joins:
|
||||||
|
seen_joins.add(rel_key)
|
||||||
q = q.outerjoin(rel)
|
q = q.outerjoin(rel)
|
||||||
|
|
||||||
if base_filters:
|
if base_filters:
|
||||||
q = q.where(and_(*base_filters))
|
q = q.where(and_(*base_filters))
|
||||||
|
|
||||||
q = q.order_by(column)
|
if is_array:
|
||||||
|
q = q.order_by(unnested)
|
||||||
|
else:
|
||||||
|
q = q.order_by(column)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
values = [row[0] for row in result.all() if row[0] is not None]
|
values = [row[0] for row in result.all() if row[0] is not None]
|
||||||
return key, values
|
return key, values
|
||||||
@@ -237,6 +274,10 @@ async def build_facets(
|
|||||||
return dict(pairs)
|
return dict(pairs)
|
||||||
|
|
||||||
|
|
||||||
|
_EQUALITY_TYPES = (String, Integer, Numeric, Date, DateTime, Time, Enum, Uuid)
|
||||||
|
"""Column types that support equality / IN filtering in build_filter_by."""
|
||||||
|
|
||||||
|
|
||||||
def build_filter_by(
|
def build_filter_by(
|
||||||
filter_by: dict[str, Any],
|
filter_by: dict[str, Any],
|
||||||
facet_fields: Sequence[FacetFieldType],
|
facet_fields: Sequence[FacetFieldType],
|
||||||
@@ -282,9 +323,23 @@ def build_filter_by(
|
|||||||
joins.append(rel)
|
joins.append(rel)
|
||||||
added_join_keys.add(rel_key)
|
added_join_keys.add(rel_key)
|
||||||
|
|
||||||
if isinstance(value, list):
|
col_type = column.property.columns[0].type
|
||||||
filters.append(column.in_(value))
|
if isinstance(col_type, ARRAY):
|
||||||
|
if isinstance(value, list):
|
||||||
|
filters.append(column.overlap(value))
|
||||||
|
else:
|
||||||
|
filters.append(column.any(value))
|
||||||
|
elif isinstance(col_type, Boolean):
|
||||||
|
if isinstance(value, list):
|
||||||
|
filters.append(column.in_(value))
|
||||||
|
else:
|
||||||
|
filters.append(column.is_(value))
|
||||||
|
elif isinstance(col_type, _EQUALITY_TYPES):
|
||||||
|
if isinstance(value, list):
|
||||||
|
filters.append(column.in_(value))
|
||||||
|
else:
|
||||||
|
filters.append(column == value)
|
||||||
else:
|
else:
|
||||||
filters.append(column == value)
|
raise UnsupportedFacetTypeError(key, type(col_type).__name__)
|
||||||
|
|
||||||
return filters, joins
|
return filters, joins
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_SessionT = TypeVar("_SessionT", bound=AsyncSession)
|
||||||
|
|
||||||
|
|
||||||
def create_db_dependency(
|
def create_db_dependency(
|
||||||
session_maker: async_sessionmaker[AsyncSession],
|
session_maker: async_sessionmaker[_SessionT],
|
||||||
) -> Callable[[], AsyncGenerator[AsyncSession, None]]:
|
) -> Callable[[], AsyncGenerator[_SessionT, None]]:
|
||||||
"""Create a FastAPI dependency for database sessions.
|
"""Create a FastAPI dependency for database sessions.
|
||||||
|
|
||||||
Creates a dependency function that yields a session and auto-commits
|
Creates a dependency function that yields a session and auto-commits
|
||||||
@@ -54,7 +57,7 @@ def create_db_dependency(
|
|||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
async def get_db() -> AsyncGenerator[_SessionT, None]:
|
||||||
async with session_maker() as session:
|
async with session_maker() as session:
|
||||||
await session.connection()
|
await session.connection()
|
||||||
yield session
|
yield session
|
||||||
@@ -65,8 +68,8 @@ def create_db_dependency(
|
|||||||
|
|
||||||
|
|
||||||
def create_db_context(
|
def create_db_context(
|
||||||
session_maker: async_sessionmaker[AsyncSession],
|
session_maker: async_sessionmaker[_SessionT],
|
||||||
) -> Callable[[], AbstractAsyncContextManager[AsyncSession]]:
|
) -> Callable[[], AbstractAsyncContextManager[_SessionT]]:
|
||||||
"""Create a context manager for database sessions.
|
"""Create a context manager for database sessions.
|
||||||
|
|
||||||
Creates a context manager for use outside of FastAPI request handlers,
|
Creates a context manager for use outside of FastAPI request handlers,
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ from .exceptions import (
|
|||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
InvalidFacetFilterError,
|
InvalidFacetFilterError,
|
||||||
InvalidOrderFieldError,
|
InvalidOrderFieldError,
|
||||||
|
InvalidSearchColumnError,
|
||||||
NoSearchableFieldsError,
|
NoSearchableFieldsError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
|
UnsupportedFacetTypeError,
|
||||||
generate_error_responses,
|
generate_error_responses,
|
||||||
)
|
)
|
||||||
from .handler import init_exceptions_handlers
|
from .handler import init_exceptions_handlers
|
||||||
@@ -23,7 +25,9 @@ __all__ = [
|
|||||||
"init_exceptions_handlers",
|
"init_exceptions_handlers",
|
||||||
"InvalidFacetFilterError",
|
"InvalidFacetFilterError",
|
||||||
"InvalidOrderFieldError",
|
"InvalidOrderFieldError",
|
||||||
|
"InvalidSearchColumnError",
|
||||||
"NoSearchableFieldsError",
|
"NoSearchableFieldsError",
|
||||||
"NotFoundError",
|
"NotFoundError",
|
||||||
"UnauthorizedError",
|
"UnauthorizedError",
|
||||||
|
"UnsupportedFacetTypeError",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -144,6 +144,61 @@ class InvalidFacetFilterError(ApiException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedFacetTypeError(ApiException):
|
||||||
|
"""Raised when a facet field has a column type not supported by filter_by."""
|
||||||
|
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400,
|
||||||
|
msg="Unsupported Facet Type",
|
||||||
|
desc="The column type is not supported for facet filtering.",
|
||||||
|
err_code="FACET-TYPE-400",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, key: str, col_type: str) -> None:
|
||||||
|
"""Initialize the exception.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The facet field key.
|
||||||
|
col_type: The unsupported column type name.
|
||||||
|
"""
|
||||||
|
self.key = key
|
||||||
|
self.col_type = col_type
|
||||||
|
super().__init__(
|
||||||
|
desc=(
|
||||||
|
f"Facet field '{key}' has unsupported column type '{col_type}'. "
|
||||||
|
f"Supported types: String, Integer, Numeric, Boolean, "
|
||||||
|
f"Date, DateTime, Time, Enum, Uuid, ARRAY."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidSearchColumnError(ApiException):
|
||||||
|
"""Raised when search_column is not one of the configured searchable fields."""
|
||||||
|
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400,
|
||||||
|
msg="Invalid Search Column",
|
||||||
|
desc="The requested search column is not a configured searchable field.",
|
||||||
|
err_code="SEARCH-COL-400",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, column: str, valid_columns: list[str]) -> None:
|
||||||
|
"""Initialize the exception.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
column: The unknown search column provided by the caller.
|
||||||
|
valid_columns: List of valid search column keys.
|
||||||
|
"""
|
||||||
|
self.column = column
|
||||||
|
self.valid_columns = valid_columns
|
||||||
|
super().__init__(
|
||||||
|
desc=(
|
||||||
|
f"'{column}' is not a searchable column. "
|
||||||
|
f"Valid columns: {valid_columns}."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InvalidOrderFieldError(ApiException):
|
class InvalidOrderFieldError(ApiException):
|
||||||
"""Raised when order_by contains a field not in the allowed order fields."""
|
"""Raised when order_by contains a field not in the allowed order fields."""
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ def _format_validation_error(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
content=error_response.model_dump(),
|
content=error_response.model_dump(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from collections.abc import Callable, Sequence
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import inspect as sa_inspect
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
@@ -16,6 +18,147 @@ from .registry import FixtureRegistry, _normalize_contexts
|
|||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def _instance_to_dict(instance: DeclarativeBase) -> dict[str, Any]:
|
||||||
|
"""Extract column values from a model instance, skipping unset server-default columns."""
|
||||||
|
state = sa_inspect(instance)
|
||||||
|
state_dict = state.dict
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for prop in state.mapper.column_attrs:
|
||||||
|
if prop.key not in state_dict:
|
||||||
|
continue
|
||||||
|
val = state_dict[prop.key]
|
||||||
|
if val is None:
|
||||||
|
col = prop.columns[0]
|
||||||
|
|
||||||
|
if (
|
||||||
|
col.server_default is not None
|
||||||
|
or (col.default is not None and col.default.is_callable)
|
||||||
|
or col.autoincrement is True
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
result[prop.key] = val
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _group_by_type(
|
||||||
|
instances: list[DeclarativeBase],
|
||||||
|
) -> list[tuple[type[DeclarativeBase], list[DeclarativeBase]]]:
|
||||||
|
"""Group instances by their concrete model class, preserving insertion order."""
|
||||||
|
groups: dict[type[DeclarativeBase], list[DeclarativeBase]] = {}
|
||||||
|
for instance in instances:
|
||||||
|
groups.setdefault(type(instance), []).append(instance)
|
||||||
|
return list(groups.items())
|
||||||
|
|
||||||
|
|
||||||
|
def _group_by_column_set(
|
||||||
|
dicts: list[dict[str, Any]],
|
||||||
|
instances: list[DeclarativeBase],
|
||||||
|
) -> list[tuple[list[dict[str, Any]], list[DeclarativeBase]]]:
|
||||||
|
"""Group (dict, instance) pairs by their dict key sets."""
|
||||||
|
groups: dict[
|
||||||
|
frozenset[str], tuple[list[dict[str, Any]], list[DeclarativeBase]]
|
||||||
|
] = {}
|
||||||
|
for d, inst in zip(dicts, instances):
|
||||||
|
key = frozenset(d)
|
||||||
|
if key not in groups:
|
||||||
|
groups[key] = ([], [])
|
||||||
|
groups[key][0].append(d)
|
||||||
|
groups[key][1].append(inst)
|
||||||
|
return list(groups.values())
|
||||||
|
|
||||||
|
|
||||||
|
async def _batch_insert(
|
||||||
|
session: AsyncSession,
|
||||||
|
model_cls: type[DeclarativeBase],
|
||||||
|
instances: list[DeclarativeBase],
|
||||||
|
) -> None:
|
||||||
|
"""INSERT all instances — raises on conflict (no duplicate handling)."""
|
||||||
|
dicts = [_instance_to_dict(i) for i in instances]
|
||||||
|
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
||||||
|
await session.execute(pg_insert(model_cls).values(group_dicts))
|
||||||
|
|
||||||
|
|
||||||
|
async def _batch_merge(
|
||||||
|
session: AsyncSession,
|
||||||
|
model_cls: type[DeclarativeBase],
|
||||||
|
instances: list[DeclarativeBase],
|
||||||
|
) -> None:
|
||||||
|
"""UPSERT: insert new rows, update existing ones with the provided values."""
|
||||||
|
mapper = model_cls.__mapper__
|
||||||
|
pk_names = [col.name for col in mapper.primary_key]
|
||||||
|
pk_names_set = set(pk_names)
|
||||||
|
non_pk_cols = [
|
||||||
|
prop.key
|
||||||
|
for prop in mapper.column_attrs
|
||||||
|
if not any(col.name in pk_names_set for col in prop.columns)
|
||||||
|
]
|
||||||
|
|
||||||
|
dicts = [_instance_to_dict(i) for i in instances]
|
||||||
|
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
||||||
|
stmt = pg_insert(model_cls).values(group_dicts)
|
||||||
|
|
||||||
|
inserted_keys = set(group_dicts[0])
|
||||||
|
update_cols = [col for col in non_pk_cols if col in inserted_keys]
|
||||||
|
|
||||||
|
if update_cols:
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
index_elements=pk_names,
|
||||||
|
set_={col: stmt.excluded[col] for col in update_cols},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = stmt.on_conflict_do_nothing(index_elements=pk_names)
|
||||||
|
|
||||||
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
async def _batch_skip_existing(
|
||||||
|
session: AsyncSession,
|
||||||
|
model_cls: type[DeclarativeBase],
|
||||||
|
instances: list[DeclarativeBase],
|
||||||
|
) -> list[DeclarativeBase]:
|
||||||
|
"""INSERT only rows that do not already exist; return the inserted ones."""
|
||||||
|
mapper = model_cls.__mapper__
|
||||||
|
pk_names = [col.name for col in mapper.primary_key]
|
||||||
|
|
||||||
|
no_pk: list[DeclarativeBase] = []
|
||||||
|
with_pk_pairs: list[tuple[DeclarativeBase, Any]] = []
|
||||||
|
for inst in instances:
|
||||||
|
pk = _get_primary_key(inst)
|
||||||
|
if pk is None:
|
||||||
|
no_pk.append(inst)
|
||||||
|
else:
|
||||||
|
with_pk_pairs.append((inst, pk))
|
||||||
|
|
||||||
|
loaded: list[DeclarativeBase] = list(no_pk)
|
||||||
|
if no_pk:
|
||||||
|
no_pk_dicts = [_instance_to_dict(i) for i in no_pk]
|
||||||
|
for group_dicts, _ in _group_by_column_set(no_pk_dicts, no_pk):
|
||||||
|
await session.execute(pg_insert(model_cls).values(group_dicts))
|
||||||
|
|
||||||
|
if with_pk_pairs:
|
||||||
|
with_pk = [i for i, _ in with_pk_pairs]
|
||||||
|
with_pk_dicts = [_instance_to_dict(i) for i in with_pk]
|
||||||
|
for group_dicts, group_insts in _group_by_column_set(with_pk_dicts, with_pk):
|
||||||
|
stmt = (
|
||||||
|
pg_insert(model_cls)
|
||||||
|
.values(group_dicts)
|
||||||
|
.on_conflict_do_nothing(index_elements=pk_names)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt.returning(*mapper.primary_key))
|
||||||
|
inserted_pks = {
|
||||||
|
row[0] if len(pk_names) == 1 else tuple(row) for row in result
|
||||||
|
}
|
||||||
|
loaded.extend(
|
||||||
|
inst
|
||||||
|
for inst, pk in zip(
|
||||||
|
group_insts, [_get_primary_key(i) for i in group_insts]
|
||||||
|
)
|
||||||
|
if pk in inserted_pks
|
||||||
|
)
|
||||||
|
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
async def _load_ordered(
|
async def _load_ordered(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
registry: FixtureRegistry,
|
registry: FixtureRegistry,
|
||||||
@@ -23,12 +166,7 @@ async def _load_ordered(
|
|||||||
strategy: LoadStrategy,
|
strategy: LoadStrategy,
|
||||||
contexts: tuple[str, ...] | None = None,
|
contexts: tuple[str, ...] | None = None,
|
||||||
) -> dict[str, list[DeclarativeBase]]:
|
) -> dict[str, list[DeclarativeBase]]:
|
||||||
"""Load fixtures in order.
|
"""Load fixtures in order using batch Core INSERT statements."""
|
||||||
|
|
||||||
When *contexts* is provided only variants whose context set intersects with
|
|
||||||
*contexts* are called for each name; their instances are concatenated.
|
|
||||||
When *contexts* is ``None`` all variants of each name are loaded.
|
|
||||||
"""
|
|
||||||
results: dict[str, list[DeclarativeBase]] = {}
|
results: dict[str, list[DeclarativeBase]] = {}
|
||||||
|
|
||||||
for name in ordered_names:
|
for name in ordered_names:
|
||||||
@@ -38,10 +176,6 @@ async def _load_ordered(
|
|||||||
else registry.get_variants(name)
|
else registry.get_variants(name)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cross-context dependency fallback: if we're loading by context but
|
|
||||||
# no variant matches (e.g. a "base"-only fixture required by a
|
|
||||||
# "testing" fixture), load all available variants so the dependency
|
|
||||||
# is satisfied.
|
|
||||||
if contexts is not None and not variants:
|
if contexts is not None and not variants:
|
||||||
variants = registry.get_variants(name)
|
variants = registry.get_variants(name)
|
||||||
|
|
||||||
@@ -59,25 +193,17 @@ async def _load_ordered(
|
|||||||
loaded: list[DeclarativeBase] = []
|
loaded: list[DeclarativeBase] = []
|
||||||
|
|
||||||
async with get_transaction(session):
|
async with get_transaction(session):
|
||||||
for instance in instances:
|
for model_cls, group in _group_by_type(instances):
|
||||||
if strategy == LoadStrategy.INSERT:
|
match strategy:
|
||||||
session.add(instance)
|
case LoadStrategy.INSERT:
|
||||||
loaded.append(instance)
|
await _batch_insert(session, model_cls, group)
|
||||||
|
loaded.extend(group)
|
||||||
elif strategy == LoadStrategy.MERGE:
|
case LoadStrategy.MERGE:
|
||||||
merged = await session.merge(instance)
|
await _batch_merge(session, model_cls, group)
|
||||||
loaded.append(merged)
|
loaded.extend(group)
|
||||||
|
case LoadStrategy.SKIP_EXISTING:
|
||||||
else: # LoadStrategy.SKIP_EXISTING
|
inserted = await _batch_skip_existing(session, model_cls, group)
|
||||||
pk = _get_primary_key(instance)
|
loaded.extend(inserted)
|
||||||
if pk is not None:
|
|
||||||
existing = await session.get(type(instance), pk)
|
|
||||||
if existing is None:
|
|
||||||
session.add(instance)
|
|
||||||
loaded.append(instance)
|
|
||||||
else:
|
|
||||||
session.add(instance)
|
|
||||||
loaded.append(instance)
|
|
||||||
|
|
||||||
results[name] = loaded
|
results[name] = loaded
|
||||||
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
||||||
@@ -155,10 +281,6 @@ async def load_fixtures_by_context(
|
|||||||
) -> dict[str, list[DeclarativeBase]]:
|
) -> dict[str, list[DeclarativeBase]]:
|
||||||
"""Load all fixtures for specific contexts.
|
"""Load all fixtures for specific contexts.
|
||||||
|
|
||||||
For each fixture name, only the variants whose context set intersects with
|
|
||||||
*contexts* are loaded. When a name has variants in multiple of the
|
|
||||||
requested contexts, their instances are merged before being inserted.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
registry: Fixture registry
|
registry: Fixture registry
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Prometheus metrics endpoint for FastAPI applications."""
|
"""Prometheus metrics endpoint for FastAPI applications."""
|
||||||
|
|
||||||
import asyncio
|
import inspect
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -55,10 +55,10 @@ def init_metrics(
|
|||||||
|
|
||||||
# Partition collectors and cache env check at startup — both are stable for the app lifetime.
|
# Partition collectors and cache env check at startup — both are stable for the app lifetime.
|
||||||
async_collectors = [
|
async_collectors = [
|
||||||
c for c in registry.get_collectors() if asyncio.iscoroutinefunction(c.func)
|
c for c in registry.get_collectors() if inspect.iscoroutinefunction(c.func)
|
||||||
]
|
]
|
||||||
sync_collectors = [
|
sync_collectors = [
|
||||||
c for c in registry.get_collectors() if not asyncio.iscoroutinefunction(c.func)
|
c for c in registry.get_collectors() if not inspect.iscoroutinefunction(c.func)
|
||||||
]
|
]
|
||||||
multiprocess_mode = _is_multiprocess()
|
multiprocess_mode = _is_multiprocess()
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ from .columns import (
|
|||||||
UUIDv7Mixin,
|
UUIDv7Mixin,
|
||||||
UpdatedAtMixin,
|
UpdatedAtMixin,
|
||||||
)
|
)
|
||||||
from .watched import ModelEvent, WatchedFieldsMixin, watch
|
from .watched import EventSession, ModelEvent, listens_for
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"EventSession",
|
||||||
"ModelEvent",
|
"ModelEvent",
|
||||||
"UUIDMixin",
|
"UUIDMixin",
|
||||||
"UUIDv7Mixin",
|
"UUIDv7Mixin",
|
||||||
"CreatedAtMixin",
|
"CreatedAtMixin",
|
||||||
"UpdatedAtMixin",
|
"UpdatedAtMixin",
|
||||||
"TimestampMixin",
|
"TimestampMixin",
|
||||||
"WatchedFieldsMixin",
|
"listens_for",
|
||||||
"watch",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,14 +6,6 @@ from datetime import datetime
|
|||||||
from sqlalchemy import DateTime, Uuid, text
|
from sqlalchemy import DateTime, Uuid, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"UUIDMixin",
|
|
||||||
"UUIDv7Mixin",
|
|
||||||
"CreatedAtMixin",
|
|
||||||
"UpdatedAtMixin",
|
|
||||||
"TimestampMixin",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class UUIDMixin:
|
class UUIDMixin:
|
||||||
"""Mixin that adds a UUID primary key auto-generated by the database."""
|
"""Mixin that adds a UUID primary key auto-generated by the database."""
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
"""Field-change monitoring via SQLAlchemy session events."""
|
"""Field-change monitoring via SQLAlchemy session events."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import inspect
|
import inspect
|
||||||
import weakref
|
from collections.abc import Callable
|
||||||
from collections.abc import Awaitable
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, TypeVar
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import event
|
from sqlalchemy import event
|
||||||
from sqlalchemy import inspect as sa_inspect
|
from sqlalchemy import inspect as sa_inspect
|
||||||
@@ -14,65 +12,114 @@ from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_v
|
|||||||
|
|
||||||
from ..logger import get_logger
|
from ..logger import get_logger
|
||||||
|
|
||||||
__all__ = ["ModelEvent", "WatchedFieldsMixin", "watch"]
|
|
||||||
|
|
||||||
_logger = get_logger()
|
_logger = get_logger()
|
||||||
_T = TypeVar("_T")
|
|
||||||
_CALLBACK_ERROR_MSG = "WatchedFieldsMixin callback raised an unhandled exception"
|
|
||||||
_WATCHED_FIELDS: weakref.WeakKeyDictionary[type, list[str]] = (
|
|
||||||
weakref.WeakKeyDictionary()
|
|
||||||
)
|
|
||||||
_SESSION_PENDING_NEW = "_ft_pending_new"
|
|
||||||
_SESSION_CREATES = "_ft_creates"
|
|
||||||
_SESSION_DELETES = "_ft_deletes"
|
|
||||||
_SESSION_UPDATES = "_ft_updates"
|
|
||||||
_SESSION_SAVEPOINT_DEPTH = "_ft_sp_depth"
|
|
||||||
|
|
||||||
|
|
||||||
class ModelEvent(str, Enum):
|
class ModelEvent(str, Enum):
|
||||||
"""Event types emitted by :class:`WatchedFieldsMixin`."""
|
"""Event types dispatched by :class:`EventSession`."""
|
||||||
|
|
||||||
CREATE = "create"
|
CREATE = "create"
|
||||||
DELETE = "delete"
|
DELETE = "delete"
|
||||||
UPDATE = "update"
|
UPDATE = "update"
|
||||||
|
|
||||||
|
|
||||||
def watch(*fields: str) -> Any:
|
_CALLBACK_ERROR_MSG = "Event callback raised an unhandled exception"
|
||||||
"""Class decorator to filter which fields trigger ``on_update``.
|
_SESSION_CREATES = "_ft_creates"
|
||||||
|
_SESSION_DELETES = "_ft_deletes"
|
||||||
|
_SESSION_UPDATES = "_ft_updates"
|
||||||
|
_DEFERRED_STRATEGY_KEY = (("deferred", True), ("instrument", True))
|
||||||
|
_EVENT_HANDLERS: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
|
||||||
|
_WATCHED_MODELS: set[type] = set()
|
||||||
|
_WATCHED_CACHE: dict[type, bool] = {}
|
||||||
|
_HANDLER_CACHE: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_caches() -> None:
|
||||||
|
"""Clear lookup caches after handler registration."""
|
||||||
|
_WATCHED_CACHE.clear()
|
||||||
|
_HANDLER_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def listens_for(
|
||||||
|
model_class: type,
|
||||||
|
event_types: list[ModelEvent] | None = None,
|
||||||
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||||
|
"""Register a callback for one or more model lifecycle events.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
*fields: One or more field names to watch. At least one name is required.
|
model_class: The SQLAlchemy model class to listen on.
|
||||||
|
event_types: List of :class:`ModelEvent` values to listen for.
|
||||||
Raises:
|
Defaults to all event types.
|
||||||
ValueError: If called with no field names.
|
|
||||||
"""
|
"""
|
||||||
if not fields:
|
evs = event_types if event_types is not None else list(ModelEvent)
|
||||||
raise ValueError("@watch requires at least one field name.")
|
|
||||||
|
|
||||||
def decorator(cls: type[_T]) -> type[_T]:
|
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
_WATCHED_FIELDS[cls] = list(fields)
|
for ev in evs:
|
||||||
return cls
|
_EVENT_HANDLERS.setdefault((model_class, ev), []).append(fn)
|
||||||
|
_WATCHED_MODELS.add(model_class)
|
||||||
|
_invalidate_caches()
|
||||||
|
return fn
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _is_watched(obj: Any) -> bool:
|
||||||
|
"""Return True if *obj*'s type (or any ancestor) has registered handlers."""
|
||||||
|
cls = type(obj)
|
||||||
|
try:
|
||||||
|
return _WATCHED_CACHE[cls]
|
||||||
|
except KeyError:
|
||||||
|
result = any(klass in _WATCHED_MODELS for klass in cls.__mro__)
|
||||||
|
_WATCHED_CACHE[cls] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_handlers(cls: type, ev: ModelEvent) -> list[Callable[..., Any]]:
|
||||||
|
"""Return registered handlers for *cls* and *ev*, walking the MRO."""
|
||||||
|
key = (cls, ev)
|
||||||
|
try:
|
||||||
|
return _HANDLER_CACHE[key]
|
||||||
|
except KeyError:
|
||||||
|
handlers: list[Callable[..., Any]] = []
|
||||||
|
for klass in cls.__mro__:
|
||||||
|
handlers.extend(_EVENT_HANDLERS.get((klass, ev), []))
|
||||||
|
_HANDLER_CACHE[key] = handlers
|
||||||
|
return handlers
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_column_attrs(obj: Any) -> dict[str, Any]:
|
def _snapshot_column_attrs(obj: Any) -> dict[str, Any]:
|
||||||
"""Read currently-loaded column values into a plain dict."""
|
"""Read currently-loaded column values into a plain dict."""
|
||||||
state = sa_inspect(obj) # InstanceState
|
state = sa_inspect(obj) # InstanceState
|
||||||
state_dict = state.dict
|
state_dict = state.dict
|
||||||
return {
|
snapshot: dict[str, Any] = {}
|
||||||
prop.key: state_dict[prop.key]
|
for prop in state.mapper.column_attrs:
|
||||||
for prop in state.mapper.column_attrs
|
if prop.key in state_dict:
|
||||||
if prop.key in state_dict
|
snapshot[prop.key] = state_dict[prop.key]
|
||||||
}
|
elif ( # pragma: no cover
|
||||||
|
not state.expired
|
||||||
|
and prop.strategy_key != _DEFERRED_STRATEGY_KEY
|
||||||
|
and all(
|
||||||
|
col.nullable
|
||||||
|
and col.server_default is None
|
||||||
|
and col.server_onupdate is None
|
||||||
|
for col in prop.columns
|
||||||
|
)
|
||||||
|
):
|
||||||
|
snapshot[prop.key] = None
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
def _get_watched_fields(cls: type) -> list[str] | None:
|
def _get_watched_fields(cls: type) -> tuple[str, ...] | None:
|
||||||
"""Return the watched fields for *cls*, walking the MRO to inherit from parents."""
|
"""Return the watched fields for *cls*."""
|
||||||
for klass in cls.__mro__:
|
fields = getattr(cls, "__watched_fields__", None)
|
||||||
if klass in _WATCHED_FIELDS:
|
if fields is not None and (
|
||||||
return _WATCHED_FIELDS[klass]
|
not isinstance(fields, tuple) or not all(isinstance(f, str) for f in fields)
|
||||||
return None
|
):
|
||||||
|
raise TypeError(
|
||||||
|
f"{cls.__name__}.__watched_fields__ must be a tuple[str, ...], "
|
||||||
|
f"got {type(fields).__name__}"
|
||||||
|
)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
def _upsert_changes(
|
def _upsert_changes(
|
||||||
@@ -93,50 +140,32 @@ 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 reference. Attributes will be refreshed after commit.
|
||||||
# Values are read in _after_flush_postexec once RETURNING has been processed.
|
|
||||||
for obj in session.new:
|
for obj in session.new:
|
||||||
if isinstance(obj, WatchedFieldsMixin):
|
if _is_watched(obj):
|
||||||
session.info.setdefault(_SESSION_PENDING_NEW, []).append(obj)
|
session.info.setdefault(_SESSION_CREATES, []).append(obj)
|
||||||
|
|
||||||
# Deleted objects: capture before they leave the identity map.
|
# Deleted objects: snapshot now while attributes are still loaded.
|
||||||
for obj in session.deleted:
|
for obj in session.deleted:
|
||||||
if isinstance(obj, WatchedFieldsMixin):
|
if _is_watched(obj):
|
||||||
session.info.setdefault(_SESSION_DELETES, []).append(obj)
|
snapshot = _snapshot_column_attrs(obj)
|
||||||
|
session.info.setdefault(_SESSION_DELETES, []).append((obj, snapshot))
|
||||||
|
|
||||||
# Dirty objects: read old/new from SQLAlchemy attribute history.
|
# Dirty objects: read old/new from SQLAlchemy attribute history.
|
||||||
for obj in session.dirty:
|
for obj in session.dirty:
|
||||||
if not isinstance(obj, WatchedFieldsMixin):
|
if not _is_watched(obj):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# None = not in dict = watch all fields; list = specific fields only
|
|
||||||
watched = _get_watched_fields(type(obj))
|
watched = _get_watched_fields(type(obj))
|
||||||
changes: dict[str, dict[str, Any]] = {}
|
changes: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
inst_attrs = sa_inspect(obj).attrs
|
||||||
attrs = (
|
attrs = (
|
||||||
# Specific fields
|
((field, inst_attrs[field]) for field in watched)
|
||||||
((field, sa_inspect(obj).attrs[field]) for field in watched)
|
|
||||||
if watched is not None
|
if watched is not None
|
||||||
# All mapped fields
|
else ((s.key, s) for s in inst_attrs)
|
||||||
else ((s.key, s) for s in sa_inspect(obj).attrs)
|
|
||||||
)
|
)
|
||||||
for field, attr_state in attrs:
|
for field, attr_state in attrs:
|
||||||
history = attr_state.history
|
history = attr_state.history
|
||||||
@@ -154,116 +183,108 @@ def _after_flush(session: Any, flush_context: Any) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@event.listens_for(AsyncSession.sync_session_class, "after_flush_postexec")
|
|
||||||
def _after_flush_postexec(session: Any, flush_context: Any) -> None:
|
|
||||||
# New objects are now persistent and RETURNING values have been applied,
|
|
||||||
# so server defaults (id, created_at, …) are available via getattr.
|
|
||||||
pending_new: list[Any] = session.info.pop(_SESSION_PENDING_NEW, [])
|
|
||||||
if not pending_new:
|
|
||||||
return
|
|
||||||
session.info.setdefault(_SESSION_CREATES, []).extend(pending_new)
|
|
||||||
|
|
||||||
|
|
||||||
@event.listens_for(AsyncSession.sync_session_class, "after_rollback")
|
@event.listens_for(AsyncSession.sync_session_class, "after_rollback")
|
||||||
def _after_rollback(session: Any) -> None:
|
def _after_rollback(session: Any) -> None:
|
||||||
session.info.pop(_SESSION_PENDING_NEW, None)
|
if session.in_transaction():
|
||||||
|
return
|
||||||
session.info.pop(_SESSION_CREATES, None)
|
session.info.pop(_SESSION_CREATES, None)
|
||||||
session.info.pop(_SESSION_DELETES, None)
|
session.info.pop(_SESSION_DELETES, None)
|
||||||
session.info.pop(_SESSION_UPDATES, None)
|
session.info.pop(_SESSION_UPDATES, None)
|
||||||
|
|
||||||
|
|
||||||
def _task_error_handler(task: asyncio.Task[Any]) -> None:
|
async def _invoke_callback(
|
||||||
if not task.cancelled() and (exc := task.exception()):
|
fn: Callable[..., Any],
|
||||||
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
obj: Any,
|
||||||
|
event_type: ModelEvent,
|
||||||
|
changes: dict[str, dict[str, Any]] | None,
|
||||||
def _schedule_with_snapshot(
|
|
||||||
loop: asyncio.AbstractEventLoop, obj: Any, fn: Any, *args: Any
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Snapshot *obj*'s column attrs now (before expire_on_commit wipes them),
|
"""Call *fn* and await the result if it is awaitable."""
|
||||||
then schedule a coroutine that restores the snapshot and calls *fn*.
|
result = fn(obj, event_type, changes)
|
||||||
"""
|
if inspect.isawaitable(result):
|
||||||
snapshot = _snapshot_column_attrs(obj)
|
await result
|
||||||
|
|
||||||
async def _run(
|
|
||||||
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")
|
class EventSession(AsyncSession):
|
||||||
def _after_commit(session: Any) -> None:
|
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
||||||
if session.info.get(_SESSION_SAVEPOINT_DEPTH, 0) > 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
creates: list[Any] = session.info.pop(_SESSION_CREATES, [])
|
async def commit(self) -> None: # noqa: C901
|
||||||
deletes: list[Any] = session.info.pop(_SESSION_DELETES, [])
|
await super().commit()
|
||||||
field_changes: dict[int, tuple[Any, dict[str, dict[str, Any]]]] = session.info.pop(
|
|
||||||
_SESSION_UPDATES, {}
|
|
||||||
)
|
|
||||||
|
|
||||||
if creates and deletes:
|
creates: list[Any] = self.info.pop(_SESSION_CREATES, [])
|
||||||
transient_ids = {id(o) for o in creates} & {id(o) for o in deletes}
|
deletes: list[tuple[Any, dict[str, Any]]] = self.info.pop(_SESSION_DELETES, [])
|
||||||
if transient_ids:
|
field_changes: dict[int, tuple[Any, dict[str, dict[str, Any]]]] = self.info.pop(
|
||||||
creates = [o for o in creates if id(o) not in transient_ids]
|
_SESSION_UPDATES, {}
|
||||||
deletes = [o for o in deletes if id(o) not in transient_ids]
|
)
|
||||||
|
|
||||||
|
if not creates and not deletes and not field_changes:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Suppress transient objects (created + deleted in same transaction).
|
||||||
|
if creates and deletes:
|
||||||
|
created_ids = {id(o) for o in creates}
|
||||||
|
deleted_ids = {id(o) for o, _ in deletes}
|
||||||
|
transient_ids = created_ids & deleted_ids
|
||||||
|
if transient_ids:
|
||||||
|
creates = [o for o in creates if id(o) not in transient_ids]
|
||||||
|
deletes = [(o, s) for o, s 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# Suppress updates for deleted objects (row is gone, refresh would fail).
|
||||||
|
if deletes and field_changes:
|
||||||
|
deleted_ids = {id(o) for o, _ in deletes}
|
||||||
field_changes = {
|
field_changes = {
|
||||||
k: v for k, v in field_changes.items() if k not in transient_ids
|
k: v for k, v in field_changes.items() if k not in deleted_ids
|
||||||
}
|
}
|
||||||
|
|
||||||
if not creates and not deletes and not field_changes:
|
# Suppress updates for newly created objects (CREATE-only semantics).
|
||||||
return
|
if creates and field_changes:
|
||||||
|
create_ids = {id(o) for o in creates}
|
||||||
|
field_changes = {
|
||||||
|
k: v for k, v in field_changes.items() if k not in create_ids
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
# Dispatch CREATE callbacks.
|
||||||
loop = asyncio.get_running_loop()
|
for obj in creates:
|
||||||
except RuntimeError:
|
try:
|
||||||
return
|
state = sa_inspect(obj, raiseerr=False)
|
||||||
|
if (
|
||||||
|
state is None or state.detached or state.transient
|
||||||
|
): # pragma: no cover
|
||||||
|
continue
|
||||||
|
await self.refresh(obj)
|
||||||
|
for handler in _get_handlers(type(obj), ModelEvent.CREATE):
|
||||||
|
await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||||
|
|
||||||
for obj in creates:
|
# Dispatch DELETE callbacks (restore snapshot; row is gone).
|
||||||
_schedule_with_snapshot(loop, obj, obj.on_create)
|
for obj, snapshot in deletes:
|
||||||
|
try:
|
||||||
|
for key, value in snapshot.items():
|
||||||
|
_sa_set_committed_value(obj, key, value)
|
||||||
|
for handler in _get_handlers(type(obj), ModelEvent.DELETE):
|
||||||
|
await _invoke_callback(handler, obj, ModelEvent.DELETE, None)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||||
|
|
||||||
for obj in deletes:
|
# Dispatch UPDATE callbacks.
|
||||||
_schedule_with_snapshot(loop, obj, obj.on_delete)
|
for obj, changes in field_changes.values():
|
||||||
|
try:
|
||||||
|
state = sa_inspect(obj, raiseerr=False)
|
||||||
|
if (
|
||||||
|
state is None or state.detached or state.transient
|
||||||
|
): # pragma: no cover
|
||||||
|
continue
|
||||||
|
await self.refresh(obj)
|
||||||
|
for handler in _get_handlers(type(obj), ModelEvent.UPDATE):
|
||||||
|
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||||
|
|
||||||
for obj, changes in field_changes.values():
|
async def rollback(self) -> None:
|
||||||
_schedule_with_snapshot(loop, obj, obj.on_update, changes)
|
await super().rollback()
|
||||||
|
self.info.pop(_SESSION_CREATES, None)
|
||||||
|
self.info.pop(_SESSION_DELETES, None)
|
||||||
class WatchedFieldsMixin:
|
self.info.pop(_SESSION_UPDATES, None)
|
||||||
"""Mixin that enables lifecycle callbacks for SQLAlchemy models."""
|
|
||||||
|
|
||||||
def on_event(
|
|
||||||
self, event: ModelEvent, changes: dict[str, dict[str, Any]] | None = None
|
|
||||||
) -> Awaitable[None] | None:
|
|
||||||
"""Catch-all callback fired for every lifecycle event.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: The event type (:attr:`ModelEvent.CREATE`, :attr:`ModelEvent.DELETE`,
|
|
||||||
or :attr:`ModelEvent.UPDATE`).
|
|
||||||
changes: Field changes for :attr:`ModelEvent.UPDATE`, ``None`` otherwise.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def on_create(self) -> Awaitable[None] | None:
|
|
||||||
"""Called after INSERT commit."""
|
|
||||||
return self.on_event(ModelEvent.CREATE)
|
|
||||||
|
|
||||||
def on_delete(self) -> Awaitable[None] | None:
|
|
||||||
"""Called after DELETE commit."""
|
|
||||||
return self.on_event(ModelEvent.DELETE)
|
|
||||||
|
|
||||||
def on_update(self, changes: dict[str, dict[str, Any]]) -> Awaitable[None] | None:
|
|
||||||
"""Called after UPDATE commit when watched fields change."""
|
|
||||||
return self.on_event(ModelEvent.UPDATE, changes=changes)
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Pytest helper utilities for FastAPI testing."""
|
"""Pytest helper utilities for FastAPI testing."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import warnings
|
|
||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import AsyncGenerator, Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -16,28 +15,8 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from ..db import cleanup_tables as _cleanup_tables
|
from ..db import cleanup_tables, create_database
|
||||||
from ..db import create_database
|
from ..models.watched import EventSession
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_tables(
|
|
||||||
session: AsyncSession,
|
|
||||||
base: type[DeclarativeBase],
|
|
||||||
) -> None:
|
|
||||||
"""Truncate all tables for fast between-test cleanup.
|
|
||||||
|
|
||||||
.. deprecated::
|
|
||||||
Import ``cleanup_tables`` from ``fastapi_toolsets.db`` instead.
|
|
||||||
This re-export will be removed in v3.0.0.
|
|
||||||
"""
|
|
||||||
warnings.warn(
|
|
||||||
"Importing cleanup_tables from fastapi_toolsets.pytest is deprecated "
|
|
||||||
"and will be removed in v3.0.0. "
|
|
||||||
"Use 'from fastapi_toolsets.db import cleanup_tables' instead.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2,
|
|
||||||
)
|
|
||||||
await _cleanup_tables(session=session, base=base)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_xdist_worker(default_test_db: str) -> str:
|
def _get_xdist_worker(default_test_db: str) -> str:
|
||||||
@@ -265,12 +244,14 @@ async def create_db_session(
|
|||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(base.metadata.create_all)
|
await conn.run_sync(base.metadata.create_all)
|
||||||
|
|
||||||
session_maker = async_sessionmaker(engine, expire_on_commit=expire_on_commit)
|
session_maker = async_sessionmaker(
|
||||||
|
engine, expire_on_commit=expire_on_commit, class_=EventSession
|
||||||
|
)
|
||||||
async with session_maker() as session:
|
async with session_maker() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
if cleanup:
|
if cleanup:
|
||||||
await _cleanup_tables(session=session, base=base)
|
await cleanup_tables(session=session, base=base)
|
||||||
|
|
||||||
if drop_tables:
|
if drop_tables:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ class PaginatedResponse(BaseResponse, Generic[DataT]):
|
|||||||
pagination: OffsetPagination | CursorPagination
|
pagination: OffsetPagination | CursorPagination
|
||||||
pagination_type: PaginationType | None = None
|
pagination_type: PaginationType | None = None
|
||||||
filter_attributes: dict[str, list[Any]] | None = None
|
filter_attributes: dict[str, list[Any]] | None = None
|
||||||
|
search_columns: list[str] | None = None
|
||||||
|
|
||||||
_discriminated_union_cache: ClassVar[dict[Any, Any]] = {}
|
_discriminated_union_cache: ClassVar[dict[Any, Any]] = {}
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ from sqlalchemy import (
|
|||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
|
JSON,
|
||||||
Numeric,
|
Numeric,
|
||||||
String,
|
String,
|
||||||
Table,
|
Table,
|
||||||
Uuid,
|
Uuid,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ class User(Base):
|
|||||||
username: Mapped[str] = mapped_column(String(50), unique=True)
|
username: Mapped[str] = mapped_column(String(50), unique=True)
|
||||||
email: Mapped[str] = mapped_column(String(100), unique=True)
|
email: Mapped[str] = mapped_column(String(100), unique=True)
|
||||||
is_active: Mapped[bool] = mapped_column(default=True)
|
is_active: Mapped[bool] = mapped_column(default=True)
|
||||||
|
notes: Mapped[str | None]
|
||||||
role_id: Mapped[uuid.UUID | None] = mapped_column(
|
role_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
ForeignKey("roles.id"), nullable=True
|
ForeignKey("roles.id"), nullable=True
|
||||||
)
|
)
|
||||||
@@ -136,6 +139,17 @@ class Post(Base):
|
|||||||
tags: Mapped[list[Tag]] = relationship(secondary=post_tags)
|
tags: Mapped[list[Tag]] = relationship(secondary=post_tags)
|
||||||
|
|
||||||
|
|
||||||
|
class Article(Base):
|
||||||
|
"""Test article model with ARRAY and JSON columns."""
|
||||||
|
|
||||||
|
__tablename__ = "articles"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
title: Mapped[str] = mapped_column(String(200))
|
||||||
|
labels: Mapped[list[str]] = mapped_column(ARRAY(String))
|
||||||
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class RoleCreate(BaseModel):
|
class RoleCreate(BaseModel):
|
||||||
"""Schema for creating a role."""
|
"""Schema for creating a role."""
|
||||||
|
|
||||||
@@ -270,6 +284,23 @@ class ProductCreate(BaseModel):
|
|||||||
price: decimal.Decimal
|
price: decimal.Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleCreate(BaseModel):
|
||||||
|
"""Schema for creating an article."""
|
||||||
|
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
title: str
|
||||||
|
labels: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleRead(PydanticBase):
|
||||||
|
"""Schema for reading an article."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
title: str
|
||||||
|
labels: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
ArticleCrud = CrudFactory(Article)
|
||||||
RoleCrud = CrudFactory(Role)
|
RoleCrud = CrudFactory(Role)
|
||||||
RoleCursorCrud = CrudFactory(Role, cursor_column=Role.id)
|
RoleCursorCrud = CrudFactory(Role, cursor_column=Role.id)
|
||||||
IntRoleCursorCrud = CrudFactory(IntRole, cursor_column=IntRole.id)
|
IntRoleCursorCrud = CrudFactory(IntRole, cursor_column=IntRole.id)
|
||||||
|
|||||||
@@ -211,6 +211,38 @@ class TestResolveLoadOptions:
|
|||||||
assert crud._resolve_load_options([]) == []
|
assert crud._resolve_load_options([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveSearchColumns:
|
||||||
|
"""Tests for _resolve_search_columns logic."""
|
||||||
|
|
||||||
|
def test_returns_none_when_no_searchable_fields(self):
|
||||||
|
"""Returns None when cls.searchable_fields is None and no search_fields passed."""
|
||||||
|
|
||||||
|
class AbstractCrud(AsyncCrud[User]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert AbstractCrud._resolve_search_columns(None) is None
|
||||||
|
|
||||||
|
def test_returns_none_when_empty_search_fields_passed(self):
|
||||||
|
"""Returns None when an empty list is passed explicitly."""
|
||||||
|
crud = CrudFactory(User)
|
||||||
|
assert crud._resolve_search_columns([]) is None
|
||||||
|
|
||||||
|
def test_returns_keys_from_class_searchable_fields(self):
|
||||||
|
"""Returns column keys from cls.searchable_fields when no override passed."""
|
||||||
|
crud = CrudFactory(User, searchable_fields=[User.username])
|
||||||
|
result = crud._resolve_search_columns(None)
|
||||||
|
assert result is not None
|
||||||
|
assert "username" in result
|
||||||
|
|
||||||
|
def test_search_fields_override_takes_priority(self):
|
||||||
|
"""Explicit search_fields override cls.searchable_fields."""
|
||||||
|
crud = CrudFactory(User, searchable_fields=[User.username])
|
||||||
|
result = crud._resolve_search_columns([User.email])
|
||||||
|
assert result is not None
|
||||||
|
assert "email" in result
|
||||||
|
assert "username" not in result
|
||||||
|
|
||||||
|
|
||||||
class TestDefaultLoadOptionsIntegration:
|
class TestDefaultLoadOptionsIntegration:
|
||||||
"""Integration tests for default_load_options with real DB queries."""
|
"""Integration tests for default_load_options with real DB queries."""
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -97,7 +97,7 @@ class TestAppSessionDep:
|
|||||||
gen = get_db()
|
gen = get_db()
|
||||||
session = await gen.__anext__()
|
session = await gen.__anext__()
|
||||||
assert isinstance(session, AsyncSession)
|
assert isinstance(session, AsyncSession)
|
||||||
await session.close()
|
await gen.aclose()
|
||||||
|
|
||||||
|
|
||||||
class TestOffsetPagination:
|
class TestOffsetPagination:
|
||||||
@@ -182,8 +182,7 @@ class TestOffsetPagination:
|
|||||||
body = resp.json()
|
body = resp.json()
|
||||||
fa = body["filter_attributes"]
|
fa = body["filter_attributes"]
|
||||||
assert set(fa["status"]) == {"draft", "published"}
|
assert set(fa["status"]) == {"draft", "published"}
|
||||||
# "name" is unique across all facet fields — no prefix needed
|
assert set(fa["category__name"]) == {"backend", "python"}
|
||||||
assert set(fa["name"]) == {"backend", "python"}
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_filter_attributes_scoped_to_filter(
|
async def test_filter_attributes_scoped_to_filter(
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ from fastapi_toolsets.fixtures import (
|
|||||||
load_fixtures,
|
load_fixtures,
|
||||||
load_fixtures_by_context,
|
load_fixtures_by_context,
|
||||||
)
|
)
|
||||||
|
from fastapi_toolsets.fixtures.utils import _get_primary_key, _instance_to_dict
|
||||||
|
|
||||||
from fastapi_toolsets.fixtures.utils import _get_primary_key
|
from .conftest import IntRole, Permission, Role, RoleCreate, RoleCrud, User, UserCrud
|
||||||
|
|
||||||
from .conftest import IntRole, Permission, Role, RoleCrud, User, UserCrud
|
|
||||||
|
|
||||||
|
|
||||||
class AppContext(str, Enum):
|
class AppContext(str, Enum):
|
||||||
@@ -621,6 +620,52 @@ class TestLoadFixtures:
|
|||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_merge_does_not_overwrite_omitted_nullable_columns(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""MERGE must not clear nullable columns that the fixture didn't set.
|
||||||
|
|
||||||
|
When a fixture omits a nullable column (e.g. role_id or notes), a re-merge
|
||||||
|
must leave the existing DB value untouched — not overwrite it with NULL.
|
||||||
|
"""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
uid = uuid.uuid4()
|
||||||
|
|
||||||
|
# First load: user has role_id and notes set
|
||||||
|
@registry.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid,
|
||||||
|
username="alice",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
notes="original",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(db_session, registry, "users", strategy=LoadStrategy.MERGE)
|
||||||
|
|
||||||
|
# Second load: fixture omits role_id and notes
|
||||||
|
registry2 = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry2.register
|
||||||
|
def users(): # noqa: F811
|
||||||
|
return [User(id=uid, username="alice-updated", email="a@test.com")]
|
||||||
|
|
||||||
|
await load_fixtures(db_session, registry2, "users", strategy=LoadStrategy.MERGE)
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
row = (
|
||||||
|
await db_session.execute(select(User).where(User.id == uid))
|
||||||
|
).scalar_one()
|
||||||
|
assert row.username == "alice-updated" # updated column changed
|
||||||
|
assert row.role_id == admin.id # omitted → preserved
|
||||||
|
assert row.notes == "original" # omitted → preserved
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_load_with_skip_existing_strategy(self, db_session: AsyncSession):
|
async def test_load_with_skip_existing_strategy(self, db_session: AsyncSession):
|
||||||
"""Load fixtures with SKIP_EXISTING strategy."""
|
"""Load fixtures with SKIP_EXISTING strategy."""
|
||||||
@@ -945,3 +990,486 @@ class TestRegistryGetVariants:
|
|||||||
registry = FixtureRegistry()
|
registry = FixtureRegistry()
|
||||||
with pytest.raises(KeyError, match="not found"):
|
with pytest.raises(KeyError, match="not found"):
|
||||||
registry.get_variants("no_such_fixture")
|
registry.get_variants("no_such_fixture")
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstanceToDict:
|
||||||
|
"""Unit tests for the _instance_to_dict helper."""
|
||||||
|
|
||||||
|
def test_explicit_values_included(self):
|
||||||
|
"""All explicitly set column values appear in the result."""
|
||||||
|
role_id = uuid.uuid4()
|
||||||
|
instance = Role(id=role_id, name="admin")
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert d["id"] == role_id
|
||||||
|
assert d["name"] == "admin"
|
||||||
|
|
||||||
|
def test_callable_default_none_excluded(self):
|
||||||
|
"""A column whose value is None but has a callable Python-side default
|
||||||
|
(e.g. ``default=uuid.uuid4``) is excluded so the DB generates it."""
|
||||||
|
instance = Role(id=None, name="admin")
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert "id" not in d
|
||||||
|
assert d["name"] == "admin"
|
||||||
|
|
||||||
|
def test_autoincrement_none_excluded(self):
|
||||||
|
"""A column whose value is None but has autoincrement=True is excluded
|
||||||
|
so the DB generates the value via its sequence."""
|
||||||
|
instance = IntRole(id=None, name="admin")
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert "id" not in d
|
||||||
|
assert d["name"] == "admin"
|
||||||
|
|
||||||
|
def test_nullable_none_included(self):
|
||||||
|
"""None on a nullable column with no default is kept (explicit NULL)."""
|
||||||
|
instance = User(id=uuid.uuid4(), username="u", email="e@e.com", role_id=None)
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert "role_id" in d
|
||||||
|
assert d["role_id"] is None
|
||||||
|
|
||||||
|
def test_nullable_str_no_default_omitted_not_in_dict(self):
|
||||||
|
"""Mapped[str | None] with no default, not provided in constructor, is absent from dict."""
|
||||||
|
instance = User(id=uuid.uuid4(), username="u", email="e@e.com")
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert "notes" not in d
|
||||||
|
|
||||||
|
def test_nullable_str_no_default_explicit_none_included(self):
|
||||||
|
"""Mapped[str | None] with no default, explicitly set to None, is included as NULL."""
|
||||||
|
instance = User(id=uuid.uuid4(), username="u", email="e@e.com", notes=None)
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert "notes" in d
|
||||||
|
assert d["notes"] is None
|
||||||
|
|
||||||
|
def test_nullable_str_no_default_with_value_included(self):
|
||||||
|
"""Mapped[str | None] with no default and a value set is included normally."""
|
||||||
|
instance = User(id=uuid.uuid4(), username="u", email="e@e.com", notes="hello")
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert d["notes"] == "hello"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_nullable_str_no_default_insert_roundtrip(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""Fixture loading works for models with Mapped[str | None] (no default).
|
||||||
|
|
||||||
|
Both the omitted-value (→ NULL) and explicit-None paths must insert without error.
|
||||||
|
"""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
uid_a = uuid.uuid4()
|
||||||
|
uid_b = uuid.uuid4()
|
||||||
|
uid_c = uuid.uuid4()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid_a, username="no_notes", email="a@test.com"
|
||||||
|
), # notes omitted
|
||||||
|
User(
|
||||||
|
id=uid_b, username="null_notes", email="b@test.com", notes=None
|
||||||
|
), # explicit None
|
||||||
|
User(
|
||||||
|
id=uid_c, username="has_notes", email="c@test.com", notes="hi"
|
||||||
|
), # value set
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await load_fixtures(db_session, registry, "users")
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
(await db_session.execute(select(User).order_by(User.username)))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_username = {r.username: r for r in rows}
|
||||||
|
|
||||||
|
assert by_username["no_notes"].notes is None
|
||||||
|
assert by_username["null_notes"].notes is None
|
||||||
|
assert by_username["has_notes"].notes == "hi"
|
||||||
|
assert len(result["users"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchMergeNonPkColumns:
|
||||||
|
"""Batch MERGE on a model with no non-PK columns (PK-only table)."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_merge_pk_only_model(self, db_session: AsyncSession):
|
||||||
|
"""MERGE strategy on a PK-only model uses on_conflict_do_nothing."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def permissions():
|
||||||
|
return [
|
||||||
|
Permission(subject="post", action="read"),
|
||||||
|
Permission(subject="post", action="write"),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await load_fixtures(
|
||||||
|
db_session, registry, "permissions", strategy=LoadStrategy.MERGE
|
||||||
|
)
|
||||||
|
assert len(result["permissions"]) == 2
|
||||||
|
|
||||||
|
# Run again — conflicts are silently ignored.
|
||||||
|
result2 = await load_fixtures(
|
||||||
|
db_session, registry, "permissions", strategy=LoadStrategy.MERGE
|
||||||
|
)
|
||||||
|
assert len(result2["permissions"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchNullableColumnEdgeCases:
|
||||||
|
"""Deep tests for nullable column handling during batch import."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_insert_batch_mixed_nullable_fk(self, db_session: AsyncSession):
|
||||||
|
"""INSERT batch where some rows set a nullable FK and others don't.
|
||||||
|
|
||||||
|
After normalization the omitted role_id becomes None. For INSERT this
|
||||||
|
is acceptable — both rows should insert successfully with the correct
|
||||||
|
values (one with FK, one with NULL).
|
||||||
|
"""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
uid1 = uuid.uuid4()
|
||||||
|
uid2 = uuid.uuid4()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1, username="with_role", email="a@test.com", role_id=admin.id
|
||||||
|
),
|
||||||
|
User(id=uid2, username="no_role", email="b@test.com"),
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(db_session, registry, "users", strategy=LoadStrategy.INSERT)
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
r.username: r
|
||||||
|
for r in (await db_session.execute(select(User))).scalars().all()
|
||||||
|
}
|
||||||
|
assert rows["with_role"].role_id == admin.id
|
||||||
|
assert rows["no_role"].role_id is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_insert_batch_mixed_nullable_notes(self, db_session: AsyncSession):
|
||||||
|
"""INSERT batch where some rows have notes and others don't.
|
||||||
|
|
||||||
|
Ensures normalization doesn't break the insert and that each row gets
|
||||||
|
the intended value.
|
||||||
|
"""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
uid1 = uuid.uuid4()
|
||||||
|
uid2 = uuid.uuid4()
|
||||||
|
uid3 = uuid.uuid4()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="has_notes",
|
||||||
|
email="a@test.com",
|
||||||
|
notes="important",
|
||||||
|
),
|
||||||
|
User(id=uid2, username="no_notes", email="b@test.com"),
|
||||||
|
User(id=uid3, username="null_notes", email="c@test.com", notes=None),
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(db_session, registry, "users", strategy=LoadStrategy.INSERT)
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
r.username: r
|
||||||
|
for r in (await db_session.execute(select(User))).scalars().all()
|
||||||
|
}
|
||||||
|
assert rows["has_notes"].notes == "important"
|
||||||
|
assert rows["no_notes"].notes is None
|
||||||
|
assert rows["null_notes"].notes is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_merge_batch_mixed_nullable_does_not_overwrite(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""MERGE batch where one row sets a nullable column and another omits it.
|
||||||
|
|
||||||
|
If both rows already exist in DB, the row that omits the column must
|
||||||
|
NOT have its existing value overwritten with NULL.
|
||||||
|
|
||||||
|
This is the core normalization bug: _normalize_rows fills missing keys
|
||||||
|
with None, and then MERGE's SET clause includes that column for ALL rows.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
uid1 = uuid.uuid4()
|
||||||
|
uid2 = uuid.uuid4()
|
||||||
|
|
||||||
|
# Pre-populate: both users have role_id and notes
|
||||||
|
registry_initial = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry_initial.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="alice",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
notes="alice notes",
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id=uid2,
|
||||||
|
username="bob",
|
||||||
|
email="b@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
notes="bob notes",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(
|
||||||
|
db_session, registry_initial, "users", strategy=LoadStrategy.INSERT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-merge: alice updates notes, bob omits notes entirely
|
||||||
|
registry_merge = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry_merge.register
|
||||||
|
def users(): # noqa: F811
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="alice",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
notes="updated",
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id=uid2,
|
||||||
|
username="bob",
|
||||||
|
email="b@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
), # notes omitted
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(
|
||||||
|
db_session, registry_merge, "users", strategy=LoadStrategy.MERGE
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
r.username: r
|
||||||
|
for r in (await db_session.execute(select(User))).scalars().all()
|
||||||
|
}
|
||||||
|
assert rows["alice"].notes == "updated"
|
||||||
|
# Bob's notes must be preserved, NOT overwritten with NULL
|
||||||
|
assert rows["bob"].notes == "bob notes"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_merge_batch_mixed_nullable_fk_preserves_existing(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""MERGE batch where one row sets role_id and another omits it.
|
||||||
|
|
||||||
|
The row that omits role_id must keep its existing DB value.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
editor = await RoleCrud.create(db_session, RoleCreate(name="editor"))
|
||||||
|
uid1 = uuid.uuid4()
|
||||||
|
uid2 = uuid.uuid4()
|
||||||
|
|
||||||
|
# Pre-populate
|
||||||
|
registry_initial = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry_initial.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="alice",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id=uid2,
|
||||||
|
username="bob",
|
||||||
|
email="b@test.com",
|
||||||
|
role_id=editor.id,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(
|
||||||
|
db_session, registry_initial, "users", strategy=LoadStrategy.INSERT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-merge: alice changes role, bob omits role_id
|
||||||
|
registry_merge = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry_merge.register
|
||||||
|
def users(): # noqa: F811
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="alice",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=editor.id,
|
||||||
|
),
|
||||||
|
User(id=uid2, username="bob", email="b@test.com"), # role_id omitted
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(
|
||||||
|
db_session, registry_merge, "users", strategy=LoadStrategy.MERGE
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
r.username: r
|
||||||
|
for r in (await db_session.execute(select(User))).scalars().all()
|
||||||
|
}
|
||||||
|
assert rows["alice"].role_id == editor.id # updated
|
||||||
|
assert rows["bob"].role_id == editor.id # must be preserved, NOT NULL
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_insert_batch_mixed_pk_presence(self, db_session: AsyncSession):
|
||||||
|
"""INSERT batch where some rows have explicit PK and others rely on
|
||||||
|
the callable default (uuid.uuid4).
|
||||||
|
|
||||||
|
Normalization adds the PK key with None to rows that omitted it,
|
||||||
|
which can cause NOT NULL violations on the PK column.
|
||||||
|
"""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
explicit_id = uuid.uuid4()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def roles():
|
||||||
|
return [
|
||||||
|
Role(id=explicit_id, name="admin"),
|
||||||
|
Role(name="user"), # PK omitted, relies on default=uuid.uuid4
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.INSERT)
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = (await db_session.execute(select(Role))).scalars().all()
|
||||||
|
assert len(rows) == 2
|
||||||
|
names = {r.name for r in rows}
|
||||||
|
assert names == {"admin", "user"}
|
||||||
|
# The "admin" row must have the explicit ID
|
||||||
|
admin = next(r for r in rows if r.name == "admin")
|
||||||
|
assert admin.id == explicit_id
|
||||||
|
# The "user" row must have a generated UUID (not None)
|
||||||
|
user = next(r for r in rows if r.name == "user")
|
||||||
|
assert user.id is not None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_skip_existing_batch_mixed_nullable(self, db_session: AsyncSession):
|
||||||
|
"""SKIP_EXISTING with mixed nullable columns inserts correctly.
|
||||||
|
|
||||||
|
Only new rows are inserted; existing rows are untouched regardless of
|
||||||
|
which columns the fixture provides.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
uid1 = uuid.uuid4()
|
||||||
|
uid2 = uuid.uuid4()
|
||||||
|
|
||||||
|
# Pre-populate uid1 with notes
|
||||||
|
registry_initial = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry_initial.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="alice",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
notes="keep me",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(
|
||||||
|
db_session, registry_initial, "users", strategy=LoadStrategy.INSERT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load again with SKIP_EXISTING: uid1 already exists, uid2 is new
|
||||||
|
registry_skip = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry_skip.register
|
||||||
|
def users(): # noqa: F811
|
||||||
|
return [
|
||||||
|
User(id=uid1, username="alice-updated", email="a@test.com"), # exists
|
||||||
|
User(
|
||||||
|
id=uid2,
|
||||||
|
username="bob",
|
||||||
|
email="b@test.com",
|
||||||
|
notes="new user",
|
||||||
|
), # new
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await load_fixtures(
|
||||||
|
db_session, registry_skip, "users", strategy=LoadStrategy.SKIP_EXISTING
|
||||||
|
)
|
||||||
|
assert len(result["users"]) == 1 # only bob inserted
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
r.username: r
|
||||||
|
for r in (await db_session.execute(select(User))).scalars().all()
|
||||||
|
}
|
||||||
|
# alice untouched
|
||||||
|
assert rows["alice"].role_id == admin.id
|
||||||
|
assert rows["alice"].notes == "keep me"
|
||||||
|
# bob inserted correctly
|
||||||
|
assert rows["bob"].notes == "new user"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_insert_batch_every_row_different_nullable_columns(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""Each row in the batch sets a different combination of nullable columns.
|
||||||
|
|
||||||
|
Tests that normalization produces valid SQL for all rows.
|
||||||
|
"""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
uid1 = uuid.uuid4()
|
||||||
|
uid2 = uuid.uuid4()
|
||||||
|
uid3 = uuid.uuid4()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="all_set",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
notes="full",
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id=uid2, username="only_role", email="b@test.com", role_id=admin.id
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id=uid3, username="only_notes", email="c@test.com", notes="partial"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(db_session, registry, "users", strategy=LoadStrategy.INSERT)
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
r.username: r
|
||||||
|
for r in (await db_session.execute(select(User))).scalars().all()
|
||||||
|
}
|
||||||
|
assert rows["all_set"].role_id == admin.id
|
||||||
|
assert rows["all_set"].notes == "full"
|
||||||
|
assert rows["only_role"].role_id == admin.id
|
||||||
|
assert rows["only_role"].notes is None
|
||||||
|
assert rows["only_notes"].role_id is None
|
||||||
|
assert rows["only_notes"].notes == "partial"
|
||||||
|
|||||||
1213
tests/test_models.py
1213
tests/test_models.py
File diff suppressed because it is too large
Load Diff
@@ -374,19 +374,6 @@ class TestCreateDbSession:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TestDeprecatedCleanupTables:
|
|
||||||
"""Tests for the deprecated cleanup_tables re-export in fastapi_toolsets.pytest."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_emits_deprecation_warning(self):
|
|
||||||
"""cleanup_tables imported from fastapi_toolsets.pytest emits DeprecationWarning."""
|
|
||||||
from fastapi_toolsets.pytest.utils import cleanup_tables
|
|
||||||
|
|
||||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
|
||||||
with pytest.warns(DeprecationWarning, match="fastapi_toolsets.db"):
|
|
||||||
await cleanup_tables(session, Base)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetXdistWorker:
|
class TestGetXdistWorker:
|
||||||
"""Tests for _get_xdist_worker helper."""
|
"""Tests for _get_xdist_worker helper."""
|
||||||
|
|
||||||
|
|||||||
159
uv.lock
generated
159
uv.lock
generated
@@ -235,7 +235,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.135.1"
|
version = "0.135.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -244,14 +244,14 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "2.4.3"
|
version = "3.0.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
@@ -285,6 +285,7 @@ dev = [
|
|||||||
{ name = "coverage" },
|
{ name = "coverage" },
|
||||||
{ name = "fastapi-toolsets", extra = ["all"] },
|
{ name = "fastapi-toolsets", extra = ["all"] },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "mike" },
|
||||||
{ name = "mkdocstrings-python" },
|
{ name = "mkdocstrings-python" },
|
||||||
{ name = "prek" },
|
{ name = "prek" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -296,6 +297,7 @@ dev = [
|
|||||||
{ name = "zensical" },
|
{ name = "zensical" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
|
{ name = "mike" },
|
||||||
{ name = "mkdocstrings-python" },
|
{ name = "mkdocstrings-python" },
|
||||||
{ name = "zensical" },
|
{ name = "zensical" },
|
||||||
]
|
]
|
||||||
@@ -328,6 +330,7 @@ dev = [
|
|||||||
{ name = "coverage", specifier = ">=7.0.0" },
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
{ name = "fastapi-toolsets", extras = ["all"] },
|
{ name = "fastapi-toolsets", extras = ["all"] },
|
||||||
{ name = "httpx", specifier = ">=0.25.0" },
|
{ name = "httpx", specifier = ">=0.25.0" },
|
||||||
|
{ name = "mike", git = "https://github.com/squidfunk/mike.git?tag=2.2.0%2Bzensical-0.1.0" },
|
||||||
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
||||||
{ name = "prek", specifier = ">=0.3.8" },
|
{ name = "prek", specifier = ">=0.3.8" },
|
||||||
{ name = "pytest", specifier = ">=8.0.0" },
|
{ name = "pytest", specifier = ">=8.0.0" },
|
||||||
@@ -336,11 +339,12 @@ dev = [
|
|||||||
{ name = "pytest-xdist", specifier = ">=3.0.0" },
|
{ name = "pytest-xdist", specifier = ">=3.0.0" },
|
||||||
{ name = "ruff", specifier = ">=0.1.0" },
|
{ name = "ruff", specifier = ">=0.1.0" },
|
||||||
{ name = "ty", specifier = ">=0.0.1a0" },
|
{ name = "ty", specifier = ">=0.0.1a0" },
|
||||||
{ name = "zensical", specifier = ">=0.0.23" },
|
{ name = "zensical", specifier = ">=0.0.30" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
|
{ name = "mike", git = "https://github.com/squidfunk/mike.git?tag=2.2.0%2Bzensical-0.1.0" },
|
||||||
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
||||||
{ name = "zensical", specifier = ">=0.0.23" },
|
{ name = "zensical", specifier = ">=0.0.30" },
|
||||||
]
|
]
|
||||||
tests = [
|
tests = [
|
||||||
{ name = "coverage", specifier = ">=7.0.0" },
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
@@ -604,6 +608,17 @@ 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.2.0+zensical.0.1.0"
|
||||||
|
source = { git = "https://github.com/squidfunk/mike.git?tag=2.2.0%2Bzensical-0.1.0#0f62791256ebeba60d20d2f1d8fe6ec3b7d1e2b3" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "pyparsing" },
|
||||||
|
{ name = "verspec" },
|
||||||
|
{ name = "zensical" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mkdocs"
|
name = "mkdocs"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
@@ -870,24 +885,33 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.19.2"
|
version = "2.20.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pymdown-extensions"
|
name = "pymdown-extensions"
|
||||||
version = "10.21"
|
version = "10.21.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "markdown" },
|
{ name = "markdown" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
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]]
|
||||||
@@ -1040,27 +1064,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.7"
|
version = "0.15.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1204,26 +1228,26 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "ty"
|
||||||
version = "0.0.25"
|
version = "0.0.27"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/12/bf/3c3147c7237277b0e8a911ff89de7183408be96b31fb42b38edb666d287f/ty-0.0.25.tar.gz", hash = "sha256:8ae3891be17dfb6acab51a2df3a8f8f6c551eb60ea674c10946dc92aae8d4401", size = 5375500, upload-time = "2026-03-24T22:32:34.608Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f4/de/e5cf1f151cf52fe1189e42d03d90909d7d1354fdc0c1847cbb63a0baa3da/ty-0.0.27.tar.gz", hash = "sha256:d7a8de3421d92420b40c94fe7e7d4816037560621903964dd035cf9bd0204a73", size = 5424130, upload-time = "2026-03-31T19:07:20.806Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/a4/6c289cbd1474285223124a4ffb55c078dbe9ae1d925d0b6a948643c7f115/ty-0.0.25-py3-none-linux_armv6l.whl", hash = "sha256:26d6d5aede5d54fb055779460f896d9c1473c6fb996716bd11cb90f027d8fee7", size = 10452747, upload-time = "2026-03-24T22:32:32.662Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/20/2a9ea661758bd67f2bfd54ce9daacb5a26c56c5f8b49fbd9a43b365a8a7d/ty-0.0.27-py3-none-linux_armv6l.whl", hash = "sha256:eb14456b8611c9e8287aa9b633f4d2a0d9f3082a31796969e0b50bdda8930281", size = 10571211, upload-time = "2026-03-31T19:07:23.28Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/13/74cb9de356b9ceb3f281ab048f8c4ac2207122161b0ac0066886ce129abe/ty-0.0.25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aedcfbc7b6b96dbc55b0da78fa02bd049373ff3d8a827f613dadd8bd17d10758", size = 10271349, upload-time = "2026-03-24T22:32:13.041Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/b2/8887a51f705d075ddbe78ae7f0d4755ef48d0a90235f67aee289e9cee950/ty-0.0.27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:02e662184703db7586118df611cf24a000d35dae38d950053d1dd7b6736fd2c4", size = 10427576, upload-time = "2026-03-31T19:07:15.499Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/93/ffc5a20cc9e14fa9b32b0c54884864bede30d144ce2ae013805bce0c86d0/ty-0.0.25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0a8fb3c1e28f73618941811e2568dca195178a1a6314651d4ee97086a4497253", size = 9730308, upload-time = "2026-03-24T22:32:19.24Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/c3/79d88163f508fb709ce19bc0b0a66c7c64b53d372d4caa56172c3d9b3ae8/ty-0.0.27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be5fc2899441f7f8f7ef40f9ffd006075a5ff6b06c44e8d2aa30e1b900c12f51", size = 9870359, upload-time = "2026-03-31T19:07:36.852Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/78/52e05ef32a5f172fce70633a4e19d8e04364271a4322ae12382c7344b0de/ty-0.0.25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814870b7f347b5d0276304cddb98a0958f08de183bf159abc920ebe321247ad4", size = 10247664, upload-time = "2026-03-24T22:32:08.669Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/4d/ed1b0db0e1e46b5ed4976bbfe0d1825faf003b4e3774ef28c785ed73e4bb/ty-0.0.27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30231e652b14742a76b64755e54bf0cb1cd4c128bcaf625222e0ca92a2094887", size = 10380488, upload-time = "2026-03-31T19:07:31.268Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/64/0d0a47ed0aa1d634c666c2cc15d3b0af4b95d0fd3dbb796032bd493f3433/ty-0.0.25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:781150e23825dc110cd5e1f50ca3d61664f7a5db5b4a55d5dbf7d3b1e246b917", size = 10261961, upload-time = "2026-03-24T22:32:43.935Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/f2/20372f6d510b01570028433064880adec2f8abe68bf0c4603be61a560bef/ty-0.0.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a119b1168f64261b3205a37e40b5b6c4aac8fd58e4587988f4e4b22c3c79847", size = 10390248, upload-time = "2026-03-31T19:07:28.345Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/ba/4666b96f0499465efb97c244554107c541d74a1add393e62276b3de9b54f/ty-0.0.25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc81ff2a0143911321251dc81d1c259fa5cdc56d043019a733c845d55409e2a", size = 10746076, upload-time = "2026-03-24T22:32:26.37Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/4b/46b31a7311306be1a560f7f20fdc37b5bf718787f60626cd265d9b637554/ty-0.0.27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e38f4e187b6975d2cbebf0f1eb1221f8f64f6e509bad14d7bb2a91afc97e4956", size = 10878479, upload-time = "2026-03-31T19:07:39.393Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/ed/aa958ccbcd85cc206600e48fbf0a1c27aef54b4b90112d9a73f69ed0c739/ty-0.0.25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f03c5c5b5c10355ea030cbe3cd93b2e759b9492c66688288ea03a68086069f2e", size = 11287331, upload-time = "2026-03-24T22:32:21.607Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/ba/5231a2a1fb1cebe053a25de8fded95e1a30a1e77d3628a9e58487297bafc/ty-0.0.27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a07b1a8fbb23844f6d22091275430d9ac617175f34aa99159b268193de210389", size = 11461232, upload-time = "2026-03-31T19:07:02.518Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/e4/f4a004e1952e6042f5bfeeb7d09cffb379270ef009d9f8568471863e86e6/ty-0.0.25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fc1ef49cd6262eb9223ccf6e258ac899aaa53e7dc2151ba65a2c9fa248dfa75", size = 11028804, upload-time = "2026-03-24T22:32:39.088Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/37/558abab3e1f6670493524f61280b4dfcc3219555f13889223e733381dfab/ty-0.0.27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d3ec4033031f240836bb0337274bac5c49dde312c7c6d7575451ed719bf8ffa3", size = 11133002, upload-time = "2026-03-31T19:07:18.371Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/32/5c15bb8ea20ed54d43c734f253a2a5da95d41474caecf4ef3682df9f68f5/ty-0.0.25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad98da1393161096235a387cc36abecd31861060c68416761eccdb7c1bc326b", size = 10845246, upload-time = "2026-03-24T22:32:41.33Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/38/188c14a57f52160407ce62c6abb556011718fd0bcbe1dca690529ce84c46/ty-0.0.27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:924a8849afd500d260bf5b7296165a05b7424fbb6b19113f30f3b999d682873f", size = 10986624, upload-time = "2026-03-31T19:07:13.066Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/fe/4ddd83e810c8682fcfada0d1c9d38936a34a024d32d7736075c1e53a038e/ty-0.0.25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2d4336aa5381eb4eab107c3dec75fe22943a648ef6646f5a8431ef1c8cdabb66", size = 10233515, upload-time = "2026-03-24T22:32:17.012Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/f1/667a71393f47d2cd6ba9ed07541b8df3eb63aab1f2ee658e77d91b8362fa/ty-0.0.27-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d8270026c07e7423a1b3a3fd065b46ed1478748f0662518b523b57744f3fa025", size = 10366721, upload-time = "2026-03-31T19:07:00.131Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/db/9fe54f6fb952e5b218f2e661e64ed656512edf2046cfbb9c159558e255db/ty-0.0.25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e10ed39564227de2b7bd89398250b65daaedbef15a25cef8eee70078f5d9e0b2", size = 10275289, upload-time = "2026-03-24T22:32:28.21Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/aa/8edafe41be898bda774249abc5be6edd733e53fb1777d59ea9331e38537d/ty-0.0.27-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e26e9735d3bdfd95d881111ad1cf570eab8188d8c3be36d6bcaad044d38984d8", size = 10412239, upload-time = "2026-03-31T19:07:05.297Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/e0/090d7b33791b42bc7ec29463ac6a634738e16b289e027608ebe542682773/ty-0.0.25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aca04e9ed9b61c706064a1c0b71a247c3f92f373d0222103f3bc54b649421796", size = 10461195, upload-time = "2026-03-24T22:32:24.252Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/ff/8bafaed4a18d38264f46bdfc427de7ea2974cf9064e4e0bdb1b6e6c724e3/ty-0.0.27-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7c09cc9a699810609acc0090af8d0db68adaee6e60a7c3e05ab80cc954a83db7", size = 10573507, upload-time = "2026-03-31T19:06:57.064Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/31/5bf12bce01b80b72a7a4e627380779b41510e730f6000862a1d078e423f7/ty-0.0.25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:18a5443e4ef339c1bd8c57fc13112c22080617ea582bfc22b497d82d65361325", size = 10931471, upload-time = "2026-03-24T22:32:14.985Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/2e/63a8284a2fefd08ab56ecbad0fde7dd4b2d4045a31cf24c1d1fcd9643227/ty-0.0.27-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d3e02853bb037221a456e034b1898aaa573e6374fbb53884e33cb7513ccb85a", size = 11090233, upload-time = "2026-03-31T19:07:34.139Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/5e/ab60c11f8a6dd2a0ae96daac83458ef2e9be1ae70481d1ad9c59d3eaf20f/ty-0.0.25-py3-none-win32.whl", hash = "sha256:a685b9a611b69195b5a557e05dbb7ebcd12815f6c32fb27fdf15edeb1fa33d8f", size = 9835974, upload-time = "2026-03-24T22:32:36.86Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/d3/d6fa1cafdfa2b34dbfa304fc6833af8e1669fc34e24d214fa76d2a2e5a25/ty-0.0.27-py3-none-win32.whl", hash = "sha256:34e7377f2047c14dbbb7bf5322e84114db7a5f2cb470db6bee63f8f3550cfc1e", size = 9984415, upload-time = "2026-03-31T19:07:07.98Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/55/625acc2ef34646268bc2baa8fdd6e22fb47cd5965e2acd3be92c687fb6b0/ty-0.0.25-py3-none-win_amd64.whl", hash = "sha256:0d4d37a1f1ab7f2669c941c38c65144ff223eb51ececd7ccfc0d623afbc0f729", size = 10815449, upload-time = "2026-03-24T22:32:11.031Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/e6/dd4e27da9632b3472d5711ca49dbd3709dbd3e8c73f3af6db9c254235ca9/ty-0.0.27-py3-none-win_amd64.whl", hash = "sha256:3f7e4145aad8b815ed69b324c93b5b773eb864dda366ca16ab8693ff88ce6f36", size = 10961535, upload-time = "2026-03-31T19:07:10.566Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/c7/0147bfb543df97740b45b222c54ff79ef20fa57f14b9d2c1dab3cd7d3faa/ty-0.0.25-py3-none-win_arm64.whl", hash = "sha256:d80b8cd965cbacbfd887ac2d985f5b6da09b7aa3569371e2894e0b30b26b89cd", size = 10225494, upload-time = "2026-03-24T22:32:30.611Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/1a/824b3496d66852ed7d5d68d9787711131552b68dce8835ce9410db32e618/ty-0.0.27-py3-none-win_arm64.whl", hash = "sha256:95bf8d01eb96bb2ba3ffc39faff19da595176448e80871a7b362f4d2de58476c", size = 10376689, upload-time = "2026-03-31T19:07:25.732Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1262,6 +1286,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "verspec"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "watchdog"
|
name = "watchdog"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -1291,7 +1324,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.29"
|
version = "0.0.31"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@@ -1301,18 +1334,18 @@ dependencies = [
|
|||||||
{ name = "pymdown-extensions" },
|
{ name = "pymdown-extensions" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/78/bd/5786ab618a60bd7469ab243a7fd2c9eecb0790c85c784abb8b97edb77a54/zensical-0.0.29.tar.gz", hash = "sha256:0d6282be7cb551e12d5806badf5e94c54a5e2f2cf07057a3e36d1eaf97c33ada", size = 3842641, upload-time = "2026-03-24T13:37:27.587Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d5/1a/9b6f5285c5aef648db38f9132f49a7059bd2c9d748f68ef0c52ed8afcff3/zensical-0.0.31.tar.gz", hash = "sha256:9c12f07bde70c4bfdb13d6cae1bedf8d18064d257a6e81128a152502b28a8fc3", size = 3891758, upload-time = "2026-04-01T11:30:21.88Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/9c/8b681daa024abca9763017bec09ecee8008e110cae1254217c8dd22cc339/zensical-0.0.29-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:20ae0709ea14fce25ab33d0a82acdaf454a7a2e232a9ee20c019942205174476", size = 12311399, upload-time = "2026-03-24T13:36:53.809Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/db/cc4e555d2e816f2d91304ff969d62cc3a401ee477dbb7c720b874bec67d6/zensical-0.0.31-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b489936d670733dd204f16b689a2acc0e45b69e42cc4901f5131ae57658b8fbc", size = 12419980, upload-time = "2026-04-01T11:29:44.01Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/ae/4ebb4d8bb2ef0164d473698b92f11caf431fc436e1625524acd5641102ca/zensical-0.0.29-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:599af3ba66fcd0146d7019f3493ed3c316051fae6c4d5599bc59f3a8f4b8a6f0", size = 12191845, upload-time = "2026-03-24T13:36:56.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/e7/c1/6789f73164c7f5821f5defb8a80b1dba8d5af24bdec7db36876793c5afd9/zensical-0.0.31-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d9f678efc0d9918e45eeb8bc62847b2cce23db7393c8c59c1be6d1c064bbaacd", size = 12292301, upload-time = "2026-04-01T11:29:47.277Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/35/67f89db06571a52283b3ecbe3bcf32fd3115ca50436b3ae177a948b83ea7/zensical-0.0.29-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eea7e48a00a71c0586e875079b5f83a070c33a147e52ad4383e4b63ab524332b", size = 12554105, upload-time = "2026-03-24T13:36:59.945Z" },
|
{ url = "https://files.pythonhosted.org/packages/4f/9a/6a83ad209081a953e0285d5056e5452c4fbcabd2f104f3797d53e4bdd96f/zensical-0.0.31-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb2b50ecf674997f818e53f12f2a67875a21b0c79ed74c151dfaef2f1475e5bf", size = 12661472, upload-time = "2026-04-01T11:29:50.706Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/f6/ac79e5d9c18b28557c9ff1c7c23d695fbdd82645d69bfe02292f46d935e7/zensical-0.0.29-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59a57db35542e98d2896b833de07d199320f8ada3b4e7ddccb7fe892292d8b74", size = 12498643, upload-time = "2026-03-24T13:37:02.376Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/4a/a82f5c81893b7a607cf9d439b75c3c3894b4ef4d3e92d5d818b4fa5c6f23/zensical-0.0.31-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6fb5c634fe88254770a2d4db5c05b06f1c3ee5e29d2ae3e7efdae8905e435b1d", size = 12603784, upload-time = "2026-04-01T11:29:53.623Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/70/5c22a96a69e0e91e569c26236918bb9bab1170f59b29ad04105ead64f199/zensical-0.0.29-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d42c2b2a96a80cf64c98ba7242f59ef95109914bd4c9499d7ebc12544663852c", size = 12854531, upload-time = "2026-03-24T13:37:04.962Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/1c/79c198628b8e006be32dfb1c5b73561757a349a6cf3069600a67ffa62495/zensical-0.0.31-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e64630552793274db1ec66c971e49a15ad351536d5d12de67ec6da7358ac50", size = 12959832, upload-time = "2026-04-01T11:29:56.736Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/25/e32237a8fcb0ceae1ef8e192e7f8db53b38f1e48f1c7cdbacd0a7b713892/zensical-0.0.29-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b2fca39c5f6b1782c77cf6591cf346357cabee85ebdb956c5ddc0fd5169f3d9", size = 12596828, upload-time = "2026-03-24T13:37:07.817Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/9d/45839d9ca0f69622e8a3b944f2d8d7f7d2b7c2da78201079c4feb275feb6/zensical-0.0.31-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:738a2fd5832e3b3c10ff642eebaf89c89ca1d28e4451dad0f36fdac53c415577", size = 12704024, upload-time = "2026-04-01T11:29:59.836Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/74/89ac909cbb258903ea53802c184e4986c17ce0ba79b1c7f77b7e78a2dce3/zensical-0.0.29-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dfc23a74ef672aa51088c080286319da1dc0b989cd5051e9e5e6d7d4abbc2fc1", size = 12732059, upload-time = "2026-03-24T13:37:11.651Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/5f/451d7f4d94092bc38bd8d514826fb7b0329c188db506795b1d20bd07d517/zensical-0.0.31-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bd601f6132e285ef6c3e4c3852be2094fc0473295a8080003db76a79760f84fb", size = 12837788, upload-time = "2026-04-01T11:30:03.048Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/31/2429de6a9328eed4acc7e9a3789f160294a15115be15f9870a0d02649302/zensical-0.0.29-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c9336d4e4b232e3c9a70e30258e916dd7e60c0a2a08c8690065e60350c302028", size = 12768542, upload-time = "2026-03-24T13:37:14.39Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/39/390a8fc384fb174ebd4450343a0aa2877b3a31ddcedf5ef0b8d26944e12c/zensical-0.0.31-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:dc3b6a9dfb5903c0aa779ef65cd6185add2b8aa1db237be840874b8c9db761b8", size = 12876822, upload-time = "2026-04-01T11:30:06.418Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/8a/55588b2a1dcbe86dad0404506c9ba367a06c663b1ff47147c84d26f7510e/zensical-0.0.29-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:30661148f0681199f3b598cbeb1d54f5cba773e54ae840bac639250d85907b84", size = 12917991, upload-time = "2026-03-24T13:37:16.795Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/60/640da2f095782cf38974cd851fb7afa62651d09a36543a1d8942b31aabdc/zensical-0.0.31-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ddd4321b275e82c4897aa45b05038ce204b88fb311ad55f8c2af572173a9b56c", size = 13024036, upload-time = "2026-04-01T11:30:09.501Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/5d/653901f0d3a3ca72daebc62746a148797f4e422cc3a2b66a4e6718e4398f/zensical-0.0.29-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6a566ac1fd4bfac5d711a7bd1ae06666712127c2718daa5083c7bf3f107e8578", size = 12868392, upload-time = "2026-03-24T13:37:19.42Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/06/0564377cbfccea3653254adfa851c1b20d1696e4b16770c7b2e1dd1ef1d7/zensical-0.0.31-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:147ab4bc17f3088f703aa6c4b9c416411f4ea8ca64d26f6586beae49d97fd3c7", size = 12975505, upload-time = "2026-04-01T11:30:12.268Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/58/d7449bc88a174b98daa3f2fbdfbdac3493768a557d8987e88bdaa6c78b1a/zensical-0.0.29-cp310-abi3-win32.whl", hash = "sha256:a231a3a02a3851741dc4d2de8910b5c39fe81e55bf026d8edf4d803e91a922fb", size = 11905486, upload-time = "2026-03-24T13:37:22.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/4b/b8a0c4e5937cb05882dcce667798403e00897135080a69f92363e5e3ff9f/zensical-0.0.31-cp310-abi3-win32.whl", hash = "sha256:03fa11e629a308507693489541f43e751697784e94365e7435b02104aefd1c2c", size = 12011233, upload-time = "2026-04-01T11:30:15.496Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/09/3fd082d016497c4d26ff20f42a8be2cc91e27191c0c5f3cd6507827f666f/zensical-0.0.29-cp310-abi3-win_amd64.whl", hash = "sha256:7145c5504380a344b8cd4586da815cdde77ef4a42319fa4f35e78250f01985af", size = 12101510, upload-time = "2026-03-24T13:37:24.77Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/99/0eacdb466d344c0c86596932201268517be42f3e0bb6c78b2b0cd84c55f6/zensical-0.0.31-cp310-abi3-win_amd64.whl", hash = "sha256:d6621d4bb46af4143560045d4a18c8c76302db56bf1dbb6e2ce107d7fb643e09", size = 12207545, upload-time = "2026-04-01T11:30:19.054Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ Examples = [
|
|||||||
|
|
||||||
[[project.nav]]
|
[[project.nav]]
|
||||||
Migration = [
|
Migration = [
|
||||||
|
{"v3.0" = "migration/v3.md"},
|
||||||
{"v2.0" = "migration/v2.md"},
|
{"v2.0" = "migration/v2.md"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user