mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 06:36:26 +02:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0cc21d2012
|
|||
|
|
a3245d50f0 | ||
|
|
baebf022f6 | ||
|
|
96d445e3f3 | ||
|
|
80306e1af3 | ||
|
|
fd999b63f1 | ||
|
|
c0f352b914 | ||
|
c4c760484b
|
|||
|
|
432e0722e0 | ||
|
|
e732e54518 | ||
|
|
05b5a2c876 | ||
|
|
4a020c56d1 | ||
|
56d365d14b
|
|||
|
|
a257d85d45 | ||
|
117675d02f
|
|||
|
|
d7ad7308c5 | ||
|
8d57bf9525
|
|||
|
|
5a08ec2f57 | ||
|
|
433dc55fcd | ||
|
|
0b2abd8c43 | ||
|
|
07c99be89b | ||
|
|
9b75cc7dfc | ||
|
|
6144b383eb | ||
|
7ec407834a
|
|||
|
|
7da34f33a2 | ||
|
8c8911fb27
|
|||
|
|
c0c3b38054 | ||
|
e17d385910
|
|||
|
|
6cf7df55ef | ||
|
|
7482bc5dad | ||
|
|
9d07dfea85 |
11
README.md
11
README.md
@@ -20,7 +20,7 @@ A modular collection of production-ready utilities for FastAPI. Install only wha
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, logging):
|
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, model mixins, logging):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add fastapi-toolsets
|
uv add fastapi-toolsets
|
||||||
@@ -29,9 +29,9 @@ uv add fastapi-toolsets
|
|||||||
Install only the extras you need:
|
Install only the extras you need:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]" # CLI (typer)
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]" # Prometheus metrics (prometheus_client)
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[pytest]" # Pytest helpers (httpx, pytest-xdist)
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
Or install everything:
|
Or install everything:
|
||||||
@@ -44,10 +44,11 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Core
|
### Core
|
||||||
|
|
||||||
- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in search with relationship traversal
|
- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in full-text/faceted search and Offset/Cursor pagination.
|
||||||
- **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`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
|
||||||
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
||||||
- **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`
|
||||||
|
|||||||
134
docs/examples/pagination-search.md
Normal file
134
docs/examples/pagination-search.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Pagination & search
|
||||||
|
|
||||||
|
This example builds an articles listing endpoint that supports **offset pagination**, **cursor pagination**, **full-text search**, **faceted filtering**, and **sorting** — all from a single `CrudFactory` definition.
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
```python title="models.py"
|
||||||
|
--8<-- "docs_src/examples/pagination_search/models.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schemas
|
||||||
|
|
||||||
|
```python title="schemas.py"
|
||||||
|
--8<-- "docs_src/examples/pagination_search/schemas.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Crud
|
||||||
|
|
||||||
|
Declare `searchable_fields`, `facet_fields`, and `order_fields` once on [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory). All endpoints built from this class share the same defaults and can override them per call.
|
||||||
|
|
||||||
|
```python title="crud.py"
|
||||||
|
--8<-- "docs_src/examples/pagination_search/crud.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session dependency
|
||||||
|
|
||||||
|
```python title="db.py"
|
||||||
|
--8<-- "docs_src/examples/pagination_search/db.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! info "Deploy a Postgres DB with docker"
|
||||||
|
```bash
|
||||||
|
docker run -d --name postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres -p 5432:5432 postgres:18-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## App
|
||||||
|
|
||||||
|
```python title="app.py"
|
||||||
|
--8<-- "docs_src/examples/pagination_search/app.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
### Offset pagination
|
||||||
|
|
||||||
|
Best for admin panels or any UI that needs a total item count and numbered pages.
|
||||||
|
|
||||||
|
```python title="routes.py:1:36"
|
||||||
|
--8<-- "docs_src/examples/pagination_search/routes.py:1:36"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example request**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /articles/offset?page=2&items_per_page=10&search=fastapi&status=published&order_by=title&order=asc
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"data": [
|
||||||
|
{ "id": "3f47ac69-...", "title": "FastAPI tips", "status": "published", ... }
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"total_count": 42,
|
||||||
|
"page": 2,
|
||||||
|
"items_per_page": 10,
|
||||||
|
"has_more": true
|
||||||
|
},
|
||||||
|
"filter_attributes": {
|
||||||
|
"status": ["archived", "draft", "published"],
|
||||||
|
"name": ["backend", "frontend", "python"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`filter_attributes` always reflects the values visible **after** applying the active filters. Use it to populate filter dropdowns on the client.
|
||||||
|
|
||||||
|
### Cursor pagination
|
||||||
|
|
||||||
|
Best for feeds, infinite scroll, or any high-throughput API where offset performance degrades.
|
||||||
|
|
||||||
|
```python title="routes.py:39:59"
|
||||||
|
--8<-- "docs_src/examples/pagination_search/routes.py:39:59"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example request**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /articles/cursor?items_per_page=10&status=published&order_by=created_at&order=desc
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"data": [
|
||||||
|
{ "id": "3f47ac69-...", "title": "FastAPI tips", "status": "published", ... }
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"next_cursor": "eyJ2YWx1ZSI6ICIzZjQ3YWM2OS0uLi4ifQ==",
|
||||||
|
"prev_cursor": null,
|
||||||
|
"items_per_page": 10,
|
||||||
|
"has_more": true
|
||||||
|
},
|
||||||
|
"filter_attributes": {
|
||||||
|
"status": ["published"],
|
||||||
|
"name": ["backend", "python"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `next_cursor` as the `cursor` query parameter on the next request to advance to the next page.
|
||||||
|
|
||||||
|
## Search behaviour
|
||||||
|
|
||||||
|
Both endpoints inherit the same `searchable_fields` declared on `ArticleCrud`:
|
||||||
|
|
||||||
|
Search is **case-insensitive** and uses a `LIKE %query%` pattern. Pass a [`SearchConfig`](../reference/crud.md#fastapi_toolsets.crud.search.SearchConfig) instead of a plain string to control case sensitivity or switch to `match_mode="all"` (AND across all fields instead of OR).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.crud import SearchConfig
|
||||||
|
|
||||||
|
# Both title AND body must contain "fastapi"
|
||||||
|
result = await ArticleCrud.offset_paginate(
|
||||||
|
session,
|
||||||
|
search=SearchConfig(query="fastapi", case_sensitive=True, match_mode="all"),
|
||||||
|
search_fields=[Article.title, Article.body],
|
||||||
|
)
|
||||||
|
```
|
||||||
@@ -20,7 +20,7 @@ A modular collection of production-ready utilities for FastAPI. Install only wha
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, logging):
|
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, model mixins, logging):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add fastapi-toolsets
|
uv add fastapi-toolsets
|
||||||
@@ -29,9 +29,9 @@ uv add fastapi-toolsets
|
|||||||
Install only the extras you need:
|
Install only the extras you need:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]" # CLI (typer)
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]" # Prometheus metrics (prometheus_client)
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[pytest]" # Pytest helpers (httpx, pytest-xdist)
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
Or install everything:
|
Or install everything:
|
||||||
@@ -44,10 +44,11 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Core
|
### Core
|
||||||
|
|
||||||
- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in search with relationship traversal
|
- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in full-text/faceted search and Offset/Cursor pagination.
|
||||||
- **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`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
|
||||||
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
||||||
- **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`
|
||||||
|
|||||||
137
docs/migration/v2.md
Normal file
137
docs/migration/v2.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Migrating to v2.0
|
||||||
|
|
||||||
|
This page covers every breaking change introduced in **v2.0** and the steps required to update your code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRUD
|
||||||
|
|
||||||
|
### `schema` is now required in `offset_paginate()` and `cursor_paginate()`
|
||||||
|
|
||||||
|
Calls that omit `schema` will now raise a `TypeError` at runtime.
|
||||||
|
|
||||||
|
Previously `schema` was optional; omitting it returned raw SQLAlchemy model instances inside the response. It is now a required keyword argument and the response always contains serialized schema instances.
|
||||||
|
|
||||||
|
=== "Before (`v1`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
# schema omitted — returned raw model instances
|
||||||
|
result = await UserCrud.offset_paginate(session=session, page=1)
|
||||||
|
result = await UserCrud.cursor_paginate(session=session, cursor=token)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = await UserCrud.offset_paginate(session=session, page=1, schema=UserRead)
|
||||||
|
result = await UserCrud.cursor_paginate(session=session, cursor=token, schema=UserRead)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `as_response` removed from `create()`, `get()`, and `update()`
|
||||||
|
|
||||||
|
Passing `as_response` to these methods will raise a `TypeError` at runtime.
|
||||||
|
|
||||||
|
The `as_response=True` shorthand is replaced by passing a `schema` directly. The return value is a `Response[schema]` when `schema` is provided, or the raw model instance when it is not.
|
||||||
|
|
||||||
|
=== "Before (`v1`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
user = await UserCrud.create(session=session, obj=data, as_response=True)
|
||||||
|
user = await UserCrud.get(session=session, filters=filters, as_response=True)
|
||||||
|
user = await UserCrud.update(session=session, obj=data, filters, as_response=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
user = await UserCrud.create(session=session, obj=data, schema=UserRead)
|
||||||
|
user = await UserCrud.get(session=session, filters=filters, schema=UserRead)
|
||||||
|
user = await UserCrud.update(session=session, obj=data, filters, schema=UserRead)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `delete()`: `as_response` renamed and return type changed
|
||||||
|
|
||||||
|
`as_response` is gone, and the plain (non-response) call no longer returns `True`.
|
||||||
|
|
||||||
|
Two changes were made to `delete()`:
|
||||||
|
|
||||||
|
1. The `as_response` parameter is renamed to `return_response`.
|
||||||
|
2. When called without `return_response=True`, the method now returns `None` on success instead of `True`.
|
||||||
|
|
||||||
|
=== "Before (`v1`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
ok = await UserCrud.delete(session=session, filters=filters)
|
||||||
|
if ok: # True on success
|
||||||
|
...
|
||||||
|
|
||||||
|
response = await UserCrud.delete(session=session, filters=filters, as_response=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
await UserCrud.delete(session=session, filters=filters) # returns None
|
||||||
|
|
||||||
|
response = await UserCrud.delete(session=session, filters=filters, return_response=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `paginate()` alias removed
|
||||||
|
|
||||||
|
Any call to `crud.paginate(...)` will raise `AttributeError` at runtime.
|
||||||
|
|
||||||
|
The `paginate` shorthand was an alias for `offset_paginate`. It has been removed; call `offset_paginate` directly.
|
||||||
|
|
||||||
|
=== "Before (`v1`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = await UserCrud.paginate(session=session, page=2, items_per_page=20, schema=UserRead)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = await UserCrud.offset_paginate(session=session, page=2, items_per_page=20, schema=UserRead)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exceptions
|
||||||
|
|
||||||
|
### Missing `api_error` raises `TypeError` at class definition time
|
||||||
|
|
||||||
|
Unfinished or stub exception subclasses that previously compiled fine will now fail on import.
|
||||||
|
|
||||||
|
In `v1`, a subclass without `api_error` would only fail when the exception was raised. In `v2`, `__init_subclass__` validates this at class definition time.
|
||||||
|
|
||||||
|
=== "Before (`v1`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyError(ApiException):
|
||||||
|
pass # fine until raised
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyError(ApiException):
|
||||||
|
pass # TypeError: MyError must define an 'api_error' class attribute.
|
||||||
|
```
|
||||||
|
|
||||||
|
For shared base classes that are not meant to be raised directly, use `abstract=True`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BillingError(ApiException, abstract=True):
|
||||||
|
"""Base for all billing-related errors — not raised directly."""
|
||||||
|
|
||||||
|
class PaymentRequiredError(BillingError):
|
||||||
|
api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schemas
|
||||||
|
|
||||||
|
### `Pagination` alias removed
|
||||||
|
|
||||||
|
`Pagination` was already deprecated in `v1` and is fully removed in `v2`, you now need to use [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination) or [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# CRUD
|
# CRUD
|
||||||
|
|
||||||
Generic async CRUD operations for SQLAlchemy models with search, pagination, and many-to-many support. This module has features that are only compatible with Postgres.
|
Generic async CRUD operations for SQLAlchemy models with search, pagination, and many-to-many support.
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
This module has been coded and tested to be compatible with PostgreSQL only.
|
This module has been coded and tested to be compatible with PostgreSQL only.
|
||||||
@@ -48,6 +48,21 @@ exists = await UserCrud.exists(session=session, filters=[User.email == email])
|
|||||||
|
|
||||||
## Pagination
|
## Pagination
|
||||||
|
|
||||||
|
!!! info "Added in `v1.1` (only offset_pagination via `paginate` if `<v1.1`)"
|
||||||
|
|
||||||
|
Two pagination strategies are available. Both return a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) but differ in how they navigate through results.
|
||||||
|
|
||||||
|
| | `offset_paginate` | `cursor_paginate` |
|
||||||
|
|---|---|---|
|
||||||
|
| Total count | Yes | No |
|
||||||
|
| Jump to arbitrary page | Yes | No |
|
||||||
|
| Performance on deep pages | Degrades | Constant |
|
||||||
|
| Stable under concurrent inserts | No | Yes |
|
||||||
|
| Search compatible | Yes | Yes |
|
||||||
|
| Use case | Admin panels, numbered pagination | Feeds, APIs, infinite scroll |
|
||||||
|
|
||||||
|
### Offset pagination
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@router.get(
|
@router.get(
|
||||||
"",
|
"",
|
||||||
@@ -58,18 +73,111 @@ async def get_users(
|
|||||||
items_per_page: int = 50,
|
items_per_page: int = 50,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
):
|
):
|
||||||
return await crud.UserCrud.paginate(
|
return await crud.UserCrud.offset_paginate(
|
||||||
session=session,
|
session=session,
|
||||||
items_per_page=items_per_page,
|
items_per_page=items_per_page,
|
||||||
page=page,
|
page=page,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
The [`paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate) function will return a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse).
|
The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) method returns a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) whose `pagination` field is an [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination) object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"data": ["..."],
|
||||||
|
"pagination": {
|
||||||
|
"total_count": 100,
|
||||||
|
"page": 1,
|
||||||
|
"items_per_page": 20,
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cursor pagination
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=PaginatedResponse[UserRead],
|
||||||
|
)
|
||||||
|
async def list_users(
|
||||||
|
session: SessionDep,
|
||||||
|
cursor: str | None = None,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
):
|
||||||
|
return await UserCrud.cursor_paginate(
|
||||||
|
session=session,
|
||||||
|
cursor=cursor,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) method returns a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) whose `pagination` field is a [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination) object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"data": ["..."],
|
||||||
|
"pagination": {
|
||||||
|
"next_cursor": "eyJ2YWx1ZSI6ICIzZjQ3YWM2OS0uLi4ifQ==",
|
||||||
|
"prev_cursor": null,
|
||||||
|
"items_per_page": 20,
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `next_cursor` as the `cursor` query parameter on the next request to advance to the next page. `prev_cursor` is set on pages 2+ and points back to the first item of the current page. Both are `null` when there is no adjacent page.
|
||||||
|
|
||||||
|
#### Choosing a cursor column
|
||||||
|
|
||||||
|
The cursor column is set once on [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory) via the `cursor_column` parameter. It must be monotonically ordered for stable results:
|
||||||
|
|
||||||
|
- Auto-increment integer PKs
|
||||||
|
- UUID v7 PKs
|
||||||
|
- Timestamps
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Random UUID v4 PKs are **not** suitable as cursor columns because their ordering is non-deterministic.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
`cursor_column` is required. Calling [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) on a CRUD class that has no `cursor_column` configured raises a `ValueError`.
|
||||||
|
|
||||||
|
The cursor value is base64-encoded when returned to the client and decoded back to the correct Python type on the next request. The following SQLAlchemy column types are supported:
|
||||||
|
|
||||||
|
| SQLAlchemy type | Python type |
|
||||||
|
|---|---|
|
||||||
|
| `Integer`, `BigInteger`, `SmallInteger` | `int` |
|
||||||
|
| `Uuid` | `uuid.UUID` |
|
||||||
|
| `DateTime` | `datetime.datetime` |
|
||||||
|
| `Date` | `datetime.date` |
|
||||||
|
| `Float`, `Numeric` | `decimal.Decimal` |
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Paginate by the primary key
|
||||||
|
PostCrud = CrudFactory(model=Post, cursor_column=Post.id)
|
||||||
|
|
||||||
|
# Paginate by a timestamp column instead
|
||||||
|
PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
||||||
|
```
|
||||||
|
|
||||||
## Search
|
## Search
|
||||||
|
|
||||||
Declare searchable fields on the CRUD class. Relationship traversal is supported via tuples:
|
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).
|
||||||
|
|
||||||
|
| | Full-text search | Faceted search |
|
||||||
|
|---|---|---|
|
||||||
|
| Input | Free-text string | Exact column values |
|
||||||
|
| Relationship support | Yes | Yes |
|
||||||
|
| Use case | Search bars | Filter dropdowns |
|
||||||
|
|
||||||
|
!!! info "You can use both search strategies in the same endpoint!"
|
||||||
|
|
||||||
|
### Full-text search
|
||||||
|
|
||||||
|
Declare `searchable_fields` on the CRUD class. Relationship traversal is supported via tuples:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
PostCrud = CrudFactory(
|
PostCrud = CrudFactory(
|
||||||
@@ -82,7 +190,16 @@ PostCrud = CrudFactory(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
This allow to do a search with the [`paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate) function:
|
You can override `searchable_fields` per call with `search_fields`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = await UserCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
search_fields=[User.country],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -95,7 +212,7 @@ async def get_users(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
):
|
):
|
||||||
return await crud.UserCrud.paginate(
|
return await crud.UserCrud.offset_paginate(
|
||||||
session=session,
|
session=session,
|
||||||
items_per_page=items_per_page,
|
items_per_page=items_per_page,
|
||||||
page=page,
|
page=page,
|
||||||
@@ -103,6 +220,195 @@ async def get_users(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=PaginatedResponse[User],
|
||||||
|
)
|
||||||
|
async def get_users(
|
||||||
|
session: SessionDep,
|
||||||
|
cursor: str | None = None,
|
||||||
|
items_per_page: int = 50,
|
||||||
|
search: str | None = None,
|
||||||
|
):
|
||||||
|
return await crud.UserCrud.cursor_paginate(
|
||||||
|
session=session,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
cursor=cursor,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Faceted search
|
||||||
|
|
||||||
|
!!! info "Added in `v1.2`"
|
||||||
|
|
||||||
|
Declare `facet_fields` on the CRUD class to return distinct column values alongside paginated results. This is useful for populating filter dropdowns or building faceted search UIs.
|
||||||
|
|
||||||
|
Facet fields use the same syntax as `searchable_fields` — direct columns or relationship tuples:
|
||||||
|
|
||||||
|
```python
|
||||||
|
UserCrud = CrudFactory(
|
||||||
|
model=User,
|
||||||
|
facet_fields=[
|
||||||
|
User.status,
|
||||||
|
User.country,
|
||||||
|
(User.role, Role.name), # value from a related model
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
You can override `facet_fields` per call:
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = await UserCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
facet_fields=[User.country],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The distinct values are returned in the `filter_attributes` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"data": ["..."],
|
||||||
|
"pagination": { "..." },
|
||||||
|
"filter_attributes": {
|
||||||
|
"status": ["active", "inactive"],
|
||||||
|
"country": ["DE", "FR", "US"],
|
||||||
|
"name": ["admin", "editor", "viewer"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError).
|
||||||
|
|
||||||
|
!!! info "The keys in `filter_by` are the same keys the client received in `filter_attributes`."
|
||||||
|
Keys 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"`.
|
||||||
|
|
||||||
|
`filter_by` and `filters` can be combined — both are applied with AND logic.
|
||||||
|
|
||||||
|
Use [`filter_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.filter_params) to generate a dict with the facet filter values from the query parameters:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
UserCrud = CrudFactory(
|
||||||
|
model=User,
|
||||||
|
facet_fields=[User.status, User.country, (User.role, Role.name)],
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("", response_model_exclude_none=True)
|
||||||
|
async def list_users(
|
||||||
|
session: SessionDep,
|
||||||
|
page: int = 1,
|
||||||
|
filter_by: Annotated[dict[str, list[str]], Depends(UserCrud.filter_params())],
|
||||||
|
) -> PaginatedResponse[UserRead]:
|
||||||
|
return await UserCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
page=page,
|
||||||
|
filter_by=filter_by,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both single-value and multi-value query parameters work:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users?status=active → filter_by={"status": ["active"]}
|
||||||
|
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
|
||||||
|
GET /users?role=admin&role=editor → filter_by={"role": ["admin", "editor"]} (IN clause)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sorting
|
||||||
|
|
||||||
|
!!! info "Added in `v1.3`"
|
||||||
|
|
||||||
|
Declare `order_fields` on the CRUD class to expose client-driven column ordering via `order_by` and `order` query parameters.
|
||||||
|
|
||||||
|
```python
|
||||||
|
UserCrud = CrudFactory(
|
||||||
|
model=User,
|
||||||
|
order_fields=[
|
||||||
|
User.name,
|
||||||
|
User.created_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Call [`order_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.order_params) to generate a FastAPI dependency that maps the query parameters to an [`OrderByClause`](../reference/crud.md#fastapi_toolsets.crud.factory.OrderByClause) expression:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi_toolsets.crud import OrderByClause
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_users(
|
||||||
|
session: SessionDep,
|
||||||
|
order_by: Annotated[OrderByClause | None, Depends(UserCrud.order_params())],
|
||||||
|
) -> PaginatedResponse[UserRead]:
|
||||||
|
return await UserCrud.offset_paginate(session=session, order_by=order_by)
|
||||||
|
```
|
||||||
|
|
||||||
|
The dependency adds two query parameters to the endpoint:
|
||||||
|
|
||||||
|
| Parameter | Type |
|
||||||
|
| ---------- | --------------- |
|
||||||
|
| `order_by` | `str | null` |
|
||||||
|
| `order` | `asc` or `desc` |
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users?order_by=name&order=asc → ORDER BY users.name ASC
|
||||||
|
GET /users?order_by=name&order=desc → ORDER BY users.name DESC
|
||||||
|
```
|
||||||
|
|
||||||
|
An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
|
||||||
|
|
||||||
|
You can also pass `order_fields` directly to `order_params()` to override the class-level defaults without modifying them:
|
||||||
|
|
||||||
|
```python
|
||||||
|
UserOrderParams = UserCrud.order_params(order_fields=[User.name])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relationship loading
|
||||||
|
|
||||||
|
!!! info "Added in `v1.1`"
|
||||||
|
|
||||||
|
By default, SQLAlchemy relationships are not loaded unless explicitly requested. Instead of using `lazy="selectin"` on model definitions (which is implicit and applies globally), define a `default_load_options` on the CRUD class to control loading strategy explicitly.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Avoid using `lazy="selectin"` on model relationships. It fires silently on every query, cannot be disabled per-call, and can cause unexpected cascading loads through deep relationship chains. Use `default_load_options` instead.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
ArticleCrud = CrudFactory(
|
||||||
|
model=Article,
|
||||||
|
default_load_options=[
|
||||||
|
selectinload(Article.category),
|
||||||
|
selectinload(Article.tags),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`default_load_options` applies automatically to all read operations (`get`, `first`, `get_multi`, `offset_paginate`, `cursor_paginate`). When `load_options` is passed at call-site, it **fully replaces** `default_load_options` for that query — giving you precise per-call control:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Only loads category, tags are not loaded
|
||||||
|
article = await ArticleCrud.get(
|
||||||
|
session=session,
|
||||||
|
filters=[Article.id == article_id],
|
||||||
|
load_options=[selectinload(Article.category)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Loads nothing — useful for write-then-refresh flows or lightweight checks
|
||||||
|
articles = await ArticleCrud.get_multi(session=session, load_options=[])
|
||||||
|
```
|
||||||
|
|
||||||
## Many-to-many relationships
|
## Many-to-many relationships
|
||||||
|
|
||||||
Use `m2m_fields` to map schema fields containing lists of IDs to SQLAlchemy relationships. The CRUD class resolves and validates all IDs before persisting:
|
Use `m2m_fields` to map schema fields containing lists of IDs to SQLAlchemy relationships. The CRUD class resolves and validates all IDs before persisting:
|
||||||
@@ -129,24 +435,39 @@ await UserCrud.upsert(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## `as_response`
|
## Response serialization
|
||||||
|
|
||||||
Pass `as_response=True` to any write operation to get a [`Response[ModelType]`](../reference/schemas.md#fastapi_toolsets.schemas.Response) back directly for API usage:
|
!!! info "Added in `v1.1`"
|
||||||
|
|
||||||
|
Pass a Pydantic schema class to `create`, `get`, `update`, or `offset_paginate` to serialize the result directly into that schema and wrap it in a [`Response[schema]`](../reference/schemas.md#fastapi_toolsets.schemas.Response) or [`PaginatedResponse[schema]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
class UserRead(PydanticBase):
|
||||||
|
id: UUID
|
||||||
|
username: str
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{uuid}",
|
"/{uuid}",
|
||||||
response_model=Response[User],
|
|
||||||
responses=generate_error_responses(NotFoundError),
|
responses=generate_error_responses(NotFoundError),
|
||||||
)
|
)
|
||||||
async def get_user(session: SessionDep, uuid: UUID):
|
async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
|
||||||
return await crud.UserCrud.get(
|
return await crud.UserCrud.get(
|
||||||
session=session,
|
session=session,
|
||||||
filters=[User.id == uuid],
|
filters=[User.id == uuid],
|
||||||
as_response=True,
|
schema=UserRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_users(session: SessionDep, page: int = 1) -> PaginatedResponse[UserRead]:
|
||||||
|
return await crud.UserCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
page=page,
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/crud.md)
|
[:material-api: API Reference](../reference/crud.md)
|
||||||
|
|||||||
@@ -87,6 +87,37 @@ await wait_for_row_change(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Creating a database
|
||||||
|
|
||||||
|
!!! info "Added in `v2.1`"
|
||||||
|
|
||||||
|
[`create_database`](../reference/db.md#fastapi_toolsets.db.create_database) creates a database at a given URL. It connects to *server_url* and issues a `CREATE DATABASE` statement:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import create_database
|
||||||
|
|
||||||
|
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
||||||
|
|
||||||
|
await create_database(db_name="myapp_test", server_url=SERVER_URL)
|
||||||
|
```
|
||||||
|
|
||||||
|
For test isolation with automatic cleanup, use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) from the `pytest` module instead — it handles drop-before, create, and drop-after automatically.
|
||||||
|
|
||||||
|
## Cleaning up tables
|
||||||
|
|
||||||
|
!!! info "Added in `v2.1`"
|
||||||
|
|
||||||
|
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables) truncates all tables:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import cleanup_tables
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def clean(db_session):
|
||||||
|
yield
|
||||||
|
await cleanup_tables(session=db_session, base=Base)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/db.md)
|
[:material-api: API Reference](../reference/db.md)
|
||||||
|
|||||||
@@ -21,29 +21,37 @@ init_exceptions_handlers(app=app)
|
|||||||
This registers handlers for:
|
This registers handlers for:
|
||||||
|
|
||||||
- [`ApiException`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ApiException) — all custom exceptions below
|
- [`ApiException`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ApiException) — all custom exceptions below
|
||||||
|
- `HTTPException` — Starlette/FastAPI HTTP errors
|
||||||
- `RequestValidationError` — Pydantic request validation (422)
|
- `RequestValidationError` — Pydantic request validation (422)
|
||||||
- `ResponseValidationError` — Pydantic response validation (422)
|
- `ResponseValidationError` — Pydantic response validation (422)
|
||||||
- `Exception` — unhandled errors (500)
|
- `Exception` — unhandled errors (500)
|
||||||
|
|
||||||
|
It also patches `app.openapi()` to replace the default Pydantic 422 schema with a structured example matching the `ErrorResponse` format.
|
||||||
|
|
||||||
## Built-in exceptions
|
## Built-in exceptions
|
||||||
|
|
||||||
| Exception | Status | Default message |
|
| Exception | Status | Default message |
|
||||||
|-----------|--------|-----------------|
|
|-----------|--------|-----------------|
|
||||||
| [`UnauthorizedError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.UnauthorizedError) | 401 | Unauthorized |
|
| [`UnauthorizedError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.UnauthorizedError) | 401 | Unauthorized |
|
||||||
| [`ForbiddenError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ForbiddenError) | 403 | Forbidden |
|
| [`ForbiddenError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ForbiddenError) | 403 | Forbidden |
|
||||||
| [`NotFoundError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NotFoundError) | 404 | Not found |
|
| [`NotFoundError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NotFoundError) | 404 | Not Found |
|
||||||
| [`ConflictError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ConflictError) | 409 | Conflict |
|
| [`ConflictError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ConflictError) | 409 | Conflict |
|
||||||
| [`NoSearchableFieldsError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError) | 400 | No searchable fields |
|
| [`NoSearchableFieldsError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError) | 400 | No Searchable Fields |
|
||||||
|
| [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError) | 400 | Invalid Facet Filter |
|
||||||
|
| [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) | 422 | Invalid Order Field |
|
||||||
|
|
||||||
|
### Per-instance overrides
|
||||||
|
|
||||||
|
All built-in exceptions accept optional keyword arguments to customise the response for a specific raise site without changing the class defaults:
|
||||||
|
|
||||||
|
| Argument | Effect |
|
||||||
|
|----------|--------|
|
||||||
|
| `detail` | Overrides both `str(exc)` (log output) and the `message` field in the response body |
|
||||||
|
| `desc` | Overrides the `description` field |
|
||||||
|
| `data` | Overrides the `data` field |
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.exceptions import NotFoundError
|
raise NotFoundError(detail="User 42 not found", desc="No user with that ID exists in the database.")
|
||||||
|
|
||||||
@router.get("/users/{id}")
|
|
||||||
async def get_user(id: int, session: AsyncSession = Depends(get_db)):
|
|
||||||
user = await UserCrud.first(session=session, filters=[User.id == id])
|
|
||||||
if not user:
|
|
||||||
raise NotFoundError
|
|
||||||
return user
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Custom exceptions
|
## Custom exceptions
|
||||||
@@ -57,12 +65,51 @@ from fastapi_toolsets.schemas import ApiError
|
|||||||
class PaymentRequiredError(ApiException):
|
class PaymentRequiredError(ApiException):
|
||||||
api_error = ApiError(
|
api_error = ApiError(
|
||||||
code=402,
|
code=402,
|
||||||
msg="Payment required",
|
msg="Payment Required",
|
||||||
desc="Your subscription has expired.",
|
desc="Your subscription has expired.",
|
||||||
err_code="PAYMENT_REQUIRED",
|
err_code="BILLING-402",
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Subclasses that do not define `api_error` raise a `TypeError` at **class creation time**, not at raise time.
|
||||||
|
|
||||||
|
### Custom `__init__`
|
||||||
|
|
||||||
|
Override `__init__` to compute `detail`, `desc`, or `data` dynamically, then delegate to `super().__init__()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class OrderValidationError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=422,
|
||||||
|
msg="Order Validation Failed",
|
||||||
|
desc="One or more order fields are invalid.",
|
||||||
|
err_code="ORDER-422",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *field_errors: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
f"{len(field_errors)} validation error(s)",
|
||||||
|
desc=", ".join(field_errors),
|
||||||
|
data={"errors": [{"message": e} for e in field_errors]},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intermediate base classes
|
||||||
|
|
||||||
|
Use `abstract=True` when creating a shared base that is not meant to be raised directly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BillingError(ApiException, abstract=True):
|
||||||
|
"""Base for all billing-related errors."""
|
||||||
|
|
||||||
|
class PaymentRequiredError(BillingError):
|
||||||
|
api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402")
|
||||||
|
|
||||||
|
class SubscriptionExpiredError(BillingError):
|
||||||
|
api_error = ApiError(code=402, msg="Subscription Expired", desc="...", err_code="BILLING-402-EXP")
|
||||||
|
```
|
||||||
|
|
||||||
## OpenAPI response documentation
|
## OpenAPI response documentation
|
||||||
|
|
||||||
Use [`generate_error_responses`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.generate_error_responses) to add error schemas to your endpoint's OpenAPI spec:
|
Use [`generate_error_responses`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.generate_error_responses) to add error schemas to your endpoint's OpenAPI spec:
|
||||||
@@ -77,8 +124,7 @@ from fastapi_toolsets.exceptions import generate_error_responses, NotFoundError,
|
|||||||
async def get_user(...): ...
|
async def get_user(...): ...
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! info
|
Multiple exceptions sharing the same HTTP status code are grouped under one entry, each appearing as a named example keyed by its `err_code`. This keeps the OpenAPI UI readable when several error variants map to the same status.
|
||||||
The pydantic validation error is automatically added by FastAPI.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,13 @@ This mounts the `/metrics` endpoint that Prometheus can scrape.
|
|||||||
|
|
||||||
### Providers
|
### Providers
|
||||||
|
|
||||||
Providers are called once at startup and register metrics that are updated externally (e.g. counters, histograms):
|
Providers are called once at startup by `init_metrics`. The return value (the Prometheus metric object) is stored in the registry and can be retrieved later with [`registry.get(name)`](../reference/metrics.md#fastapi_toolsets.metrics.registry.MetricsRegistry.get).
|
||||||
|
|
||||||
|
Use providers when you want **deferred initialization**: the Prometheus metric is not registered with the global `CollectorRegistry` until `init_metrics` runs, not at import time. This is particularly useful for testing — importing the module in a test suite without calling `init_metrics` leaves no metrics registered, avoiding cross-test pollution.
|
||||||
|
|
||||||
|
It is also useful when metrics are defined across multiple modules and merged with `include_registry`: any code that needs a metric can call `metrics.get()` on the shared registry instead of importing the metric directly from its origin module.
|
||||||
|
|
||||||
|
If neither of these applies to you, declaring metrics at module level (e.g. `HTTP_REQUESTS = Counter(...)`) is simpler and equally valid.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from prometheus_client import Counter, Histogram
|
from prometheus_client import Counter, Histogram
|
||||||
@@ -50,15 +56,32 @@ def request_duration():
|
|||||||
return Histogram("request_duration_seconds", "Request duration")
|
return Histogram("request_duration_seconds", "Request duration")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Collectors
|
To use a provider's metric elsewhere (e.g. in a middleware), call `metrics.get()` inside the handler — **not** at module level, as providers are only initialized when `init_metrics` runs:
|
||||||
|
|
||||||
Collectors are called on every scrape. Use them for metrics that reflect current state (e.g. gauges):
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
async def metrics_middleware(request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
metrics.get("http_requests").labels(
|
||||||
|
method=request.method, status=response.status_code
|
||||||
|
).inc()
|
||||||
|
return response
|
||||||
|
```
|
||||||
|
|
||||||
|
### Collectors
|
||||||
|
|
||||||
|
Collectors are called on every scrape. Use them for metrics that reflect current state (e.g. gauges).
|
||||||
|
|
||||||
|
!!! warning "Declare the metric at module level"
|
||||||
|
Do **not** instantiate the Prometheus metric inside the collector function. Doing so recreates it on every scrape, raising `ValueError: Duplicated timeseries in CollectorRegistry`. Declare it once at module level instead:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
|
_queue_depth = Gauge("queue_depth", "Current queue depth")
|
||||||
|
|
||||||
@metrics.register(collect=True)
|
@metrics.register(collect=True)
|
||||||
def queue_depth():
|
def collect_queue_depth():
|
||||||
gauge = Gauge("queue_depth", "Current queue depth")
|
_queue_depth.set(get_current_queue_depth())
|
||||||
gauge.set(get_current_queue_depth())
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Merging registries
|
## Merging registries
|
||||||
|
|||||||
113
docs/module/models.md
Normal file
113
docs/module/models.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Models
|
||||||
|
|
||||||
|
!!! info "Added in `v2.0`"
|
||||||
|
|
||||||
|
Reusable SQLAlchemy 2.0 mixins for common column patterns, designed to be composed freely on any `DeclarativeBase` model.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `models` module provides mixins that each add a single, well-defined column behaviour. They work with standard SQLAlchemy 2.0 declarative syntax and are fully compatible with `AsyncSession`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
|
class Article(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "articles"
|
||||||
|
|
||||||
|
title: Mapped[str]
|
||||||
|
content: Mapped[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
All timestamp columns are timezone-aware (`TIMESTAMPTZ`). All defaults are server-side, so they are also applied when inserting rows via raw SQL outside the ORM.
|
||||||
|
|
||||||
|
## Mixins
|
||||||
|
|
||||||
|
### [`UUIDMixin`](../reference/models.md#fastapi_toolsets.models.UUIDMixin)
|
||||||
|
|
||||||
|
Adds a `id: UUID` primary key generated server-side by PostgreSQL using `gen_random_uuid()` (requires PostgreSQL 13+). The value is retrieved via `RETURNING` after insert, so it is available on the Python object immediately after `flush()`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import UUIDMixin
|
||||||
|
|
||||||
|
class User(Base, UUIDMixin):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
username: Mapped[str]
|
||||||
|
|
||||||
|
# id is None before flush
|
||||||
|
user = User(username="alice")
|
||||||
|
await session.flush()
|
||||||
|
print(user.id) # UUID('...')
|
||||||
|
```
|
||||||
|
|
||||||
|
### [`CreatedAtMixin`](../reference/models.md#fastapi_toolsets.models.CreatedAtMixin)
|
||||||
|
|
||||||
|
Adds a `created_at: datetime` column set to `NOW()` on insert. The column has no `onupdate` hook — it is intentionally immutable after the row is created.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin
|
||||||
|
|
||||||
|
class Order(Base, UUIDMixin, CreatedAtMixin):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
|
||||||
|
total: Mapped[float]
|
||||||
|
```
|
||||||
|
|
||||||
|
### [`UpdatedAtMixin`](../reference/models.md#fastapi_toolsets.models.UpdatedAtMixin)
|
||||||
|
|
||||||
|
Adds an `updated_at: datetime` column set to `NOW()` on insert and automatically updated to `NOW()` on every ORM-level update (via SQLAlchemy's `onupdate` hook).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin
|
||||||
|
|
||||||
|
class Post(Base, UUIDMixin, UpdatedAtMixin):
|
||||||
|
__tablename__ = "posts"
|
||||||
|
|
||||||
|
title: Mapped[str]
|
||||||
|
|
||||||
|
post = Post(title="Hello")
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(post)
|
||||||
|
|
||||||
|
post.title = "Hello World"
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(post)
|
||||||
|
print(post.updated_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
`updated_at` is updated by SQLAlchemy at ORM flush time. If you update rows via raw SQL (e.g. `UPDATE posts SET ...`), the column will **not** be updated automatically — use a database trigger if you need that guarantee.
|
||||||
|
|
||||||
|
### [`TimestampMixin`](../reference/models.md#fastapi_toolsets.models.TimestampMixin)
|
||||||
|
|
||||||
|
Convenience mixin that combines [`CreatedAtMixin`](../reference/models.md#fastapi_toolsets.models.CreatedAtMixin) and [`UpdatedAtMixin`](../reference/models.md#fastapi_toolsets.models.UpdatedAtMixin). Equivalent to inheriting both.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
|
class Article(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "articles"
|
||||||
|
|
||||||
|
title: Mapped[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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)
|
||||||
@@ -40,10 +40,10 @@ async def http_client(db_session):
|
|||||||
|
|
||||||
## Database sessions in tests
|
## Database sessions in tests
|
||||||
|
|
||||||
Use [`create_db_session`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_db_session) to create an isolated `AsyncSession` for a test:
|
Use [`create_db_session`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_db_session) to create an isolated `AsyncSession` for a test, combined with [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) to set up a per-worker database:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.pytest import create_db_session, create_worker_database
|
from fastapi_toolsets.pytest import create_worker_database, create_db_session
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def worker_db_url():
|
async def worker_db_url():
|
||||||
@@ -64,16 +64,28 @@ async def db_session(worker_db_url):
|
|||||||
!!! info
|
!!! info
|
||||||
In this example, the database is reset between each test using the argument `cleanup=True`.
|
In this example, the database is reset between each test using the argument `cleanup=True`.
|
||||||
|
|
||||||
|
Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils.worker_database_url) to derive the per-worker URL manually if needed:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.pytest import worker_database_url
|
||||||
|
|
||||||
|
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/test_db", default_test_db="test")
|
||||||
|
# e.g. "postgresql+asyncpg://user:pass@localhost/test_db_gw0" under xdist
|
||||||
|
```
|
||||||
|
|
||||||
## Parallel testing with pytest-xdist
|
## Parallel testing with pytest-xdist
|
||||||
|
|
||||||
The examples above are already compatible with parallel test execution with `pytest-xdist`.
|
The examples above are already compatible with parallel test execution with `pytest-xdist`.
|
||||||
|
|
||||||
## Cleaning up tables
|
## Cleaning up tables
|
||||||
|
|
||||||
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/pytest.md#fastapi_toolsets.pytest.utils.cleanup_tables), this will truncates all tables between tests for fast isolation:
|
!!! warning
|
||||||
|
Since `V2.1.0` `cleanup_tables` now live in `fastapi_toolsets.db`. For backward compatibility the function is still available in `fastapi_toolsets.pytest`, but this will be remove in `V3.0.0`.
|
||||||
|
|
||||||
|
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables), this will truncate all tables between tests for fast isolation:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.pytest import cleanup_tables
|
from fastapi_toolsets.db import cleanup_tables
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
async def clean(db_session):
|
async def clean(db_session):
|
||||||
|
|||||||
@@ -22,16 +22,20 @@ async def get_user(user: User = UserDep) -> Response[UserSchema]:
|
|||||||
|
|
||||||
### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse)
|
### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse)
|
||||||
|
|
||||||
Wraps a list of items with pagination metadata.
|
Wraps a list of items with pagination metadata and optional facet values. The `pagination` field accepts either [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination) or [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination) depending on the strategy used.
|
||||||
|
|
||||||
|
#### [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination)
|
||||||
|
|
||||||
|
Page-number based. Requires `total_count` so clients can compute the total number of pages.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.schemas import PaginatedResponse, Pagination
|
from fastapi_toolsets.schemas import PaginatedResponse, OffsetPagination
|
||||||
|
|
||||||
@router.get("/users")
|
@router.get("/users")
|
||||||
async def list_users() -> PaginatedResponse[UserSchema]:
|
async def list_users() -> PaginatedResponse[UserSchema]:
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
data=users,
|
data=users,
|
||||||
pagination=Pagination(
|
pagination=OffsetPagination(
|
||||||
total_count=100,
|
total_count=100,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -40,6 +44,28 @@ async def list_users() -> PaginatedResponse[UserSchema]:
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination)
|
||||||
|
|
||||||
|
Cursor based. Efficient for large or frequently updated datasets where offset pagination is impractical. Provides opaque `next_cursor` / `prev_cursor` tokens; no total count is exposed.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.schemas import PaginatedResponse, CursorPagination
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
async def list_events() -> PaginatedResponse[EventSchema]:
|
||||||
|
return PaginatedResponse(
|
||||||
|
data=events,
|
||||||
|
pagination=CursorPagination(
|
||||||
|
next_cursor="eyJpZCI6IDQyfQ==",
|
||||||
|
prev_cursor=None,
|
||||||
|
items_per_page=20,
|
||||||
|
has_more=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#filter-attributes-facets)). It is `None` by default and can be hidden from API responses with `response_model_exclude_none=True`.
|
||||||
|
|
||||||
### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse)
|
### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse)
|
||||||
|
|
||||||
Returned automatically by the exceptions handler.
|
Returned automatically by the exceptions handler.
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ You can import them directly from `fastapi_toolsets.db`:
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import (
|
from fastapi_toolsets.db import (
|
||||||
LockMode,
|
LockMode,
|
||||||
|
cleanup_tables,
|
||||||
|
create_database,
|
||||||
create_db_dependency,
|
create_db_dependency,
|
||||||
create_db_context,
|
create_db_context,
|
||||||
get_transaction,
|
get_transaction,
|
||||||
@@ -26,3 +28,7 @@ from fastapi_toolsets.db import (
|
|||||||
## ::: fastapi_toolsets.db.lock_tables
|
## ::: fastapi_toolsets.db.lock_tables
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.wait_for_row_change
|
## ::: fastapi_toolsets.db.wait_for_row_change
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.create_database
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.cleanup_tables
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from fastapi_toolsets.exceptions import (
|
|||||||
NotFoundError,
|
NotFoundError,
|
||||||
ConflictError,
|
ConflictError,
|
||||||
NoSearchableFieldsError,
|
NoSearchableFieldsError,
|
||||||
|
InvalidFacetFilterError,
|
||||||
|
InvalidOrderFieldError,
|
||||||
generate_error_responses,
|
generate_error_responses,
|
||||||
init_exceptions_handlers,
|
init_exceptions_handlers,
|
||||||
)
|
)
|
||||||
@@ -29,6 +31,10 @@ from fastapi_toolsets.exceptions import (
|
|||||||
|
|
||||||
## ::: fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError
|
## ::: fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError
|
||||||
|
|
||||||
## ::: fastapi_toolsets.exceptions.exceptions.generate_error_responses
|
## ::: fastapi_toolsets.exceptions.exceptions.generate_error_responses
|
||||||
|
|
||||||
## ::: fastapi_toolsets.exceptions.handler.init_exceptions_handlers
|
## ::: fastapi_toolsets.exceptions.handler.init_exceptions_handlers
|
||||||
|
|||||||
22
docs/reference/models.md
Normal file
22
docs/reference/models.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# `models`
|
||||||
|
|
||||||
|
Here's the reference for the SQLAlchemy model mixins provided by the `models` module.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.models`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import (
|
||||||
|
UUIDMixin,
|
||||||
|
CreatedAtMixin,
|
||||||
|
UpdatedAtMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.models.UUIDMixin
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.models.CreatedAtMixin
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.models.UpdatedAtMixin
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.models.TimestampMixin
|
||||||
@@ -24,5 +24,3 @@ from fastapi_toolsets.pytest import (
|
|||||||
## ::: fastapi_toolsets.pytest.utils.worker_database_url
|
## ::: fastapi_toolsets.pytest.utils.worker_database_url
|
||||||
|
|
||||||
## ::: fastapi_toolsets.pytest.utils.create_worker_database
|
## ::: fastapi_toolsets.pytest.utils.create_worker_database
|
||||||
|
|
||||||
## ::: fastapi_toolsets.pytest.utils.cleanup_tables
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# `schemas` module
|
# `schemas`
|
||||||
|
|
||||||
Here's the reference for all response models and types provided by the `schemas` module.
|
Here's the reference for all response models and types provided by the `schemas` module.
|
||||||
|
|
||||||
@@ -12,7 +12,8 @@ from fastapi_toolsets.schemas import (
|
|||||||
BaseResponse,
|
BaseResponse,
|
||||||
Response,
|
Response,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
Pagination,
|
OffsetPagination,
|
||||||
|
CursorPagination,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -29,6 +30,8 @@ from fastapi_toolsets.schemas import (
|
|||||||
|
|
||||||
## ::: fastapi_toolsets.schemas.ErrorResponse
|
## ::: fastapi_toolsets.schemas.ErrorResponse
|
||||||
|
|
||||||
## ::: fastapi_toolsets.schemas.Pagination
|
## ::: fastapi_toolsets.schemas.OffsetPagination
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.schemas.CursorPagination
|
||||||
|
|
||||||
## ::: fastapi_toolsets.schemas.PaginatedResponse
|
## ::: fastapi_toolsets.schemas.PaginatedResponse
|
||||||
|
|||||||
0
docs_src/__init__.py
Normal file
0
docs_src/__init__.py
Normal file
0
docs_src/examples/__init__.py
Normal file
0
docs_src/examples/__init__.py
Normal file
0
docs_src/examples/pagination_search/__init__.py
Normal file
0
docs_src/examples/pagination_search/__init__.py
Normal file
9
docs_src/examples/pagination_search/app.py
Normal file
9
docs_src/examples/pagination_search/app.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||||
|
|
||||||
|
from .routes import router
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app=app)
|
||||||
|
app.include_router(router=router)
|
||||||
21
docs_src/examples/pagination_search/crud.py
Normal file
21
docs_src/examples/pagination_search/crud.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from fastapi_toolsets.crud import CrudFactory
|
||||||
|
|
||||||
|
from .models import Article, Category
|
||||||
|
|
||||||
|
ArticleCrud = CrudFactory(
|
||||||
|
model=Article,
|
||||||
|
cursor_column=Article.created_at,
|
||||||
|
searchable_fields=[ # default fields for full-text search
|
||||||
|
Article.title,
|
||||||
|
Article.body,
|
||||||
|
(Article.category, Category.name),
|
||||||
|
],
|
||||||
|
facet_fields=[ # fields exposed as filter dropdowns
|
||||||
|
Article.status,
|
||||||
|
(Article.category, Category.name),
|
||||||
|
],
|
||||||
|
order_fields=[ # fields exposed for client-driven ordering
|
||||||
|
Article.title,
|
||||||
|
Article.created_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
17
docs_src/examples/pagination_search/db.py
Normal file
17
docs_src/examples/pagination_search/db.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from fastapi_toolsets.db import create_db_context, create_db_dependency
|
||||||
|
|
||||||
|
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
|
||||||
|
|
||||||
|
engine = create_async_engine(url=DATABASE_URL, future=True)
|
||||||
|
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
get_db = create_db_dependency(session_maker=async_session_maker)
|
||||||
|
get_db_context = create_db_context(session_maker=async_session_maker)
|
||||||
|
|
||||||
|
|
||||||
|
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
||||||
36
docs_src/examples/pagination_search/models.py
Normal file
36
docs_src/examples/pagination_search/models.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
__tablename__ = "categories"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), unique=True)
|
||||||
|
|
||||||
|
articles: Mapped[list["Article"]] = relationship(back_populates="category")
|
||||||
|
|
||||||
|
|
||||||
|
class Article(Base):
|
||||||
|
__tablename__ = "articles"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
|
body: Mapped[str] = mapped_column(Text)
|
||||||
|
status: Mapped[str] = mapped_column(String(32))
|
||||||
|
published: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("categories.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
category: Mapped["Category | None"] = relationship(back_populates="articles")
|
||||||
59
docs_src/examples/pagination_search/routes.py
Normal file
59
docs_src/examples/pagination_search/routes.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from fastapi_toolsets.crud import OrderByClause
|
||||||
|
from fastapi_toolsets.schemas import PaginatedResponse
|
||||||
|
|
||||||
|
from .crud import ArticleCrud
|
||||||
|
from .db import SessionDep
|
||||||
|
from .models import Article
|
||||||
|
from .schemas import ArticleRead
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/articles")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/offset")
|
||||||
|
async def list_articles_offset(
|
||||||
|
session: SessionDep,
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
items_per_page: int = Query(20, ge=1, le=100),
|
||||||
|
search: str | None = None,
|
||||||
|
) -> PaginatedResponse[ArticleRead]:
|
||||||
|
return await ArticleCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
page=page,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
search=search,
|
||||||
|
filter_by=filter_by or None,
|
||||||
|
order_by=order_by,
|
||||||
|
schema=ArticleRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cursor")
|
||||||
|
async def list_articles_cursor(
|
||||||
|
session: SessionDep,
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
cursor: str | None = None,
|
||||||
|
items_per_page: int = Query(20, ge=1, le=100),
|
||||||
|
search: str | None = None,
|
||||||
|
) -> PaginatedResponse[ArticleRead]:
|
||||||
|
return await ArticleCrud.cursor_paginate(
|
||||||
|
session=session,
|
||||||
|
cursor=cursor,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
search=search,
|
||||||
|
filter_by=filter_by or None,
|
||||||
|
order_by=order_by,
|
||||||
|
schema=ArticleRead,
|
||||||
|
)
|
||||||
13
docs_src/examples/pagination_search/schemas.py
Normal file
13
docs_src/examples/pagination_search/schemas.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi_toolsets.schemas import PydanticBase
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleRead(PydanticBase):
|
||||||
|
id: uuid.UUID
|
||||||
|
created_at: datetime.datetime
|
||||||
|
title: str
|
||||||
|
status: str
|
||||||
|
published: bool
|
||||||
|
category_id: uuid.UUID | None
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "1.0.0"
|
version = "2.1.0"
|
||||||
description = "Reusable tools for FastAPI: async CRUD, fixtures, CLI, and standardized responses for SQLAlchemy + PostgreSQL"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -11,7 +11,7 @@ authors = [
|
|||||||
]
|
]
|
||||||
keywords = ["fastapi", "sqlalchemy", "postgresql"]
|
keywords = ["fastapi", "sqlalchemy", "postgresql"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 5 - Production/Stable",
|
||||||
"Framework :: AsyncIO",
|
"Framework :: AsyncIO",
|
||||||
"Framework :: FastAPI",
|
"Framework :: FastAPI",
|
||||||
"Framework :: Pydantic",
|
"Framework :: Pydantic",
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "2.1.0"
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ async def load(
|
|||||||
registry = get_fixtures_registry()
|
registry = get_fixtures_registry()
|
||||||
db_context = get_db_context()
|
db_context = get_db_context()
|
||||||
|
|
||||||
context_list = [c.value for c in contexts] if contexts else [Context.BASE]
|
context_list = list(contexts) if contexts else [Context.BASE]
|
||||||
|
|
||||||
ordered = registry.resolve_context_dependencies(*context_list)
|
ordered = registry.resolve_context_dependencies(*context_list)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""CLI configuration and dynamic imports."""
|
"""CLI configuration and dynamic imports."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING, Any, Literal, overload
|
from typing import TYPE_CHECKING, Any, Literal, overload
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
"""Generic async CRUD operations for SQLAlchemy models."""
|
"""Generic async CRUD operations for SQLAlchemy models."""
|
||||||
|
|
||||||
from ..exceptions import NoSearchableFieldsError
|
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
||||||
from .factory import CrudFactory, JoinType, M2MFieldType
|
from ..types import (
|
||||||
from .search import (
|
FacetFieldType,
|
||||||
SearchConfig,
|
JoinType,
|
||||||
get_searchable_fields,
|
M2MFieldType,
|
||||||
|
OrderByClause,
|
||||||
|
SearchFieldType,
|
||||||
)
|
)
|
||||||
|
from .factory import CrudFactory
|
||||||
|
from .search import SearchConfig, get_searchable_fields
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CrudFactory",
|
"CrudFactory",
|
||||||
|
"FacetFieldType",
|
||||||
"get_searchable_fields",
|
"get_searchable_fields",
|
||||||
|
"InvalidFacetFilterError",
|
||||||
"JoinType",
|
"JoinType",
|
||||||
"M2MFieldType",
|
"M2MFieldType",
|
||||||
"NoSearchableFieldsError",
|
"NoSearchableFieldsError",
|
||||||
|
"OrderByClause",
|
||||||
"SearchConfig",
|
"SearchConfig",
|
||||||
|
"SearchFieldType",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,26 +2,71 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping, Sequence
|
import base64
|
||||||
from typing import Any, ClassVar, Generic, Literal, Self, TypeVar, cast, overload
|
import inspect
|
||||||
|
import json
|
||||||
|
import uuid as uuid_module
|
||||||
|
from collections.abc import Awaitable, Callable, Sequence
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, ClassVar, Generic, Literal, Self, cast, overload
|
||||||
|
|
||||||
|
from fastapi import Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import Date, DateTime, Float, Integer, Numeric, Uuid, and_, func, select
|
||||||
from sqlalchemy import delete as sql_delete
|
from sqlalchemy import delete as sql_delete
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
from sqlalchemy.exc import NoResultFound
|
from sqlalchemy.exc import NoResultFound
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
||||||
|
from sqlalchemy.sql.base import ExecutableOption
|
||||||
from sqlalchemy.sql.roles import WhereHavingRole
|
from sqlalchemy.sql.roles import WhereHavingRole
|
||||||
|
|
||||||
from ..db import get_transaction
|
from ..db import get_transaction
|
||||||
from ..exceptions import NotFoundError
|
from ..exceptions import InvalidOrderFieldError, NotFoundError
|
||||||
from ..schemas import PaginatedResponse, Pagination, Response
|
from ..schemas import CursorPagination, OffsetPagination, PaginatedResponse, Response
|
||||||
from .search import SearchConfig, SearchFieldType, build_search_filters
|
from ..types import (
|
||||||
|
FacetFieldType,
|
||||||
|
JoinType,
|
||||||
|
M2MFieldType,
|
||||||
|
ModelType,
|
||||||
|
OrderByClause,
|
||||||
|
SchemaType,
|
||||||
|
SearchFieldType,
|
||||||
|
)
|
||||||
|
from .search import (
|
||||||
|
SearchConfig,
|
||||||
|
build_facets,
|
||||||
|
build_filter_by,
|
||||||
|
build_search_filters,
|
||||||
|
facet_keys,
|
||||||
|
)
|
||||||
|
|
||||||
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
|
||||||
JoinType = list[tuple[type[DeclarativeBase], Any]]
|
def _encode_cursor(value: Any) -> str:
|
||||||
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
"""Encode cursor column value as an base64 string."""
|
||||||
|
return base64.b64encode(json.dumps(str(value)).encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_cursor(cursor: str) -> str:
|
||||||
|
"""Decode cursor base64 string."""
|
||||||
|
return json.loads(base64.b64decode(cursor.encode()).decode())
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
|
||||||
|
"""Apply a list of (model, condition) joins to a SQLAlchemy select query."""
|
||||||
|
if not joins:
|
||||||
|
return q
|
||||||
|
for model, condition in joins:
|
||||||
|
q = q.outerjoin(model, condition) if outer_join else q.join(model, condition)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_search_joins(q: Any, search_joins: list[Any]) -> Any:
|
||||||
|
"""Apply relationship-based outer joins (from search/filter_by) to a query."""
|
||||||
|
for join_rel in search_joins:
|
||||||
|
q = q.outerjoin(join_rel)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
class AsyncCrud(Generic[ModelType]):
|
class AsyncCrud(Generic[ModelType]):
|
||||||
@@ -32,27 +77,20 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
|
|
||||||
model: ClassVar[type[DeclarativeBase]]
|
model: ClassVar[type[DeclarativeBase]]
|
||||||
searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
|
searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
|
||||||
|
facet_fields: ClassVar[Sequence[FacetFieldType] | None] = None
|
||||||
|
order_fields: ClassVar[Sequence[QueryableAttribute[Any]] | None] = None
|
||||||
m2m_fields: ClassVar[M2MFieldType | None] = None
|
m2m_fields: ClassVar[M2MFieldType | None] = None
|
||||||
|
default_load_options: ClassVar[list[ExecutableOption] | None] = None
|
||||||
|
cursor_column: ClassVar[Any | None] = None
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def create( # pragma: no cover
|
def _resolve_load_options(
|
||||||
cls: type[Self],
|
cls, load_options: list[ExecutableOption] | None
|
||||||
session: AsyncSession,
|
) -> list[ExecutableOption] | None:
|
||||||
obj: BaseModel,
|
"""Return load_options if provided, else fall back to default_load_options."""
|
||||||
*,
|
if load_options is not None:
|
||||||
as_response: Literal[True],
|
return load_options
|
||||||
) -> Response[ModelType]: ...
|
return cls.default_load_options
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
|
||||||
async def create( # pragma: no cover
|
|
||||||
cls: type[Self],
|
|
||||||
session: AsyncSession,
|
|
||||||
obj: BaseModel,
|
|
||||||
*,
|
|
||||||
as_response: Literal[False] = ...,
|
|
||||||
) -> ModelType: ...
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _resolve_m2m(
|
async def _resolve_m2m(
|
||||||
@@ -110,23 +148,189 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
return set()
|
return set()
|
||||||
return set(cls.m2m_fields.keys())
|
return set(cls.m2m_fields.keys())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_facet_fields(
|
||||||
|
cls: type[Self],
|
||||||
|
facet_fields: Sequence[FacetFieldType] | None,
|
||||||
|
) -> Sequence[FacetFieldType] | None:
|
||||||
|
"""Return facet_fields if given, otherwise fall back to the class-level default."""
|
||||||
|
return facet_fields if facet_fields is not None else cls.facet_fields
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _prepare_filter_by(
|
||||||
|
cls: type[Self],
|
||||||
|
filter_by: dict[str, Any] | BaseModel | None,
|
||||||
|
facet_fields: Sequence[FacetFieldType] | None,
|
||||||
|
) -> tuple[list[Any], list[Any]]:
|
||||||
|
"""Normalize filter_by and return (filters, joins) to apply to the query."""
|
||||||
|
if isinstance(filter_by, BaseModel):
|
||||||
|
filter_by = filter_by.model_dump(exclude_none=True)
|
||||||
|
if not filter_by:
|
||||||
|
return [], []
|
||||||
|
resolved = cls._resolve_facet_fields(facet_fields)
|
||||||
|
return build_filter_by(filter_by, resolved or [])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _build_filter_attributes(
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
facet_fields: Sequence[FacetFieldType] | None,
|
||||||
|
filters: list[Any],
|
||||||
|
search_joins: list[Any],
|
||||||
|
) -> dict[str, list[Any]] | None:
|
||||||
|
"""Build facet filter_attributes, or return None if no facet fields configured."""
|
||||||
|
resolved = cls._resolve_facet_fields(facet_fields)
|
||||||
|
if not resolved:
|
||||||
|
return None
|
||||||
|
return await build_facets(
|
||||||
|
session,
|
||||||
|
cls.model,
|
||||||
|
resolved,
|
||||||
|
base_filters=filters,
|
||||||
|
base_joins=search_joins,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def filter_params(
|
||||||
|
cls: type[Self],
|
||||||
|
*,
|
||||||
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
) -> Callable[..., Awaitable[dict[str, list[str]]]]:
|
||||||
|
"""Return a FastAPI dependency that collects facet filter values from query parameters.
|
||||||
|
Args:
|
||||||
|
facet_fields: Override the facet fields for this dependency. Falls back to the
|
||||||
|
class-level ``facet_fields`` if not provided.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An async dependency function named ``{Model}FilterParams`` that resolves to a
|
||||||
|
``dict[str, list[str]]`` containing only the keys that were supplied in the
|
||||||
|
request (absent/``None`` parameters are excluded).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If no facet fields are configured on this CRUD class and none are
|
||||||
|
provided via ``facet_fields``.
|
||||||
|
"""
|
||||||
|
fields = cls._resolve_facet_fields(facet_fields)
|
||||||
|
if not fields:
|
||||||
|
raise ValueError(
|
||||||
|
f"{cls.__name__} has no facet_fields configured. "
|
||||||
|
"Pass facet_fields= or set them on CrudFactory."
|
||||||
|
)
|
||||||
|
keys = facet_keys(fields)
|
||||||
|
|
||||||
|
async def dependency(**kwargs: Any) -> dict[str, list[str]]:
|
||||||
|
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]
|
||||||
|
parameters=[
|
||||||
|
inspect.Parameter(
|
||||||
|
k,
|
||||||
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=list[str] | None,
|
||||||
|
default=Query(default=None),
|
||||||
|
)
|
||||||
|
for k in keys
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def order_params(
|
||||||
|
cls: type[Self],
|
||||||
|
*,
|
||||||
|
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||||
|
default_field: QueryableAttribute[Any] | None = None,
|
||||||
|
default_order: Literal["asc", "desc"] = "asc",
|
||||||
|
) -> Callable[..., Awaitable[OrderByClause | None]]:
|
||||||
|
"""Return a FastAPI dependency that resolves order query params into an order_by clause.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
order_fields: Override the allowed order fields. Falls back to the class-level
|
||||||
|
``order_fields`` if not provided.
|
||||||
|
default_field: Field to order by when ``order_by`` query param is absent.
|
||||||
|
If ``None`` and no ``order_by`` is provided, no ordering is applied.
|
||||||
|
default_order: Default order direction when ``order`` is absent
|
||||||
|
(``"asc"`` or ``"desc"``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An async dependency function named ``{Model}OrderParams`` that resolves to an
|
||||||
|
``OrderByClause`` (or ``None``). Pass it to ``Depends()`` in your route.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If no order fields are configured on this CRUD class and none are
|
||||||
|
provided via ``order_fields``.
|
||||||
|
InvalidOrderFieldError: When the request provides an unknown ``order_by`` value.
|
||||||
|
"""
|
||||||
|
fields = order_fields if order_fields is not None else cls.order_fields
|
||||||
|
if not fields:
|
||||||
|
raise ValueError(
|
||||||
|
f"{cls.__name__} has no order_fields configured. "
|
||||||
|
"Pass order_fields= or set them on CrudFactory."
|
||||||
|
)
|
||||||
|
field_map: dict[str, QueryableAttribute[Any]] = {f.key: f for f in fields}
|
||||||
|
valid_keys = sorted(field_map.keys())
|
||||||
|
|
||||||
|
async def dependency(
|
||||||
|
order_by: str | None = Query(
|
||||||
|
None, description=f"Field to order by. Valid values: {valid_keys}"
|
||||||
|
),
|
||||||
|
order: Literal["asc", "desc"] = Query(
|
||||||
|
default_order, description="Sort direction"
|
||||||
|
),
|
||||||
|
) -> OrderByClause | None:
|
||||||
|
if order_by is None:
|
||||||
|
if default_field is None:
|
||||||
|
return None
|
||||||
|
field = default_field
|
||||||
|
elif order_by not in field_map:
|
||||||
|
raise InvalidOrderFieldError(order_by, valid_keys)
|
||||||
|
else:
|
||||||
|
field = field_map[order_by]
|
||||||
|
return field.asc() if order == "asc" else field.desc()
|
||||||
|
|
||||||
|
dependency.__name__ = f"{cls.model.__name__}OrderParams"
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def create( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
obj: BaseModel,
|
||||||
|
*,
|
||||||
|
schema: type[SchemaType],
|
||||||
|
) -> Response[SchemaType]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def create( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
obj: BaseModel,
|
||||||
|
*,
|
||||||
|
schema: None = ...,
|
||||||
|
) -> ModelType: ...
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def create(
|
async def create(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
obj: BaseModel,
|
obj: BaseModel,
|
||||||
*,
|
*,
|
||||||
as_response: bool = False,
|
schema: type[BaseModel] | None = None,
|
||||||
) -> ModelType | Response[ModelType]:
|
) -> ModelType | Response[Any]:
|
||||||
"""Create a new record in the database.
|
"""Create a new record in the database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: DB async session
|
session: DB async session
|
||||||
obj: Pydantic model with data to create
|
obj: Pydantic model with data to create
|
||||||
as_response: If True, wrap result in Response object
|
schema: Pydantic schema to serialize the result into. When provided,
|
||||||
|
the result is automatically wrapped in a ``Response[schema]``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created model instance or Response wrapping it
|
Created model instance, or ``Response[schema]`` when ``schema`` is given.
|
||||||
"""
|
"""
|
||||||
async with get_transaction(session):
|
async with get_transaction(session):
|
||||||
m2m_exclude = cls._m2m_schema_fields()
|
m2m_exclude = cls._m2m_schema_fields()
|
||||||
@@ -143,8 +347,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
session.add(db_model)
|
session.add(db_model)
|
||||||
await session.refresh(db_model)
|
await session.refresh(db_model)
|
||||||
result = cast(ModelType, db_model)
|
result = cast(ModelType, db_model)
|
||||||
if as_response:
|
if schema:
|
||||||
return Response(data=result)
|
return Response(data=schema.model_validate(result))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -157,9 +361,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: bool = False,
|
with_for_update: bool = False,
|
||||||
load_options: list[Any] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
as_response: Literal[True],
|
schema: type[SchemaType],
|
||||||
) -> Response[ModelType]: ...
|
) -> Response[SchemaType]: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -171,8 +375,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: bool = False,
|
with_for_update: bool = False,
|
||||||
load_options: list[Any] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
as_response: Literal[False] = ...,
|
schema: None = ...,
|
||||||
) -> ModelType: ...
|
) -> ModelType: ...
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -184,9 +388,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: bool = False,
|
with_for_update: bool = False,
|
||||||
load_options: list[Any] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
as_response: bool = False,
|
schema: type[BaseModel] | None = None,
|
||||||
) -> ModelType | Response[ModelType]:
|
) -> ModelType | Response[Any]:
|
||||||
"""Get exactly one record. Raises NotFoundError if not found.
|
"""Get exactly one record. Raises NotFoundError if not found.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -196,26 +400,21 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
|
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
|
||||||
with_for_update: Lock the row for update
|
with_for_update: Lock the row for update
|
||||||
load_options: SQLAlchemy loader options (e.g., selectinload)
|
load_options: SQLAlchemy loader options (e.g., selectinload)
|
||||||
as_response: If True, wrap result in Response object
|
schema: Pydantic schema to serialize the result into. When provided,
|
||||||
|
the result is automatically wrapped in a ``Response[schema]``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Model instance or Response wrapping it
|
Model instance, or ``Response[schema]`` when ``schema`` is given.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotFoundError: If no record found
|
NotFoundError: If no record found
|
||||||
MultipleResultsFound: If more than one record found
|
MultipleResultsFound: If more than one record found
|
||||||
"""
|
"""
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
if joins:
|
q = _apply_joins(q, joins, outer_join)
|
||||||
for model, condition in joins:
|
|
||||||
q = (
|
|
||||||
q.outerjoin(model, condition)
|
|
||||||
if outer_join
|
|
||||||
else q.join(model, condition)
|
|
||||||
)
|
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if load_options:
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
q = q.options(*load_options)
|
q = q.options(*resolved)
|
||||||
if with_for_update:
|
if with_for_update:
|
||||||
q = q.with_for_update()
|
q = q.with_for_update()
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
@@ -223,8 +422,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
if not item:
|
if not item:
|
||||||
raise NotFoundError()
|
raise NotFoundError()
|
||||||
result = cast(ModelType, item)
|
result = cast(ModelType, item)
|
||||||
if as_response:
|
if schema:
|
||||||
return Response(data=result)
|
return Response(data=schema.model_validate(result))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -235,7 +434,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
load_options: list[Any] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
) -> ModelType | None:
|
) -> ModelType | None:
|
||||||
"""Get the first matching record, or None.
|
"""Get the first matching record, or None.
|
||||||
|
|
||||||
@@ -250,17 +449,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Model instance or None
|
Model instance or None
|
||||||
"""
|
"""
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
if joins:
|
q = _apply_joins(q, joins, outer_join)
|
||||||
for model, condition in joins:
|
|
||||||
q = (
|
|
||||||
q.outerjoin(model, condition)
|
|
||||||
if outer_join
|
|
||||||
else q.join(model, condition)
|
|
||||||
)
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if load_options:
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
q = q.options(*load_options)
|
q = q.options(*resolved)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
return cast(ModelType | None, result.unique().scalars().first())
|
return cast(ModelType | None, result.unique().scalars().first())
|
||||||
|
|
||||||
@@ -272,8 +465,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters: list[Any] | None = None,
|
filters: list[Any] | None = None,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
load_options: list[Any] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
order_by: Any | None = None,
|
order_by: OrderByClause | None = None,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
offset: int | None = None,
|
offset: int | None = None,
|
||||||
) -> Sequence[ModelType]:
|
) -> Sequence[ModelType]:
|
||||||
@@ -293,17 +486,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
List of model instances
|
List of model instances
|
||||||
"""
|
"""
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
if joins:
|
q = _apply_joins(q, joins, outer_join)
|
||||||
for model, condition in joins:
|
|
||||||
q = (
|
|
||||||
q.outerjoin(model, condition)
|
|
||||||
if outer_join
|
|
||||||
else q.join(model, condition)
|
|
||||||
)
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if load_options:
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
q = q.options(*load_options)
|
q = q.options(*resolved)
|
||||||
if order_by is not None:
|
if order_by is not None:
|
||||||
q = q.order_by(order_by)
|
q = q.order_by(order_by)
|
||||||
if offset is not None:
|
if offset is not None:
|
||||||
@@ -323,8 +510,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
exclude_unset: bool = True,
|
exclude_unset: bool = True,
|
||||||
exclude_none: bool = False,
|
exclude_none: bool = False,
|
||||||
as_response: Literal[True],
|
schema: type[SchemaType],
|
||||||
) -> Response[ModelType]: ...
|
) -> Response[SchemaType]: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -336,7 +523,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
exclude_unset: bool = True,
|
exclude_unset: bool = True,
|
||||||
exclude_none: bool = False,
|
exclude_none: bool = False,
|
||||||
as_response: Literal[False] = ...,
|
schema: None = ...,
|
||||||
) -> ModelType: ...
|
) -> ModelType: ...
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -348,8 +535,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
exclude_unset: bool = True,
|
exclude_unset: bool = True,
|
||||||
exclude_none: bool = False,
|
exclude_none: bool = False,
|
||||||
as_response: bool = False,
|
schema: type[BaseModel] | None = None,
|
||||||
) -> ModelType | Response[ModelType]:
|
) -> ModelType | Response[Any]:
|
||||||
"""Update a record in the database.
|
"""Update a record in the database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -358,10 +545,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters: List of SQLAlchemy filter conditions
|
filters: List of SQLAlchemy filter conditions
|
||||||
exclude_unset: Exclude fields not explicitly set in the schema
|
exclude_unset: Exclude fields not explicitly set in the schema
|
||||||
exclude_none: Exclude fields with None value
|
exclude_none: Exclude fields with None value
|
||||||
as_response: If True, wrap result in Response object
|
schema: Pydantic schema to serialize the result into. When provided,
|
||||||
|
the result is automatically wrapped in a ``Response[schema]``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated model instance or Response wrapping it
|
Updated model instance, or ``Response[schema]`` when ``schema`` is given.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotFoundError: If no record found
|
NotFoundError: If no record found
|
||||||
@@ -371,7 +559,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
|
|
||||||
# Eagerly load M2M relationships that will be updated so that
|
# Eagerly load M2M relationships that will be updated so that
|
||||||
# setattr does not trigger a lazy load (which fails in async).
|
# setattr does not trigger a lazy load (which fails in async).
|
||||||
m2m_load_options: list[Any] = []
|
m2m_load_options: list[ExecutableOption] = []
|
||||||
if m2m_exclude and cls.m2m_fields:
|
if m2m_exclude and cls.m2m_fields:
|
||||||
for schema_field, rel in cls.m2m_fields.items():
|
for schema_field, rel in cls.m2m_fields.items():
|
||||||
if schema_field in obj.model_fields_set:
|
if schema_field in obj.model_fields_set:
|
||||||
@@ -395,8 +583,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
for rel_attr, related_instances in m2m_resolved.items():
|
for rel_attr, related_instances in m2m_resolved.items():
|
||||||
setattr(db_model, rel_attr, related_instances)
|
setattr(db_model, rel_attr, related_instances)
|
||||||
await session.refresh(db_model)
|
await session.refresh(db_model)
|
||||||
if as_response:
|
if schema:
|
||||||
return Response(data=db_model)
|
return Response(data=schema.model_validate(db_model))
|
||||||
return db_model
|
return db_model
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -452,7 +640,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
filters: list[Any],
|
filters: list[Any],
|
||||||
*,
|
*,
|
||||||
as_response: Literal[True],
|
return_response: Literal[True],
|
||||||
) -> Response[None]: ...
|
) -> Response[None]: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -462,8 +650,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
filters: list[Any],
|
filters: list[Any],
|
||||||
*,
|
*,
|
||||||
as_response: Literal[False] = ...,
|
return_response: Literal[False] = ...,
|
||||||
) -> bool: ...
|
) -> None: ...
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def delete(
|
async def delete(
|
||||||
@@ -471,24 +659,26 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
filters: list[Any],
|
filters: list[Any],
|
||||||
*,
|
*,
|
||||||
as_response: bool = False,
|
return_response: bool = False,
|
||||||
) -> bool | Response[None]:
|
) -> None | Response[None]:
|
||||||
"""Delete records from the database.
|
"""Delete records from the database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: DB async session
|
session: DB async session
|
||||||
filters: List of SQLAlchemy filter conditions
|
filters: List of SQLAlchemy filter conditions
|
||||||
as_response: If True, wrap result in Response object
|
return_response: When ``True``, returns ``Response[None]`` instead
|
||||||
|
of ``None``. Useful for API endpoints that expect a consistent
|
||||||
|
response envelope.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if deletion was executed, or Response wrapping it
|
``None``, or ``Response[None]`` when ``return_response=True``.
|
||||||
"""
|
"""
|
||||||
async with get_transaction(session):
|
async with get_transaction(session):
|
||||||
q = sql_delete(cls.model).where(and_(*filters))
|
q = sql_delete(cls.model).where(and_(*filters))
|
||||||
await session.execute(q)
|
await session.execute(q)
|
||||||
if as_response:
|
if return_response:
|
||||||
return Response(data=None)
|
return Response(data=None)
|
||||||
return True
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count(
|
async def count(
|
||||||
@@ -511,13 +701,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Number of matching records
|
Number of matching records
|
||||||
"""
|
"""
|
||||||
q = select(func.count()).select_from(cls.model)
|
q = select(func.count()).select_from(cls.model)
|
||||||
if joins:
|
q = _apply_joins(q, joins, outer_join)
|
||||||
for model, condition in joins:
|
|
||||||
q = (
|
|
||||||
q.outerjoin(model, condition)
|
|
||||||
if outer_join
|
|
||||||
else q.join(model, condition)
|
|
||||||
)
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
@@ -544,33 +728,30 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
True if at least one record matches
|
True if at least one record matches
|
||||||
"""
|
"""
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
if joins:
|
q = _apply_joins(q, joins, outer_join)
|
||||||
for model, condition in joins:
|
|
||||||
q = (
|
|
||||||
q.outerjoin(model, condition)
|
|
||||||
if outer_join
|
|
||||||
else q.join(model, condition)
|
|
||||||
)
|
|
||||||
q = q.where(and_(*filters)).exists().select()
|
q = q.where(and_(*filters)).exists().select()
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
return bool(result.scalar())
|
return bool(result.scalar())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def paginate(
|
async def offset_paginate(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
filters: list[Any] | None = None,
|
filters: list[Any] | None = None,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
load_options: list[Any] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
order_by: Any | None = None,
|
order_by: OrderByClause | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
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,
|
||||||
) -> PaginatedResponse[ModelType]:
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
"""Get paginated results with metadata.
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
|
schema: type[BaseModel],
|
||||||
|
) -> PaginatedResponse[Any]:
|
||||||
|
"""Get paginated results using offset-based pagination.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: DB async session
|
session: DB async session
|
||||||
@@ -583,50 +764,52 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
items_per_page: Number of items per page
|
items_per_page: Number of items per page
|
||||||
search: Search query string or SearchConfig object
|
search: Search query string or SearchConfig object
|
||||||
search_fields: Fields to search in (overrides class default)
|
search_fields: Fields to search in (overrides class default)
|
||||||
|
facet_fields: Columns to compute distinct values for (overrides class default)
|
||||||
|
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,
|
||||||
|
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
||||||
|
schema: Pydantic schema to serialize each item into.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with 'data' and 'pagination' keys
|
PaginatedResponse with OffsetPagination metadata
|
||||||
"""
|
"""
|
||||||
filters = list(filters) if filters else []
|
filters = list(filters) if filters else []
|
||||||
offset = (page - 1) * items_per_page
|
offset = (page - 1) * items_per_page
|
||||||
search_joins: list[Any] = []
|
|
||||||
|
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
||||||
|
filters.extend(fb_filters)
|
||||||
|
|
||||||
# Build search filters
|
# Build search filters
|
||||||
if search:
|
if search:
|
||||||
search_filters, search_joins = build_search_filters(
|
search_filters, new_search_joins = build_search_filters(
|
||||||
cls.model,
|
cls.model,
|
||||||
search,
|
search,
|
||||||
search_fields=search_fields,
|
search_fields=search_fields,
|
||||||
default_fields=cls.searchable_fields,
|
default_fields=cls.searchable_fields,
|
||||||
)
|
)
|
||||||
filters.extend(search_filters)
|
filters.extend(search_filters)
|
||||||
|
search_joins.extend(new_search_joins)
|
||||||
|
|
||||||
# Build query with joins
|
# Build query with joins
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
|
|
||||||
# Apply explicit joins
|
# Apply explicit joins
|
||||||
if joins:
|
q = _apply_joins(q, joins, outer_join)
|
||||||
for model, condition in joins:
|
|
||||||
q = (
|
|
||||||
q.outerjoin(model, condition)
|
|
||||||
if outer_join
|
|
||||||
else q.join(model, condition)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply search joins (always outer joins for search)
|
# Apply search joins (always outer joins for search)
|
||||||
for join_rel in search_joins:
|
q = _apply_search_joins(q, search_joins)
|
||||||
q = q.outerjoin(join_rel)
|
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if load_options:
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
q = q.options(*load_options)
|
q = q.options(*resolved)
|
||||||
if order_by is not None:
|
if order_by is not None:
|
||||||
q = q.order_by(order_by)
|
q = q.order_by(order_by)
|
||||||
|
|
||||||
q = q.offset(offset).limit(items_per_page)
|
q = q.offset(offset).limit(items_per_page)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
items = cast(list[ModelType], result.unique().scalars().all())
|
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
||||||
|
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
||||||
|
|
||||||
# Count query (with same joins and filters)
|
# Count query (with same joins and filters)
|
||||||
pk_col = cls.model.__mapper__.primary_key[0]
|
pk_col = cls.model.__mapper__.primary_key[0]
|
||||||
@@ -634,17 +817,10 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
count_q = count_q.select_from(cls.model)
|
count_q = count_q.select_from(cls.model)
|
||||||
|
|
||||||
# Apply explicit joins to count query
|
# Apply explicit joins to count query
|
||||||
if joins:
|
count_q = _apply_joins(count_q, joins, outer_join)
|
||||||
for model, condition in joins:
|
|
||||||
count_q = (
|
|
||||||
count_q.outerjoin(model, condition)
|
|
||||||
if outer_join
|
|
||||||
else count_q.join(model, condition)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply search joins to count query
|
# Apply search joins to count query
|
||||||
for join_rel in search_joins:
|
count_q = _apply_search_joins(count_q, search_joins)
|
||||||
count_q = count_q.outerjoin(join_rel)
|
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
count_q = count_q.where(and_(*filters))
|
count_q = count_q.where(and_(*filters))
|
||||||
@@ -652,14 +828,161 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
count_result = await session.execute(count_q)
|
count_result = await session.execute(count_q)
|
||||||
total_count = count_result.scalar_one()
|
total_count = count_result.scalar_one()
|
||||||
|
|
||||||
|
filter_attributes = await cls._build_filter_attributes(
|
||||||
|
session, facet_fields, filters, search_joins
|
||||||
|
)
|
||||||
|
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
data=items,
|
data=items,
|
||||||
pagination=Pagination(
|
pagination=OffsetPagination(
|
||||||
total_count=total_count,
|
total_count=total_count,
|
||||||
items_per_page=items_per_page,
|
items_per_page=items_per_page,
|
||||||
page=page,
|
page=page,
|
||||||
has_more=page * items_per_page < total_count,
|
has_more=page * items_per_page < total_count,
|
||||||
),
|
),
|
||||||
|
filter_attributes=filter_attributes,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def cursor_paginate(
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
cursor: str | None = None,
|
||||||
|
filters: list[Any] | None = None,
|
||||||
|
joins: JoinType | None = None,
|
||||||
|
outer_join: bool = False,
|
||||||
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
order_by: OrderByClause | None = None,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
search: str | SearchConfig | None = None,
|
||||||
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
|
schema: type[BaseModel],
|
||||||
|
) -> PaginatedResponse[Any]:
|
||||||
|
"""Get paginated results using cursor-based pagination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB async session.
|
||||||
|
cursor: Cursor string from a previous ``CursorPagination``.
|
||||||
|
Omit (or pass ``None``) to start from the beginning.
|
||||||
|
filters: List of SQLAlchemy filter conditions.
|
||||||
|
joins: List of ``(model, condition)`` tuples for joining related
|
||||||
|
tables.
|
||||||
|
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN.
|
||||||
|
load_options: SQLAlchemy loader options. Falls back to
|
||||||
|
``default_load_options`` when not provided.
|
||||||
|
order_by: Additional ordering applied after the cursor column.
|
||||||
|
items_per_page: Number of items per page (default 20).
|
||||||
|
search: Search query string or SearchConfig object.
|
||||||
|
search_fields: Fields to search in (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.
|
||||||
|
Keys must match the column.key of a facet field. Scalar → equality,
|
||||||
|
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
||||||
|
schema: Optional Pydantic schema to serialize each item into.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedResponse with CursorPagination metadata
|
||||||
|
"""
|
||||||
|
filters = list(filters) if filters else []
|
||||||
|
|
||||||
|
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
||||||
|
filters.extend(fb_filters)
|
||||||
|
|
||||||
|
if cls.cursor_column is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"{cls.__name__}.cursor_column is not set. "
|
||||||
|
"Pass cursor_column=<column> to CrudFactory() to use cursor_paginate."
|
||||||
|
)
|
||||||
|
cursor_column: Any = cls.cursor_column
|
||||||
|
cursor_col_name: str = cursor_column.key
|
||||||
|
|
||||||
|
if cursor is not None:
|
||||||
|
raw_val = _decode_cursor(cursor)
|
||||||
|
col_type = cursor_column.property.columns[0].type
|
||||||
|
if isinstance(col_type, Integer):
|
||||||
|
cursor_val: Any = int(raw_val)
|
||||||
|
elif isinstance(col_type, Uuid):
|
||||||
|
cursor_val = uuid_module.UUID(raw_val)
|
||||||
|
elif isinstance(col_type, DateTime):
|
||||||
|
cursor_val = datetime.fromisoformat(raw_val)
|
||||||
|
elif isinstance(col_type, Date):
|
||||||
|
cursor_val = date.fromisoformat(raw_val)
|
||||||
|
elif isinstance(col_type, (Float, Numeric)):
|
||||||
|
cursor_val = Decimal(raw_val)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported cursor column type: {type(col_type).__name__!r}. "
|
||||||
|
"Supported types: Integer, BigInteger, SmallInteger, Uuid, "
|
||||||
|
"DateTime, Date, Float, Numeric."
|
||||||
|
)
|
||||||
|
filters.append(cursor_column > cursor_val)
|
||||||
|
|
||||||
|
# Build search filters
|
||||||
|
if search:
|
||||||
|
search_filters, new_search_joins = build_search_filters(
|
||||||
|
cls.model,
|
||||||
|
search,
|
||||||
|
search_fields=search_fields,
|
||||||
|
default_fields=cls.searchable_fields,
|
||||||
|
)
|
||||||
|
filters.extend(search_filters)
|
||||||
|
search_joins.extend(new_search_joins)
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
q = select(cls.model)
|
||||||
|
|
||||||
|
# Apply explicit joins
|
||||||
|
q = _apply_joins(q, joins, outer_join)
|
||||||
|
|
||||||
|
# Apply search joins (always outer joins)
|
||||||
|
q = _apply_search_joins(q, search_joins)
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
q = q.where(and_(*filters))
|
||||||
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
|
q = q.options(*resolved)
|
||||||
|
|
||||||
|
# Cursor column is always the primary sort
|
||||||
|
q = q.order_by(cursor_column)
|
||||||
|
if order_by is not None:
|
||||||
|
q = q.order_by(order_by)
|
||||||
|
|
||||||
|
# Fetch one extra to detect whether a next page exists
|
||||||
|
q = q.limit(items_per_page + 1)
|
||||||
|
result = await session.execute(q)
|
||||||
|
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
||||||
|
|
||||||
|
has_more = len(raw_items) > items_per_page
|
||||||
|
items_page = raw_items[:items_per_page]
|
||||||
|
|
||||||
|
# next_cursor points past the last item on this page
|
||||||
|
next_cursor: str | None = None
|
||||||
|
if has_more and items_page:
|
||||||
|
next_cursor = _encode_cursor(getattr(items_page[-1], cursor_col_name))
|
||||||
|
|
||||||
|
# prev_cursor points to the first item on this page or None when on the first page
|
||||||
|
prev_cursor: str | None = None
|
||||||
|
if cursor is not None and items_page:
|
||||||
|
prev_cursor = _encode_cursor(getattr(items_page[0], cursor_col_name))
|
||||||
|
|
||||||
|
items: list[Any] = [schema.model_validate(item) for item in items_page]
|
||||||
|
|
||||||
|
filter_attributes = await cls._build_filter_attributes(
|
||||||
|
session, facet_fields, filters, search_joins
|
||||||
|
)
|
||||||
|
|
||||||
|
return PaginatedResponse(
|
||||||
|
data=items,
|
||||||
|
pagination=CursorPagination(
|
||||||
|
next_cursor=next_cursor,
|
||||||
|
prev_cursor=prev_cursor,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
has_more=has_more,
|
||||||
|
),
|
||||||
|
filter_attributes=filter_attributes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -667,16 +990,33 @@ def CrudFactory(
|
|||||||
model: type[ModelType],
|
model: type[ModelType],
|
||||||
*,
|
*,
|
||||||
searchable_fields: Sequence[SearchFieldType] | None = None,
|
searchable_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||||
m2m_fields: M2MFieldType | None = None,
|
m2m_fields: M2MFieldType | None = None,
|
||||||
|
default_load_options: list[ExecutableOption] | None = None,
|
||||||
|
cursor_column: Any | None = None,
|
||||||
) -> type[AsyncCrud[ModelType]]:
|
) -> type[AsyncCrud[ModelType]]:
|
||||||
"""Create a CRUD class for a specific model.
|
"""Create a CRUD class for a specific model.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model: SQLAlchemy model class
|
model: SQLAlchemy model class
|
||||||
searchable_fields: Optional list of searchable fields
|
searchable_fields: Optional list of searchable fields
|
||||||
|
facet_fields: Optional list of columns to compute distinct values for in paginated
|
||||||
|
responses. Supports direct columns (``User.status``) and relationship tuples
|
||||||
|
(``(User.role, Role.name)``). Can be overridden per call.
|
||||||
|
order_fields: Optional list of model attributes that callers are allowed to order by
|
||||||
|
via ``order_params()``. Can be overridden per call.
|
||||||
m2m_fields: Optional mapping for many-to-many relationships.
|
m2m_fields: Optional mapping for many-to-many relationships.
|
||||||
Maps schema field names (containing lists of IDs) to
|
Maps schema field names (containing lists of IDs) to
|
||||||
SQLAlchemy relationship attributes.
|
SQLAlchemy relationship attributes.
|
||||||
|
default_load_options: Default SQLAlchemy loader options applied to all read
|
||||||
|
queries when no explicit ``load_options`` are passed. Use this
|
||||||
|
instead of ``lazy="selectin"`` on the model so that loading
|
||||||
|
strategy is explicit and per-CRUD. Overridden entirely (not
|
||||||
|
merged) when ``load_options`` is provided at call-site.
|
||||||
|
cursor_column: Required to call ``cursor_paginate``.
|
||||||
|
Must be monotonically ordered (e.g. integer PK, UUID v7, timestamp).
|
||||||
|
See the cursor pagination docs for supported column types.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AsyncCrud subclass bound to the model
|
AsyncCrud subclass bound to the model
|
||||||
@@ -702,6 +1042,31 @@ def CrudFactory(
|
|||||||
m2m_fields={"tag_ids": Post.tags},
|
m2m_fields={"tag_ids": Post.tags},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# With facet fields for filter dropdowns / faceted search:
|
||||||
|
UserCrud = CrudFactory(
|
||||||
|
User,
|
||||||
|
facet_fields=[User.status, User.country, (User.role, Role.name)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# With a fixed cursor column for cursor_paginate:
|
||||||
|
PostCrud = CrudFactory(
|
||||||
|
Post,
|
||||||
|
cursor_column=Post.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# With default load strategy (replaces lazy="selectin" on the model):
|
||||||
|
ArticleCrud = CrudFactory(
|
||||||
|
Article,
|
||||||
|
default_load_options=[selectinload(Article.category), selectinload(Article.tags)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Override default_load_options for a specific call:
|
||||||
|
article = await ArticleCrud.get(
|
||||||
|
session,
|
||||||
|
[Article.id == 1],
|
||||||
|
load_options=[selectinload(Article.category)], # tags won't load
|
||||||
|
)
|
||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
user = await UserCrud.get(session, [User.id == 1])
|
user = await UserCrud.get(session, [User.id == 1])
|
||||||
posts = await PostCrud.get_multi(session, filters=[Post.user_id == user.id])
|
posts = await PostCrud.get_multi(session, filters=[Post.user_id == user.id])
|
||||||
@@ -710,7 +1075,7 @@ def CrudFactory(
|
|||||||
post = await PostCrud.create(session, PostCreate(title="Hello", tag_ids=[id1, id2]))
|
post = await PostCrud.create(session, PostCreate(title="Hello", tag_ids=[id1, id2]))
|
||||||
|
|
||||||
# With search
|
# With search
|
||||||
result = await UserCrud.paginate(session, search="john")
|
result = await UserCrud.offset_paginate(session, search="john")
|
||||||
|
|
||||||
# With joins (inner join by default):
|
# With joins (inner join by default):
|
||||||
users = await UserCrud.get_multi(
|
users = await UserCrud.get_multi(
|
||||||
@@ -733,7 +1098,11 @@ def CrudFactory(
|
|||||||
{
|
{
|
||||||
"model": model,
|
"model": model,
|
||||||
"searchable_fields": searchable_fields,
|
"searchable_fields": searchable_fields,
|
||||||
|
"facet_fields": facet_fields,
|
||||||
|
"order_fields": order_fields,
|
||||||
"m2m_fields": m2m_fields,
|
"m2m_fields": m2m_fields,
|
||||||
|
"default_load_options": default_load_options,
|
||||||
|
"cursor_column": cursor_column,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return cast(type[AsyncCrud[ModelType]], cls)
|
return cast(type[AsyncCrud[ModelType]], cls)
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
"""Search utilities for AsyncCrud."""
|
"""Search utilities for AsyncCrud."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import functools
|
||||||
|
from collections import Counter
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
from sqlalchemy import String, or_
|
from sqlalchemy import String, and_, or_, select
|
||||||
|
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 ..exceptions import NoSearchableFieldsError
|
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
||||||
|
from ..types import FacetFieldType, SearchFieldType
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlalchemy.sql.elements import ColumnElement
|
from sqlalchemy.sql.elements import ColumnElement
|
||||||
|
|
||||||
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SearchConfig:
|
class SearchConfig:
|
||||||
@@ -33,6 +36,7 @@ class SearchConfig:
|
|||||||
match_mode: Literal["any", "all"] = "any"
|
match_mode: Literal["any", "all"] = "any"
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=128)
|
||||||
def get_searchable_fields(
|
def get_searchable_fields(
|
||||||
model: type[DeclarativeBase],
|
model: type[DeclarativeBase],
|
||||||
*,
|
*,
|
||||||
@@ -89,18 +93,18 @@ def build_search_filters(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (filter_conditions, joins_needed)
|
Tuple of (filter_conditions, joins_needed)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NoSearchableFieldsError: If no searchable field has been configured
|
||||||
"""
|
"""
|
||||||
# Normalize input
|
# Normalize input
|
||||||
if isinstance(search, str):
|
if isinstance(search, str):
|
||||||
config = SearchConfig(query=search, fields=search_fields)
|
config = SearchConfig(query=search, fields=search_fields)
|
||||||
else:
|
else:
|
||||||
config = search
|
config = (
|
||||||
if search_fields is not None:
|
replace(search, fields=search_fields)
|
||||||
config = SearchConfig(
|
if search_fields is not None
|
||||||
query=config.query,
|
else search
|
||||||
fields=search_fields,
|
|
||||||
case_sensitive=config.case_sensitive,
|
|
||||||
match_mode=config.match_mode,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not config.query or not config.query.strip():
|
if not config.query or not config.query.strip():
|
||||||
@@ -136,7 +140,7 @@ def build_search_filters(
|
|||||||
else:
|
else:
|
||||||
filters.append(column_as_string.ilike(f"%{query}%"))
|
filters.append(column_as_string.ilike(f"%{query}%"))
|
||||||
|
|
||||||
if not filters:
|
if not filters: # pragma: no cover
|
||||||
return [], []
|
return [], []
|
||||||
|
|
||||||
# Combine based on match_mode
|
# Combine based on match_mode
|
||||||
@@ -144,3 +148,143 @@ def build_search_filters(
|
|||||||
return [or_(*filters)], joins
|
return [or_(*filters)], joins
|
||||||
else:
|
else:
|
||||||
return filters, joins
|
return filters, joins
|
||||||
|
|
||||||
|
|
||||||
|
def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
|
||||||
|
"""Return a key for each facet field, disambiguating duplicate column keys.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
facet_fields: Sequence of facet fields — either direct columns or
|
||||||
|
relationship tuples ``(rel, ..., column)``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of string keys, one per facet field, in the same order.
|
||||||
|
"""
|
||||||
|
raw: list[tuple[str, str | None]] = []
|
||||||
|
for field in facet_fields:
|
||||||
|
if isinstance(field, tuple):
|
||||||
|
rel = field[-2]
|
||||||
|
column = field[-1]
|
||||||
|
raw.append((column.key, rel.key))
|
||||||
|
else:
|
||||||
|
raw.append((field.key, None))
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def build_facets(
|
||||||
|
session: "AsyncSession",
|
||||||
|
model: type[DeclarativeBase],
|
||||||
|
facet_fields: Sequence[FacetFieldType],
|
||||||
|
*,
|
||||||
|
base_filters: "list[ColumnElement[bool]] | None" = None,
|
||||||
|
base_joins: list[InstrumentedAttribute[Any]] | None = None,
|
||||||
|
) -> dict[str, list[Any]]:
|
||||||
|
"""Return distinct values for each facet field, respecting current filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB async session
|
||||||
|
model: SQLAlchemy model class
|
||||||
|
facet_fields: Columns or relationship tuples to facet on
|
||||||
|
base_filters: Filter conditions already applied to the main query (search + caller filters)
|
||||||
|
base_joins: Relationship joins already applied to the main query
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping column key to sorted list of distinct non-None values
|
||||||
|
"""
|
||||||
|
existing_join_keys: set[str] = {str(j) for j in (base_joins or [])}
|
||||||
|
|
||||||
|
keys = facet_keys(facet_fields)
|
||||||
|
|
||||||
|
async def _query_facet(field: FacetFieldType, key: str) -> tuple[str, list[Any]]:
|
||||||
|
if isinstance(field, tuple):
|
||||||
|
# Relationship chain: (User.role, Role.name) — last element is the column
|
||||||
|
rels = field[:-1]
|
||||||
|
column = field[-1]
|
||||||
|
else:
|
||||||
|
rels = ()
|
||||||
|
column = field
|
||||||
|
|
||||||
|
q = select(column).select_from(model).distinct()
|
||||||
|
|
||||||
|
# Apply base joins (already done on main query, but needed here independently)
|
||||||
|
for rel in base_joins or []:
|
||||||
|
q = q.outerjoin(rel)
|
||||||
|
|
||||||
|
# Add any extra joins required by this facet field that aren't already in base_joins
|
||||||
|
for rel in rels:
|
||||||
|
if str(rel) not in existing_join_keys:
|
||||||
|
q = q.outerjoin(rel)
|
||||||
|
|
||||||
|
if base_filters:
|
||||||
|
q = q.where(and_(*base_filters))
|
||||||
|
|
||||||
|
q = q.order_by(column)
|
||||||
|
result = await session.execute(q)
|
||||||
|
values = [row[0] for row in result.all() if row[0] is not None]
|
||||||
|
return key, values
|
||||||
|
|
||||||
|
pairs = await asyncio.gather(
|
||||||
|
*[_query_facet(f, k) for f, k in zip(facet_fields, keys)]
|
||||||
|
)
|
||||||
|
return dict(pairs)
|
||||||
|
|
||||||
|
|
||||||
|
def build_filter_by(
|
||||||
|
filter_by: dict[str, Any],
|
||||||
|
facet_fields: Sequence[FacetFieldType],
|
||||||
|
) -> tuple["list[ColumnElement[bool]]", list[InstrumentedAttribute[Any]]]:
|
||||||
|
"""Translate a {column_key: value} dict into SQLAlchemy filter conditions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter_by: Mapping of column key to scalar value or list of values
|
||||||
|
facet_fields: Declared facet fields to validate keys against
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (filter_conditions, joins_needed)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidFacetFilterError: If a key in filter_by is not a declared facet field
|
||||||
|
"""
|
||||||
|
index: dict[
|
||||||
|
str, tuple[InstrumentedAttribute[Any], list[InstrumentedAttribute[Any]]]
|
||||||
|
] = {}
|
||||||
|
for key, field in zip(facet_keys(facet_fields), facet_fields):
|
||||||
|
if isinstance(field, tuple):
|
||||||
|
rels = list(field[:-1])
|
||||||
|
column = field[-1]
|
||||||
|
else:
|
||||||
|
rels = []
|
||||||
|
column = field
|
||||||
|
index[key] = (column, rels)
|
||||||
|
|
||||||
|
valid_keys = set(index)
|
||||||
|
filters: list[ColumnElement[bool]] = []
|
||||||
|
joins: list[InstrumentedAttribute[Any]] = []
|
||||||
|
added_join_keys: set[str] = set()
|
||||||
|
|
||||||
|
for key, value in filter_by.items():
|
||||||
|
if key not in index:
|
||||||
|
raise InvalidFacetFilterError(key, valid_keys)
|
||||||
|
|
||||||
|
column, rels = index[key]
|
||||||
|
|
||||||
|
for rel in rels:
|
||||||
|
rel_key = str(rel)
|
||||||
|
if rel_key not in added_join_keys:
|
||||||
|
joins.append(rel)
|
||||||
|
added_join_keys.add(rel_key)
|
||||||
|
|
||||||
|
if isinstance(value, list):
|
||||||
|
filters.append(column.in_(value))
|
||||||
|
else:
|
||||||
|
filters.append(column == value)
|
||||||
|
|
||||||
|
return filters, joins
|
||||||
|
|||||||
@@ -7,15 +7,19 @@ from enum import Enum
|
|||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from .exceptions import NotFoundError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"LockMode",
|
"LockMode",
|
||||||
|
"cleanup_tables",
|
||||||
|
"create_database",
|
||||||
"create_db_context",
|
"create_db_context",
|
||||||
"create_db_dependency",
|
"create_db_dependency",
|
||||||
"lock_tables",
|
|
||||||
"get_transaction",
|
"get_transaction",
|
||||||
|
"lock_tables",
|
||||||
"wait_for_row_change",
|
"wait_for_row_change",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -186,6 +190,71 @@ async def lock_tables(
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
async def create_database(
|
||||||
|
db_name: str,
|
||||||
|
*,
|
||||||
|
server_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""Create a database.
|
||||||
|
|
||||||
|
Connects to *server_url* using ``AUTOCOMMIT`` isolation and issues a
|
||||||
|
``CREATE DATABASE`` statement for *db_name*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_name: Name of the database to create.
|
||||||
|
server_url: URL used for server-level DDL (must point to an existing
|
||||||
|
database on the same server).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import create_database
|
||||||
|
|
||||||
|
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
||||||
|
await create_database("myapp_test", server_url=SERVER_URL)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
engine = create_async_engine(server_url, isolation_level="AUTOCOMMIT")
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"CREATE DATABASE {db_name}"))
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_tables(
|
||||||
|
session: AsyncSession,
|
||||||
|
base: type[DeclarativeBase],
|
||||||
|
) -> None:
|
||||||
|
"""Truncate all tables for fast between-test cleanup.
|
||||||
|
|
||||||
|
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
|
||||||
|
across every table in *base*'s metadata, which is significantly faster
|
||||||
|
than dropping and re-creating tables between tests.
|
||||||
|
|
||||||
|
This is a no-op when the metadata contains no tables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active async database session.
|
||||||
|
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session(worker_db_url):
|
||||||
|
async with create_db_session(worker_db_url, Base) as session:
|
||||||
|
yield session
|
||||||
|
await cleanup_tables(session, Base)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
tables = base.metadata.sorted_tables
|
||||||
|
if not tables:
|
||||||
|
return
|
||||||
|
|
||||||
|
table_names = ", ".join(f'"{t.name}"' for t in tables)
|
||||||
|
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
_M = TypeVar("_M", bound=DeclarativeBase)
|
_M = TypeVar("_M", bound=DeclarativeBase)
|
||||||
|
|
||||||
|
|
||||||
@@ -216,7 +285,7 @@ async def wait_for_row_change(
|
|||||||
The refreshed model instance with updated values
|
The refreshed model instance with updated values
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
LookupError: If the row does not exist or is deleted during polling
|
NotFoundError: If the row does not exist or is deleted during polling
|
||||||
TimeoutError: If timeout expires before a change is detected
|
TimeoutError: If timeout expires before a change is detected
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@@ -237,7 +306,7 @@ async def wait_for_row_change(
|
|||||||
"""
|
"""
|
||||||
instance = await session.get(model, pk_value)
|
instance = await session.get(model, pk_value)
|
||||||
if instance is None:
|
if instance is None:
|
||||||
raise LookupError(f"{model.__name__} with pk={pk_value!r} not found")
|
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
|
||||||
|
|
||||||
if columns is not None:
|
if columns is not None:
|
||||||
watch_cols = columns
|
watch_cols = columns
|
||||||
@@ -261,7 +330,7 @@ async def wait_for_row_change(
|
|||||||
instance = await session.get(model, pk_value)
|
instance = await session.get(model, pk_value)
|
||||||
|
|
||||||
if instance is None:
|
if instance is None:
|
||||||
raise LookupError(f"{model.__name__} with pk={pk_value!r} was deleted")
|
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} was deleted")
|
||||||
|
|
||||||
current = {col: getattr(instance, col) for col in watch_cols}
|
current = {col: getattr(instance, col) for col in watch_cols}
|
||||||
if current != initial:
|
if current != initial:
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
"""Dependency factories for FastAPI routes."""
|
"""Dependency factories for FastAPI routes."""
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import Callable
|
||||||
from typing import Any, TypeVar, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
|
||||||
|
|
||||||
from .crud import CrudFactory
|
from .crud import CrudFactory
|
||||||
|
from .types import ModelType, SessionDependency
|
||||||
|
|
||||||
__all__ = ["BodyDependency", "PathDependency"]
|
__all__ = ["BodyDependency", "PathDependency"]
|
||||||
|
|
||||||
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
|
||||||
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]]
|
|
||||||
|
|
||||||
|
|
||||||
def PathDependency(
|
def PathDependency(
|
||||||
model: type[ModelType],
|
model: type[ModelType],
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from .exceptions import (
|
|||||||
ApiException,
|
ApiException,
|
||||||
ConflictError,
|
ConflictError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
|
InvalidFacetFilterError,
|
||||||
|
InvalidOrderFieldError,
|
||||||
NoSearchableFieldsError,
|
NoSearchableFieldsError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
@@ -19,6 +21,8 @@ __all__ = [
|
|||||||
"ForbiddenError",
|
"ForbiddenError",
|
||||||
"generate_error_responses",
|
"generate_error_responses",
|
||||||
"init_exceptions_handlers",
|
"init_exceptions_handlers",
|
||||||
|
"InvalidFacetFilterError",
|
||||||
|
"InvalidOrderFieldError",
|
||||||
"NoSearchableFieldsError",
|
"NoSearchableFieldsError",
|
||||||
"NotFoundError",
|
"NotFoundError",
|
||||||
"UnauthorizedError",
|
"UnauthorizedError",
|
||||||
|
|||||||
@@ -6,32 +6,46 @@ from ..schemas import ApiError, ErrorResponse, ResponseStatus
|
|||||||
|
|
||||||
|
|
||||||
class ApiException(Exception):
|
class ApiException(Exception):
|
||||||
"""Base exception for API errors with structured response.
|
"""Base exception for API errors with structured response."""
|
||||||
|
|
||||||
Subclass this to create custom API exceptions with consistent error format.
|
|
||||||
The exception handler will use api_error to generate the response.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
class CustomError(ApiException):
|
|
||||||
api_error = ApiError(
|
|
||||||
code=400,
|
|
||||||
msg="Bad Request",
|
|
||||||
desc="The request was invalid.",
|
|
||||||
err_code="CUSTOM-400",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
|
|
||||||
api_error: ClassVar[ApiError]
|
api_error: ClassVar[ApiError]
|
||||||
|
|
||||||
def __init__(self, detail: str | None = None):
|
def __init_subclass__(cls, abstract: bool = False, **kwargs: Any) -> None:
|
||||||
|
super().__init_subclass__(**kwargs)
|
||||||
|
if not abstract and not hasattr(cls, "api_error"):
|
||||||
|
raise TypeError(
|
||||||
|
f"{cls.__name__} must define an 'api_error' class attribute. "
|
||||||
|
"Pass abstract=True when creating intermediate base classes."
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
detail: str | None = None,
|
||||||
|
*,
|
||||||
|
desc: str | None = None,
|
||||||
|
data: Any = None,
|
||||||
|
) -> None:
|
||||||
"""Initialize the exception.
|
"""Initialize the exception.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
detail: Optional override for the error message
|
detail: Optional human-readable message
|
||||||
|
desc: Optional per-instance override for the ``description`` field
|
||||||
|
in the HTTP response body.
|
||||||
|
data: Optional per-instance override for the ``data`` field in the
|
||||||
|
HTTP response body.
|
||||||
"""
|
"""
|
||||||
super().__init__(detail or self.api_error.msg)
|
updates: dict[str, Any] = {}
|
||||||
|
if detail is not None:
|
||||||
|
updates["msg"] = detail
|
||||||
|
if desc is not None:
|
||||||
|
updates["desc"] = desc
|
||||||
|
if data is not None:
|
||||||
|
updates["data"] = data
|
||||||
|
if updates:
|
||||||
|
object.__setattr__(
|
||||||
|
self, "api_error", self.__class__.api_error.model_copy(update=updates)
|
||||||
|
)
|
||||||
|
super().__init__(self.api_error.msg)
|
||||||
|
|
||||||
|
|
||||||
class UnauthorizedError(ApiException):
|
class UnauthorizedError(ApiException):
|
||||||
@@ -92,14 +106,66 @@ class NoSearchableFieldsError(ApiException):
|
|||||||
"""Initialize the exception.
|
"""Initialize the exception.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model: The SQLAlchemy model class that has no searchable fields
|
model: The model class that has no searchable fields configured.
|
||||||
"""
|
"""
|
||||||
self.model = model
|
self.model = model
|
||||||
detail = (
|
super().__init__(
|
||||||
|
desc=(
|
||||||
f"No searchable fields found for model '{model.__name__}'. "
|
f"No searchable fields found for model '{model.__name__}'. "
|
||||||
"Provide 'search_fields' parameter or set 'searchable_fields' on the CRUD class."
|
"Provide 'search_fields' parameter or set 'searchable_fields' on the CRUD class."
|
||||||
)
|
)
|
||||||
super().__init__(detail)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidFacetFilterError(ApiException):
|
||||||
|
"""Raised when filter_by contains a key not declared in facet_fields."""
|
||||||
|
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400,
|
||||||
|
msg="Invalid Facet Filter",
|
||||||
|
desc="One or more filter_by keys are not declared as facet fields.",
|
||||||
|
err_code="FACET-400",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, key: str, valid_keys: set[str]) -> None:
|
||||||
|
"""Initialize the exception.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The unknown filter key provided by the caller.
|
||||||
|
valid_keys: Set of valid keys derived from the declared facet_fields.
|
||||||
|
"""
|
||||||
|
self.key = key
|
||||||
|
self.valid_keys = valid_keys
|
||||||
|
super().__init__(
|
||||||
|
desc=(
|
||||||
|
f"'{key}' is not a declared facet field. "
|
||||||
|
f"Valid keys: {sorted(valid_keys) or 'none — set facet_fields on the CRUD class'}."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidOrderFieldError(ApiException):
|
||||||
|
"""Raised when order_by contains a field not in the allowed order fields."""
|
||||||
|
|
||||||
|
api_error = ApiError(
|
||||||
|
code=422,
|
||||||
|
msg="Invalid Order Field",
|
||||||
|
desc="The requested order field is not allowed for this resource.",
|
||||||
|
err_code="SORT-422",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, field: str, valid_fields: list[str]) -> None:
|
||||||
|
"""Initialize the exception.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field: The unknown order field provided by the caller.
|
||||||
|
valid_fields: List of valid field names.
|
||||||
|
"""
|
||||||
|
self.field = field
|
||||||
|
self.valid_fields = valid_fields
|
||||||
|
super().__init__(
|
||||||
|
desc=f"'{field}' is not an allowed order field. Valid fields: {valid_fields}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_error_responses(
|
def generate_error_responses(
|
||||||
@@ -107,44 +173,39 @@ def generate_error_responses(
|
|||||||
) -> dict[int | str, dict[str, Any]]:
|
) -> dict[int | str, dict[str, Any]]:
|
||||||
"""Generate OpenAPI response documentation for exceptions.
|
"""Generate OpenAPI response documentation for exceptions.
|
||||||
|
|
||||||
Use this to document possible error responses for an endpoint.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
*errors: Exception classes that inherit from ApiException
|
*errors: Exception classes that inherit from ApiException.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict suitable for FastAPI's responses parameter
|
Dict suitable for FastAPI's ``responses`` parameter.
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.exceptions import generate_error_responses, UnauthorizedError, ForbiddenError
|
|
||||||
|
|
||||||
@app.get(
|
|
||||||
"/admin",
|
|
||||||
responses=generate_error_responses(UnauthorizedError, ForbiddenError)
|
|
||||||
)
|
|
||||||
async def admin_endpoint():
|
|
||||||
...
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
responses: dict[int | str, dict[str, Any]] = {}
|
responses: dict[int | str, dict[str, Any]] = {}
|
||||||
|
|
||||||
for error in errors:
|
for error in errors:
|
||||||
api_error = error.api_error
|
api_error = error.api_error
|
||||||
|
code = api_error.code
|
||||||
|
|
||||||
responses[api_error.code] = {
|
if code not in responses:
|
||||||
|
responses[code] = {
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
"description": api_error.msg,
|
"description": api_error.msg,
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"example": {
|
"examples": {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
responses[code]["content"]["application/json"]["examples"][
|
||||||
|
api_error.err_code
|
||||||
|
] = {
|
||||||
|
"summary": api_error.msg,
|
||||||
|
"value": {
|
||||||
"data": api_error.data,
|
"data": api_error.data,
|
||||||
"status": ResponseStatus.FAIL.value,
|
"status": ResponseStatus.FAIL.value,
|
||||||
"message": api_error.msg,
|
"message": api_error.msg,
|
||||||
"description": api_error.desc,
|
"description": api_error.desc,
|
||||||
"error_code": api_error.err_code,
|
"error_code": api_error.err_code,
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +1,41 @@
|
|||||||
"""Exception handlers for FastAPI applications."""
|
"""Exception handlers for FastAPI applications."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response, status
|
from fastapi import FastAPI, Request, Response, status
|
||||||
from fastapi.exceptions import RequestValidationError, ResponseValidationError
|
from fastapi.exceptions import (
|
||||||
from fastapi.openapi.utils import get_openapi
|
HTTPException,
|
||||||
|
RequestValidationError,
|
||||||
|
ResponseValidationError,
|
||||||
|
)
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from ..schemas import ErrorResponse, ResponseStatus
|
from ..schemas import ErrorResponse, ResponseStatus
|
||||||
from .exceptions import ApiException
|
from .exceptions import ApiException
|
||||||
|
|
||||||
|
_VALIDATION_LOCATION_PARAMS: frozenset[str] = frozenset(
|
||||||
|
{"body", "query", "path", "header", "cookie"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def init_exceptions_handlers(app: FastAPI) -> FastAPI:
|
def init_exceptions_handlers(app: FastAPI) -> FastAPI:
|
||||||
"""Register exception handlers and custom OpenAPI schema on a FastAPI app.
|
"""Register exception handlers and custom OpenAPI schema on a FastAPI app.
|
||||||
|
|
||||||
Installs handlers for :class:`ApiException`, validation errors, and
|
|
||||||
unhandled exceptions, and replaces the default 422 schema with a
|
|
||||||
consistent error format.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
app: FastAPI application instance
|
app: FastAPI application instance.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The same FastAPI instance (for chaining)
|
The same FastAPI instance (for chaining).
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
init_exceptions_handlers(app)
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
_register_exception_handlers(app)
|
_register_exception_handlers(app)
|
||||||
app.openapi = lambda: _custom_openapi(app) # type: ignore[method-assign]
|
_original_openapi = app.openapi
|
||||||
|
app.openapi = lambda: _patched_openapi(app, _original_openapi) # type: ignore[method-assign]
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def _register_exception_handlers(app: FastAPI) -> None:
|
def _register_exception_handlers(app: FastAPI) -> None:
|
||||||
"""Register all exception handlers on a FastAPI application.
|
"""Register all exception handlers on a FastAPI application."""
|
||||||
|
|
||||||
Args:
|
|
||||||
app: FastAPI application instance
|
|
||||||
|
|
||||||
Example:
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
init_exceptions_handlers(app)
|
|
||||||
"""
|
|
||||||
|
|
||||||
@app.exception_handler(ApiException)
|
@app.exception_handler(ApiException)
|
||||||
async def api_exception_handler(request: Request, exc: ApiException) -> Response:
|
async def api_exception_handler(request: Request, exc: ApiException) -> Response:
|
||||||
@@ -62,12 +47,25 @@ def _register_exception_handlers(app: FastAPI) -> None:
|
|||||||
description=api_error.desc,
|
description=api_error.desc,
|
||||||
error_code=api_error.err_code,
|
error_code=api_error.err_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=api_error.code,
|
status_code=api_error.code,
|
||||||
content=error_response.model_dump(),
|
content=error_response.model_dump(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
|
||||||
|
"""Handle Starlette/FastAPI HTTPException with a consistent error format."""
|
||||||
|
detail = exc.detail if isinstance(exc.detail, str) else "HTTP Error"
|
||||||
|
error_response = ErrorResponse(
|
||||||
|
message=detail,
|
||||||
|
error_code=f"HTTP-{exc.status_code}",
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=error_response.model_dump(),
|
||||||
|
headers=getattr(exc, "headers", None),
|
||||||
|
)
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def request_validation_handler(
|
async def request_validation_handler(
|
||||||
request: Request, exc: RequestValidationError
|
request: Request, exc: RequestValidationError
|
||||||
@@ -90,7 +88,6 @@ def _register_exception_handlers(app: FastAPI) -> None:
|
|||||||
description="An unexpected error occurred. Please try again later.",
|
description="An unexpected error occurred. Please try again later.",
|
||||||
error_code="SERVER-500",
|
error_code="SERVER-500",
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
content=error_response.model_dump(),
|
content=error_response.model_dump(),
|
||||||
@@ -105,11 +102,10 @@ def _format_validation_error(
|
|||||||
formatted_errors = []
|
formatted_errors = []
|
||||||
|
|
||||||
for error in errors:
|
for error in errors:
|
||||||
field_path = ".".join(
|
locs = error["loc"]
|
||||||
str(loc)
|
if locs and locs[0] in _VALIDATION_LOCATION_PARAMS:
|
||||||
for loc in error["loc"]
|
locs = locs[1:]
|
||||||
if loc not in ("body", "query", "path", "header", "cookie")
|
field_path = ".".join(str(loc) for loc in locs)
|
||||||
)
|
|
||||||
formatted_errors.append(
|
formatted_errors.append(
|
||||||
{
|
{
|
||||||
"field": field_path or "root",
|
"field": field_path or "root",
|
||||||
@@ -131,34 +127,22 @@ def _format_validation_error(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _custom_openapi(app: FastAPI) -> dict[str, Any]:
|
def _patched_openapi(
|
||||||
"""Generate custom OpenAPI schema with standardized error format.
|
app: FastAPI, original_openapi: Callable[[], dict[str, Any]]
|
||||||
|
) -> dict[str, Any]:
|
||||||
Replaces default 422 validation error responses with the custom format.
|
"""Generate the OpenAPI schema and replace default 422 responses.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
app: FastAPI application instance
|
app: FastAPI application instance.
|
||||||
|
original_openapi: The previous ``app.openapi`` callable to delegate to.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OpenAPI schema dict
|
Patched OpenAPI schema dict.
|
||||||
|
|
||||||
Example:
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
init_exceptions_handlers(app) # Automatically sets custom OpenAPI
|
|
||||||
"""
|
"""
|
||||||
if app.openapi_schema:
|
if app.openapi_schema:
|
||||||
return app.openapi_schema
|
return app.openapi_schema
|
||||||
|
|
||||||
openapi_schema = get_openapi(
|
openapi_schema = original_openapi()
|
||||||
title=app.title,
|
|
||||||
version=app.version,
|
|
||||||
openapi_version=app.openapi_version,
|
|
||||||
description=app.description,
|
|
||||||
routes=app.routes,
|
|
||||||
)
|
|
||||||
|
|
||||||
for path_data in openapi_schema.get("paths", {}).values():
|
for path_data in openapi_schema.get("paths", {}).values():
|
||||||
for operation in path_data.values():
|
for operation in path_data.values():
|
||||||
@@ -168,7 +152,10 @@ def _custom_openapi(app: FastAPI) -> dict[str, Any]:
|
|||||||
"description": "Validation Error",
|
"description": "Validation Error",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"example": {
|
"examples": {
|
||||||
|
"VAL-422": {
|
||||||
|
"summary": "Validation Error",
|
||||||
|
"value": {
|
||||||
"data": {
|
"data": {
|
||||||
"errors": [
|
"errors": [
|
||||||
{
|
{
|
||||||
@@ -182,6 +169,8 @@ def _custom_openapi(app: FastAPI) -> dict[str, Any]:
|
|||||||
"message": "Validation Error",
|
"message": "Validation Error",
|
||||||
"description": "1 validation error(s) detected",
|
"description": "1 validation error(s) detected",
|
||||||
"error_code": "VAL-422",
|
"error_code": "VAL-422",
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,24 +1,84 @@
|
|||||||
"""Fixture loading utilities for database seeding."""
|
"""Fixture loading utilities for database seeding."""
|
||||||
|
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from typing import Any, TypeVar
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from ..db import get_transaction
|
from ..db import get_transaction
|
||||||
from ..logger import get_logger
|
from ..logger import get_logger
|
||||||
|
from ..types import ModelType
|
||||||
from .enum import LoadStrategy
|
from .enum import LoadStrategy
|
||||||
from .registry import Context, FixtureRegistry
|
from .registry import Context, FixtureRegistry
|
||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
T = TypeVar("T", bound=DeclarativeBase)
|
|
||||||
|
async def _load_ordered(
|
||||||
|
session: AsyncSession,
|
||||||
|
registry: FixtureRegistry,
|
||||||
|
ordered_names: list[str],
|
||||||
|
strategy: LoadStrategy,
|
||||||
|
) -> dict[str, list[DeclarativeBase]]:
|
||||||
|
"""Load fixtures in order."""
|
||||||
|
results: dict[str, list[DeclarativeBase]] = {}
|
||||||
|
|
||||||
|
for name in ordered_names:
|
||||||
|
fixture = registry.get(name)
|
||||||
|
instances = list(fixture.func())
|
||||||
|
|
||||||
|
if not instances:
|
||||||
|
results[name] = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_name = type(instances[0]).__name__
|
||||||
|
loaded: list[DeclarativeBase] = []
|
||||||
|
|
||||||
|
async with get_transaction(session):
|
||||||
|
for instance in instances:
|
||||||
|
if strategy == LoadStrategy.INSERT:
|
||||||
|
session.add(instance)
|
||||||
|
loaded.append(instance)
|
||||||
|
|
||||||
|
elif strategy == LoadStrategy.MERGE:
|
||||||
|
merged = await session.merge(instance)
|
||||||
|
loaded.append(merged)
|
||||||
|
|
||||||
|
else: # LoadStrategy.SKIP_EXISTING
|
||||||
|
pk = _get_primary_key(instance)
|
||||||
|
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
|
||||||
|
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
||||||
|
"""Get the primary key value of a model instance."""
|
||||||
|
mapper = instance.__class__.__mapper__
|
||||||
|
pk_cols = mapper.primary_key
|
||||||
|
|
||||||
|
if len(pk_cols) == 1:
|
||||||
|
return getattr(instance, pk_cols[0].name, None)
|
||||||
|
|
||||||
|
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
|
||||||
|
if all(v is not None for v in pk_values):
|
||||||
|
return pk_values
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_obj_by_attr(
|
def get_obj_by_attr(
|
||||||
fixtures: Callable[[], Sequence[T]], attr_name: str, value: Any
|
fixtures: Callable[[], Sequence[ModelType]], attr_name: str, value: Any
|
||||||
) -> T:
|
) -> ModelType:
|
||||||
"""Get a SQLAlchemy model instance by matching an attribute value.
|
"""Get a SQLAlchemy model instance by matching an attribute value.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -57,13 +117,6 @@ async def load_fixtures(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping fixture names to loaded instances
|
Dict mapping fixture names to loaded instances
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
# Loads 'roles' first (dependency), then 'users'
|
|
||||||
result = await load_fixtures(session, fixtures, "users")
|
|
||||||
print(result["users"]) # [User(...), ...]
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
ordered = registry.resolve_dependencies(*names)
|
ordered = registry.resolve_dependencies(*names)
|
||||||
return await _load_ordered(session, registry, ordered, strategy)
|
return await _load_ordered(session, registry, ordered, strategy)
|
||||||
@@ -85,76 +138,6 @@ async def load_fixtures_by_context(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping fixture names to loaded instances
|
Dict mapping fixture names to loaded instances
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
# Load base + testing fixtures
|
|
||||||
await load_fixtures_by_context(
|
|
||||||
session, fixtures,
|
|
||||||
Context.BASE, Context.TESTING
|
|
||||||
)
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
ordered = registry.resolve_context_dependencies(*contexts)
|
ordered = registry.resolve_context_dependencies(*contexts)
|
||||||
return await _load_ordered(session, registry, ordered, strategy)
|
return await _load_ordered(session, registry, ordered, strategy)
|
||||||
|
|
||||||
|
|
||||||
async def _load_ordered(
|
|
||||||
session: AsyncSession,
|
|
||||||
registry: FixtureRegistry,
|
|
||||||
ordered_names: list[str],
|
|
||||||
strategy: LoadStrategy,
|
|
||||||
) -> dict[str, list[DeclarativeBase]]:
|
|
||||||
"""Load fixtures in order."""
|
|
||||||
results: dict[str, list[DeclarativeBase]] = {}
|
|
||||||
|
|
||||||
for name in ordered_names:
|
|
||||||
fixture = registry.get(name)
|
|
||||||
instances = list(fixture.func())
|
|
||||||
|
|
||||||
if not instances:
|
|
||||||
results[name] = []
|
|
||||||
continue
|
|
||||||
|
|
||||||
model_name = type(instances[0]).__name__
|
|
||||||
loaded: list[DeclarativeBase] = []
|
|
||||||
|
|
||||||
async with get_transaction(session):
|
|
||||||
for instance in instances:
|
|
||||||
if strategy == LoadStrategy.INSERT:
|
|
||||||
session.add(instance)
|
|
||||||
loaded.append(instance)
|
|
||||||
|
|
||||||
elif strategy == LoadStrategy.MERGE:
|
|
||||||
merged = await session.merge(instance)
|
|
||||||
loaded.append(merged)
|
|
||||||
|
|
||||||
elif strategy == LoadStrategy.SKIP_EXISTING:
|
|
||||||
pk = _get_primary_key(instance)
|
|
||||||
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
|
|
||||||
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
|
||||||
"""Get the primary key value of a model instance."""
|
|
||||||
mapper = instance.__class__.__mapper__
|
|
||||||
pk_cols = mapper.primary_key
|
|
||||||
|
|
||||||
if len(pk_cols) == 1:
|
|
||||||
return getattr(instance, pk_cols[0].name, None)
|
|
||||||
|
|
||||||
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
|
|
||||||
if all(v is not None for v in pk_values):
|
|
||||||
return pk_values
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -51,19 +51,25 @@ def init_metrics(
|
|||||||
"""
|
"""
|
||||||
for provider in registry.get_providers():
|
for provider in registry.get_providers():
|
||||||
logger.debug("Initialising metric provider '%s'", provider.name)
|
logger.debug("Initialising metric provider '%s'", provider.name)
|
||||||
provider.func()
|
registry._instances[provider.name] = provider.func()
|
||||||
|
|
||||||
collectors = registry.get_collectors()
|
# Partition collectors and cache env check at startup — both are stable for the app lifetime.
|
||||||
|
async_collectors = [
|
||||||
|
c for c in registry.get_collectors() if asyncio.iscoroutinefunction(c.func)
|
||||||
|
]
|
||||||
|
sync_collectors = [
|
||||||
|
c for c in registry.get_collectors() if not asyncio.iscoroutinefunction(c.func)
|
||||||
|
]
|
||||||
|
multiprocess_mode = _is_multiprocess()
|
||||||
|
|
||||||
@app.get(path, include_in_schema=False)
|
@app.get(path, include_in_schema=False)
|
||||||
async def metrics_endpoint() -> Response:
|
async def metrics_endpoint() -> Response:
|
||||||
for collector in collectors:
|
for collector in sync_collectors:
|
||||||
if asyncio.iscoroutinefunction(collector.func):
|
|
||||||
await collector.func()
|
|
||||||
else:
|
|
||||||
collector.func()
|
collector.func()
|
||||||
|
for collector in async_collectors:
|
||||||
|
await collector.func()
|
||||||
|
|
||||||
if _is_multiprocess():
|
if multiprocess_mode:
|
||||||
prom_registry = CollectorRegistry()
|
prom_registry = CollectorRegistry()
|
||||||
multiprocess.MultiProcessCollector(prom_registry)
|
multiprocess.MultiProcessCollector(prom_registry)
|
||||||
output = generate_latest(prom_registry)
|
output = generate_latest(prom_registry)
|
||||||
|
|||||||
@@ -19,31 +19,11 @@ class Metric:
|
|||||||
|
|
||||||
|
|
||||||
class MetricsRegistry:
|
class MetricsRegistry:
|
||||||
"""Registry for managing Prometheus metric providers and collectors.
|
"""Registry for managing Prometheus metric providers and collectors."""
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from prometheus_client import Counter, Gauge
|
|
||||||
from fastapi_toolsets.metrics import MetricsRegistry
|
|
||||||
|
|
||||||
metrics = MetricsRegistry()
|
|
||||||
|
|
||||||
@metrics.register
|
|
||||||
def http_requests():
|
|
||||||
return Counter("http_requests_total", "Total HTTP requests", ["method", "status"])
|
|
||||||
|
|
||||||
@metrics.register(name="db_pool")
|
|
||||||
def database_pool_size():
|
|
||||||
return Gauge("db_pool_size", "Database connection pool size")
|
|
||||||
|
|
||||||
@metrics.register(collect=True)
|
|
||||||
def collect_queue_depth(gauge=Gauge("queue_depth", "Current queue depth")):
|
|
||||||
gauge.set(get_current_queue_depth())
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._metrics: dict[str, Metric] = {}
|
self._metrics: dict[str, Metric] = {}
|
||||||
|
self._instances: dict[str, Any] = {}
|
||||||
|
|
||||||
def register(
|
def register(
|
||||||
self,
|
self,
|
||||||
@@ -61,17 +41,6 @@ class MetricsRegistry:
|
|||||||
name: Metric name (defaults to function name).
|
name: Metric name (defaults to function name).
|
||||||
collect: If ``True``, the function is called on every scrape.
|
collect: If ``True``, the function is called on every scrape.
|
||||||
If ``False`` (default), called once at init time.
|
If ``False`` (default), called once at init time.
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
@metrics.register
|
|
||||||
def my_counter():
|
|
||||||
return Counter("my_counter", "A counter")
|
|
||||||
|
|
||||||
@metrics.register(collect=True, name="queue")
|
|
||||||
def collect_queue_depth():
|
|
||||||
gauge.set(compute_depth())
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
@@ -87,6 +56,25 @@ class MetricsRegistry:
|
|||||||
return decorator(func)
|
return decorator(func)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
def get(self, name: str) -> Any:
|
||||||
|
"""Return the metric instance created by a provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: The metric name (defaults to the provider function name).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If the metric name is unknown or ``init_metrics`` has not
|
||||||
|
been called yet.
|
||||||
|
"""
|
||||||
|
if name not in self._instances:
|
||||||
|
if name in self._metrics:
|
||||||
|
raise KeyError(
|
||||||
|
f"Metric '{name}' exists but has not been initialized yet. "
|
||||||
|
"Ensure init_metrics() has been called before accessing metric instances."
|
||||||
|
)
|
||||||
|
raise KeyError(f"Unknown metric '{name}'.")
|
||||||
|
return self._instances[name]
|
||||||
|
|
||||||
def include_registry(self, registry: "MetricsRegistry") -> None:
|
def include_registry(self, registry: "MetricsRegistry") -> None:
|
||||||
"""Include another :class:`MetricsRegistry` into this one.
|
"""Include another :class:`MetricsRegistry` into this one.
|
||||||
|
|
||||||
@@ -95,18 +83,6 @@ class MetricsRegistry:
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If a metric name already exists in the current registry.
|
ValueError: If a metric name already exists in the current registry.
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
main = MetricsRegistry()
|
|
||||||
sub = MetricsRegistry()
|
|
||||||
|
|
||||||
@sub.register
|
|
||||||
def sub_metric():
|
|
||||||
return Counter("sub_total", "Sub counter")
|
|
||||||
|
|
||||||
main.include_registry(sub)
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
for metric_name, definition in registry._metrics.items():
|
for metric_name, definition in registry._metrics.items():
|
||||||
if metric_name in self._metrics:
|
if metric_name in self._metrics:
|
||||||
|
|||||||
47
src/fastapi_toolsets/models.py
Normal file
47
src/fastapi_toolsets/models.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""SQLAlchemy model mixins for common column patterns."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Uuid, func, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"UUIDMixin",
|
||||||
|
"CreatedAtMixin",
|
||||||
|
"UpdatedAtMixin",
|
||||||
|
"TimestampMixin",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDMixin:
|
||||||
|
"""Mixin that adds a UUID primary key auto-generated by the database."""
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid,
|
||||||
|
primary_key=True,
|
||||||
|
server_default=text("gen_random_uuid()"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatedAtMixin:
|
||||||
|
"""Mixin that adds a ``created_at`` timestamp column."""
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatedAtMixin:
|
||||||
|
"""Mixin that adds an ``updated_at`` timestamp column."""
|
||||||
|
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin(CreatedAtMixin, UpdatedAtMixin):
|
||||||
|
"""Mixin that combines ``created_at`` and ``updated_at`` timestamp columns."""
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"""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
|
||||||
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
@@ -15,7 +15,134 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from ..db import create_db_context
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from ..db import (
|
||||||
|
cleanup_tables as _cleanup_tables,
|
||||||
|
create_database,
|
||||||
|
create_db_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_tables(
|
||||||
|
session: AsyncSession,
|
||||||
|
base: type[DeclarativeBase],
|
||||||
|
) -> None:
|
||||||
|
"""Truncate all tables for fast between-test cleanup.
|
||||||
|
|
||||||
|
.. deprecated::
|
||||||
|
Import ``cleanup_tables`` from ``fastapi_toolsets.db`` instead.
|
||||||
|
This re-export will be removed in v3.0.0.
|
||||||
|
"""
|
||||||
|
warnings.warn(
|
||||||
|
"Importing cleanup_tables from fastapi_toolsets.pytest is deprecated "
|
||||||
|
"and will be removed in v3.0.0. "
|
||||||
|
"Use 'from fastapi_toolsets.db import cleanup_tables' instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
await _cleanup_tables(session=session, base=base)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_xdist_worker(default_test_db: str) -> str:
|
||||||
|
"""Return the pytest-xdist worker name, or *default_test_db* when not running under xdist.
|
||||||
|
|
||||||
|
Reads the ``PYTEST_XDIST_WORKER`` environment variable that xdist sets
|
||||||
|
automatically in each worker process (e.g. ``"gw0"``, ``"gw1"``).
|
||||||
|
When xdist is not installed or not active, the variable is absent and
|
||||||
|
*default_test_db* is returned instead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
default_test_db: Fallback value returned when ``PYTEST_XDIST_WORKER``
|
||||||
|
is not set.
|
||||||
|
"""
|
||||||
|
return os.environ.get("PYTEST_XDIST_WORKER", default_test_db)
|
||||||
|
|
||||||
|
|
||||||
|
def worker_database_url(database_url: str, default_test_db: str) -> str:
|
||||||
|
"""Derive a per-worker database URL for pytest-xdist parallel runs.
|
||||||
|
|
||||||
|
Appends ``_{worker_name}`` to the database name so each xdist worker
|
||||||
|
operates on its own database. When not running under xdist,
|
||||||
|
``_{default_test_db}`` is appended instead.
|
||||||
|
|
||||||
|
The worker name is read from the ``PYTEST_XDIST_WORKER`` environment
|
||||||
|
variable (set automatically by xdist in each worker process).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_url: Original database connection URL.
|
||||||
|
default_test_db: Suffix appended to the database name when
|
||||||
|
``PYTEST_XDIST_WORKER`` is not set.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A database URL with a worker- or default-specific database name.
|
||||||
|
"""
|
||||||
|
worker = _get_xdist_worker(default_test_db=default_test_db)
|
||||||
|
|
||||||
|
url = make_url(database_url)
|
||||||
|
url = url.set(database=f"{url.database}_{worker}")
|
||||||
|
return url.render_as_string(hide_password=False)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def create_worker_database(
|
||||||
|
database_url: str,
|
||||||
|
default_test_db: str = "test_db",
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Create and drop a per-worker database for pytest-xdist isolation.
|
||||||
|
|
||||||
|
Derives a worker-specific database URL using :func:`worker_database_url`,
|
||||||
|
then delegates to :func:`~fastapi_toolsets.db.create_database` to create
|
||||||
|
and drop it. Intended for use as a **session-scoped** fixture.
|
||||||
|
|
||||||
|
When running under xdist the database name is suffixed with the worker
|
||||||
|
name (e.g. ``_gw0``). Otherwise it is suffixed with *default_test_db*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_url: Original database connection URL (used as the server
|
||||||
|
connection and as the base for the worker database name).
|
||||||
|
default_test_db: Suffix appended to the database name when
|
||||||
|
``PYTEST_XDIST_WORKER`` is not set. Defaults to ``"test_db"``.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
The worker-specific database URL.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.pytest import create_worker_database, create_db_session
|
||||||
|
|
||||||
|
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost/test_db"
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def worker_db_url():
|
||||||
|
async with create_worker_database(DATABASE_URL) as url:
|
||||||
|
yield url
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session(worker_db_url):
|
||||||
|
async with create_db_session(
|
||||||
|
worker_db_url, Base, cleanup=True
|
||||||
|
) as session:
|
||||||
|
yield session
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
worker_url = worker_database_url(
|
||||||
|
database_url=database_url, default_test_db=default_test_db
|
||||||
|
)
|
||||||
|
worker_db_name: str = make_url(worker_url).database # type: ignore[assignment]
|
||||||
|
|
||||||
|
engine = create_async_engine(database_url, isolation_level="AUTOCOMMIT")
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
||||||
|
await create_database(db_name=worker_db_name, server_url=database_url)
|
||||||
|
|
||||||
|
yield worker_url
|
||||||
|
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -156,160 +283,3 @@ async def create_db_session(
|
|||||||
await conn.run_sync(base.metadata.drop_all)
|
await conn.run_sync(base.metadata.drop_all)
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def _get_xdist_worker(default_test_db: str) -> str:
|
|
||||||
"""Return the pytest-xdist worker name, or *default_test_db* when not running under xdist.
|
|
||||||
|
|
||||||
Reads the ``PYTEST_XDIST_WORKER`` environment variable that xdist sets
|
|
||||||
automatically in each worker process (e.g. ``"gw0"``, ``"gw1"``).
|
|
||||||
When xdist is not installed or not active, the variable is absent and
|
|
||||||
*default_test_db* is returned instead.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
default_test_db: Fallback value returned when ``PYTEST_XDIST_WORKER``
|
|
||||||
is not set.
|
|
||||||
"""
|
|
||||||
return os.environ.get("PYTEST_XDIST_WORKER", default_test_db)
|
|
||||||
|
|
||||||
|
|
||||||
def worker_database_url(database_url: str, default_test_db: str) -> str:
|
|
||||||
"""Derive a per-worker database URL for pytest-xdist parallel runs.
|
|
||||||
|
|
||||||
Appends ``_{worker_name}`` to the database name so each xdist worker
|
|
||||||
operates on its own database. When not running under xdist,
|
|
||||||
``_{default_test_db}`` is appended instead.
|
|
||||||
|
|
||||||
The worker name is read from the ``PYTEST_XDIST_WORKER`` environment
|
|
||||||
variable (set automatically by xdist in each worker process).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
database_url: Original database connection URL.
|
|
||||||
default_test_db: Suffix appended to the database name when
|
|
||||||
``PYTEST_XDIST_WORKER`` is not set.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A database URL with a worker- or default-specific database name.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
# With PYTEST_XDIST_WORKER="gw0":
|
|
||||||
url = worker_database_url(
|
|
||||||
"postgresql+asyncpg://user:pass@localhost/test_db",
|
|
||||||
default_test_db="test",
|
|
||||||
)
|
|
||||||
# "postgresql+asyncpg://user:pass@localhost/test_db_gw0"
|
|
||||||
|
|
||||||
# Without PYTEST_XDIST_WORKER:
|
|
||||||
url = worker_database_url(
|
|
||||||
"postgresql+asyncpg://user:pass@localhost/test_db",
|
|
||||||
default_test_db="test",
|
|
||||||
)
|
|
||||||
# "postgresql+asyncpg://user:pass@localhost/test_db_test"
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
worker = _get_xdist_worker(default_test_db=default_test_db)
|
|
||||||
|
|
||||||
url = make_url(database_url)
|
|
||||||
url = url.set(database=f"{url.database}_{worker}")
|
|
||||||
return url.render_as_string(hide_password=False)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def create_worker_database(
|
|
||||||
database_url: str,
|
|
||||||
default_test_db: str = "test_db",
|
|
||||||
) -> AsyncGenerator[str, None]:
|
|
||||||
"""Create and drop a per-worker database for pytest-xdist isolation.
|
|
||||||
|
|
||||||
Intended for use as a **session-scoped** fixture. Connects to the server
|
|
||||||
using the original *database_url* (with ``AUTOCOMMIT`` isolation for DDL),
|
|
||||||
creates a dedicated database for the worker, and yields the worker-specific
|
|
||||||
URL. On cleanup the worker database is dropped.
|
|
||||||
|
|
||||||
When running under xdist the database name is suffixed with the worker
|
|
||||||
name (e.g. ``_gw0``). Otherwise it is suffixed with *default_test_db*.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
database_url: Original database connection URL.
|
|
||||||
default_test_db: Suffix appended to the database name when
|
|
||||||
``PYTEST_XDIST_WORKER`` is not set. Defaults to ``"test_db"``.
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
The worker-specific database URL.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.pytest import (
|
|
||||||
create_worker_database, create_db_session,
|
|
||||||
)
|
|
||||||
|
|
||||||
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost/test_db"
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
async def worker_db_url():
|
|
||||||
async with create_worker_database(DATABASE_URL) as url:
|
|
||||||
yield url
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def db_session(worker_db_url):
|
|
||||||
async with create_db_session(
|
|
||||||
worker_db_url, Base, cleanup=True
|
|
||||||
) as session:
|
|
||||||
yield session
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
worker_url = worker_database_url(
|
|
||||||
database_url=database_url, default_test_db=default_test_db
|
|
||||||
)
|
|
||||||
worker_db_name = make_url(worker_url).database
|
|
||||||
|
|
||||||
engine = create_async_engine(
|
|
||||||
database_url,
|
|
||||||
isolation_level="AUTOCOMMIT",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
|
||||||
await conn.execute(text(f"CREATE DATABASE {worker_db_name}"))
|
|
||||||
|
|
||||||
yield worker_url
|
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_tables(
|
|
||||||
session: AsyncSession,
|
|
||||||
base: type[DeclarativeBase],
|
|
||||||
) -> None:
|
|
||||||
"""Truncate all tables for fast between-test cleanup.
|
|
||||||
|
|
||||||
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
|
|
||||||
across every table in *base*'s metadata, which is significantly faster
|
|
||||||
than dropping and re-creating tables between tests.
|
|
||||||
|
|
||||||
This is a no-op when the metadata contains no tables.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: An active async database session.
|
|
||||||
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
@pytest.fixture
|
|
||||||
async def db_session(worker_db_url):
|
|
||||||
async with create_db_session(worker_db_url, Base) as session:
|
|
||||||
yield session
|
|
||||||
await cleanup_tables(session, Base)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
tables = base.metadata.sorted_tables
|
|
||||||
if not tables:
|
|
||||||
return
|
|
||||||
|
|
||||||
table_names = ", ".join(f'"{t.name}"' for t in tables)
|
|
||||||
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
|
||||||
await session.commit()
|
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
"""Base Pydantic schemas for API responses."""
|
"""Base Pydantic schemas for API responses."""
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, ClassVar, Generic, TypeVar
|
from typing import Any, ClassVar, Generic
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from .types import DataT
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ApiError",
|
"ApiError",
|
||||||
|
"CursorPagination",
|
||||||
"ErrorResponse",
|
"ErrorResponse",
|
||||||
"Pagination",
|
"OffsetPagination",
|
||||||
"PaginatedResponse",
|
"PaginatedResponse",
|
||||||
"PydanticBase",
|
"PydanticBase",
|
||||||
"Response",
|
"Response",
|
||||||
"ResponseStatus",
|
"ResponseStatus",
|
||||||
]
|
]
|
||||||
|
|
||||||
DataT = TypeVar("DataT")
|
|
||||||
|
|
||||||
|
|
||||||
class PydanticBase(BaseModel):
|
class PydanticBase(BaseModel):
|
||||||
"""Base class for all Pydantic models with common configuration."""
|
"""Base class for all Pydantic models with common configuration."""
|
||||||
@@ -90,8 +91,8 @@ class ErrorResponse(BaseResponse):
|
|||||||
data: Any | None = None
|
data: Any | None = None
|
||||||
|
|
||||||
|
|
||||||
class Pagination(PydanticBase):
|
class OffsetPagination(PydanticBase):
|
||||||
"""Pagination metadata for list responses.
|
"""Pagination metadata for offset-based list responses.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
total_count: Total number of items across all pages
|
total_count: Total number of items across all pages
|
||||||
@@ -106,17 +107,25 @@ class Pagination(PydanticBase):
|
|||||||
has_more: bool
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
class PaginatedResponse(BaseResponse, Generic[DataT]):
|
class CursorPagination(PydanticBase):
|
||||||
"""Paginated API response for list endpoints.
|
"""Pagination metadata for cursor-based list responses.
|
||||||
|
|
||||||
Example:
|
Attributes:
|
||||||
```python
|
next_cursor: Encoded cursor for the next page, or None on the last page.
|
||||||
PaginatedResponse[UserRead](
|
prev_cursor: Encoded cursor for the previous page, or None on the first page.
|
||||||
data=users,
|
items_per_page: Number of items requested per page.
|
||||||
pagination=Pagination(total_count=100, items_per_page=10, page=1, has_more=True)
|
has_more: Whether there is at least one more page after this one.
|
||||||
)
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
next_cursor: str | None
|
||||||
|
prev_cursor: str | None = None
|
||||||
|
items_per_page: int
|
||||||
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponse(BaseResponse, Generic[DataT]):
|
||||||
|
"""Paginated API response for list endpoints."""
|
||||||
|
|
||||||
data: list[DataT]
|
data: list[DataT]
|
||||||
pagination: Pagination
|
pagination: OffsetPagination | CursorPagination
|
||||||
|
filter_attributes: dict[str, list[Any]] | None = None
|
||||||
|
|||||||
27
src/fastapi_toolsets/types.py
Normal file
27
src/fastapi_toolsets/types.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Shared type aliases for the fastapi-toolsets package."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator, Callable, Mapping
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
||||||
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
|
from sqlalchemy.sql.elements import ColumnElement
|
||||||
|
|
||||||
|
# Generic TypeVars
|
||||||
|
DataT = TypeVar("DataT")
|
||||||
|
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
||||||
|
SchemaType = TypeVar("SchemaType", bound=BaseModel)
|
||||||
|
|
||||||
|
# CRUD type aliases
|
||||||
|
JoinType = list[tuple[type[DeclarativeBase], Any]]
|
||||||
|
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
||||||
|
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
||||||
|
|
||||||
|
# Search / facet type aliases
|
||||||
|
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
||||||
|
FacetFieldType = SearchFieldType
|
||||||
|
|
||||||
|
# Dependency type aliases
|
||||||
|
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]]
|
||||||
@@ -5,11 +5,25 @@ import uuid
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import Column, ForeignKey, String, Table, Uuid
|
import datetime
|
||||||
|
import decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Column,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Table,
|
||||||
|
Uuid,
|
||||||
|
)
|
||||||
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
|
||||||
|
|
||||||
from fastapi_toolsets.crud import CrudFactory
|
from fastapi_toolsets.crud import CrudFactory
|
||||||
|
from fastapi_toolsets.schemas import PydanticBase
|
||||||
|
|
||||||
DATABASE_URL = os.getenv(
|
DATABASE_URL = os.getenv(
|
||||||
key="DATABASE_URL",
|
key="DATABASE_URL",
|
||||||
@@ -69,6 +83,45 @@ post_tags = Table(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IntRole(Base):
|
||||||
|
"""Test role model with auto-increment integer PK."""
|
||||||
|
|
||||||
|
__tablename__ = "int_roles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(50), unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(Base):
|
||||||
|
"""Test model with composite primary key."""
|
||||||
|
|
||||||
|
__tablename__ = "permissions"
|
||||||
|
|
||||||
|
subject: Mapped[str] = mapped_column(String(50), primary_key=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(50), primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Event(Base):
|
||||||
|
"""Test model with DateTime and Date cursor columns."""
|
||||||
|
|
||||||
|
__tablename__ = "events"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
occurred_at: Mapped[datetime.datetime] = mapped_column(DateTime)
|
||||||
|
scheduled_date: Mapped[datetime.date] = mapped_column(Date)
|
||||||
|
|
||||||
|
|
||||||
|
class Product(Base):
|
||||||
|
"""Test model with Numeric cursor column."""
|
||||||
|
|
||||||
|
__tablename__ = "products"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
price: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2))
|
||||||
|
|
||||||
|
|
||||||
class Post(Base):
|
class Post(Base):
|
||||||
"""Test post model."""
|
"""Test post model."""
|
||||||
|
|
||||||
@@ -90,6 +143,13 @@ class RoleCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class RoleRead(PydanticBase):
|
||||||
|
"""Schema for reading a role."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class RoleUpdate(BaseModel):
|
class RoleUpdate(BaseModel):
|
||||||
"""Schema for updating a role."""
|
"""Schema for updating a role."""
|
||||||
|
|
||||||
@@ -106,6 +166,14 @@ class UserCreate(BaseModel):
|
|||||||
role_id: uuid.UUID | None = None
|
role_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(PydanticBase):
|
||||||
|
"""Schema for reading a user (subset of fields — no email)."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
"""Schema for updating a user."""
|
"""Schema for updating a user."""
|
||||||
|
|
||||||
@@ -160,11 +228,61 @@ class PostM2MUpdate(BaseModel):
|
|||||||
tag_ids: list[uuid.UUID] | None = None
|
tag_ids: list[uuid.UUID] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class IntRoleRead(PydanticBase):
|
||||||
|
"""Schema for reading an IntRole."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class IntRoleCreate(BaseModel):
|
||||||
|
"""Schema for creating an IntRole."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class EventRead(PydanticBase):
|
||||||
|
"""Schema for reading an Event."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class EventCreate(BaseModel):
|
||||||
|
"""Schema for creating an Event."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
occurred_at: datetime.datetime
|
||||||
|
scheduled_date: datetime.date
|
||||||
|
|
||||||
|
|
||||||
|
class ProductRead(PydanticBase):
|
||||||
|
"""Schema for reading a Product."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCreate(BaseModel):
|
||||||
|
"""Schema for creating a Product."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
price: decimal.Decimal
|
||||||
|
|
||||||
|
|
||||||
RoleCrud = CrudFactory(Role)
|
RoleCrud = CrudFactory(Role)
|
||||||
|
RoleCursorCrud = CrudFactory(Role, cursor_column=Role.id)
|
||||||
|
IntRoleCursorCrud = CrudFactory(IntRole, cursor_column=IntRole.id)
|
||||||
UserCrud = CrudFactory(User)
|
UserCrud = CrudFactory(User)
|
||||||
|
UserCursorCrud = CrudFactory(User, cursor_column=User.id)
|
||||||
PostCrud = CrudFactory(Post)
|
PostCrud = CrudFactory(Post)
|
||||||
TagCrud = CrudFactory(Tag)
|
TagCrud = CrudFactory(Tag)
|
||||||
PostM2MCrud = CrudFactory(Post, m2m_fields={"tag_ids": Post.tags})
|
PostM2MCrud = CrudFactory(Post, m2m_fields={"tag_ids": Post.tags})
|
||||||
|
EventCrud = CrudFactory(Event)
|
||||||
|
EventDateTimeCursorCrud = CrudFactory(Event, cursor_column=Event.occurred_at)
|
||||||
|
EventDateCursorCrud = CrudFactory(Event, cursor_column=Event.scheduled_date)
|
||||||
|
ProductCrud = CrudFactory(Product)
|
||||||
|
ProductNumericCursorCrud = CrudFactory(Product, cursor_column=Product.price)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
1035
tests/test_crud.py
1035
tests/test_crud.py
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,18 +4,25 @@ import asyncio
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from fastapi_toolsets.db import (
|
from fastapi_toolsets.db import (
|
||||||
LockMode,
|
LockMode,
|
||||||
|
cleanup_tables,
|
||||||
|
create_database,
|
||||||
create_db_context,
|
create_db_context,
|
||||||
create_db_dependency,
|
create_db_dependency,
|
||||||
get_transaction,
|
get_transaction,
|
||||||
lock_tables,
|
lock_tables,
|
||||||
wait_for_row_change,
|
wait_for_row_change,
|
||||||
)
|
)
|
||||||
|
from fastapi_toolsets.exceptions import NotFoundError
|
||||||
|
from fastapi_toolsets.pytest import create_db_session
|
||||||
|
|
||||||
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User
|
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User, UserCrud
|
||||||
|
|
||||||
|
|
||||||
class TestCreateDbDependency:
|
class TestCreateDbDependency:
|
||||||
@@ -307,9 +314,9 @@ class TestWaitForRowChange:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_nonexistent_row_raises(self, db_session: AsyncSession):
|
async def test_nonexistent_row_raises(self, db_session: AsyncSession):
|
||||||
"""Raises LookupError when the row does not exist."""
|
"""Raises NotFoundError when the row does not exist."""
|
||||||
fake_id = uuid.uuid4()
|
fake_id = uuid.uuid4()
|
||||||
with pytest.raises(LookupError, match="not found"):
|
with pytest.raises(NotFoundError, match="not found"):
|
||||||
await wait_for_row_change(db_session, Role, fake_id, interval=0.05)
|
await wait_for_row_change(db_session, Role, fake_id, interval=0.05)
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -326,7 +333,7 @@ class TestWaitForRowChange:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_deleted_row_raises(self, db_session: AsyncSession, engine):
|
async def test_deleted_row_raises(self, db_session: AsyncSession, engine):
|
||||||
"""Raises LookupError when the row is deleted during polling."""
|
"""Raises NotFoundError when the row is deleted during polling."""
|
||||||
role = Role(name="delete_role")
|
role = Role(name="delete_role")
|
||||||
db_session.add(role)
|
db_session.add(role)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
@@ -340,6 +347,86 @@ class TestWaitForRowChange:
|
|||||||
await other.commit()
|
await other.commit()
|
||||||
|
|
||||||
delete_task = asyncio.create_task(delete_later())
|
delete_task = asyncio.create_task(delete_later())
|
||||||
with pytest.raises(LookupError):
|
with pytest.raises(NotFoundError):
|
||||||
await wait_for_row_change(db_session, Role, role.id, interval=0.05)
|
await wait_for_row_change(db_session, Role, role.id, interval=0.05)
|
||||||
await delete_task
|
await delete_task
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateDatabase:
|
||||||
|
"""Tests for create_database."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_creates_database(self):
|
||||||
|
"""Database is created by create_database."""
|
||||||
|
target_url = (
|
||||||
|
make_url(DATABASE_URL)
|
||||||
|
.set(database="test_create_db_general")
|
||||||
|
.render_as_string(hide_password=False)
|
||||||
|
)
|
||||||
|
expected_db: str = make_url(target_url).database # type: ignore[assignment]
|
||||||
|
|
||||||
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"DROP DATABASE IF EXISTS {expected_db}"))
|
||||||
|
|
||||||
|
await create_database(db_name=expected_db, server_url=DATABASE_URL)
|
||||||
|
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
text("SELECT 1 FROM pg_database WHERE datname = :name"),
|
||||||
|
{"name": expected_db},
|
||||||
|
)
|
||||||
|
assert result.scalar() == 1
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"DROP DATABASE IF EXISTS {expected_db}"))
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanupTables:
|
||||||
|
"""Tests for cleanup_tables helper."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_truncates_all_tables(self):
|
||||||
|
"""All table rows are removed after cleanup_tables."""
|
||||||
|
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
||||||
|
role = Role(id=uuid.uuid4(), name="cleanup_role")
|
||||||
|
session.add(role)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
username="cleanup_user",
|
||||||
|
email="cleanup@test.com",
|
||||||
|
role_id=role.id,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Verify rows exist
|
||||||
|
roles_count = await RoleCrud.count(session)
|
||||||
|
users_count = await UserCrud.count(session)
|
||||||
|
assert roles_count == 1
|
||||||
|
assert users_count == 1
|
||||||
|
|
||||||
|
await cleanup_tables(session, Base)
|
||||||
|
|
||||||
|
# Verify tables are empty
|
||||||
|
roles_count = await RoleCrud.count(session)
|
||||||
|
users_count = await UserCrud.count(session)
|
||||||
|
assert roles_count == 0
|
||||||
|
assert users_count == 0
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_noop_for_empty_metadata(self):
|
||||||
|
"""cleanup_tables does not raise when metadata has no tables."""
|
||||||
|
|
||||||
|
class EmptyBase(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
||||||
|
# Should not raise
|
||||||
|
await cleanup_tables(session, EmptyBase)
|
||||||
|
|||||||
395
tests/test_example_pagination_search.py
Normal file
395
tests/test_example_pagination_search.py
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
"""Live test for the docs/examples/pagination-search.md example.
|
||||||
|
|
||||||
|
Spins up the exact FastAPI app described in the example (sourced from
|
||||||
|
docs_src/examples/pagination_search/) and exercises it through a real HTTP
|
||||||
|
client against a real PostgreSQL database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from docs_src.examples.pagination_search.db import get_db
|
||||||
|
from docs_src.examples.pagination_search.models import Article, Base, Category
|
||||||
|
from docs_src.examples.pagination_search.routes import router
|
||||||
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||||
|
|
||||||
|
from .conftest import DATABASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
def build_app(session: AsyncSession) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
app.include_router(router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
async def ex_db_session():
|
||||||
|
"""Isolated session for the example models (separate tables from conftest)."""
|
||||||
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
session = session_factory()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(ex_db_session: AsyncSession):
|
||||||
|
app = build_app(ex_db_session)
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
async def seed(session: AsyncSession):
|
||||||
|
"""Insert representative fixture data."""
|
||||||
|
python = Category(name="python")
|
||||||
|
backend = Category(name="backend")
|
||||||
|
session.add_all([python, backend])
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
now = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
Article(
|
||||||
|
title="FastAPI tips",
|
||||||
|
body="Ten useful tips for FastAPI.",
|
||||||
|
status="published",
|
||||||
|
published=True,
|
||||||
|
category_id=python.id,
|
||||||
|
created_at=now,
|
||||||
|
),
|
||||||
|
Article(
|
||||||
|
title="SQLAlchemy async",
|
||||||
|
body="How to use async SQLAlchemy.",
|
||||||
|
status="published",
|
||||||
|
published=True,
|
||||||
|
category_id=backend.id,
|
||||||
|
created_at=now + datetime.timedelta(seconds=1),
|
||||||
|
),
|
||||||
|
Article(
|
||||||
|
title="Draft notes",
|
||||||
|
body="Work in progress.",
|
||||||
|
status="draft",
|
||||||
|
published=False,
|
||||||
|
category_id=None,
|
||||||
|
created_at=now + datetime.timedelta(seconds=2),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppSessionDep:
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_db_yields_async_session(self):
|
||||||
|
"""get_db yields a real AsyncSession when called directly."""
|
||||||
|
from docs_src.examples.pagination_search.db import get_db
|
||||||
|
|
||||||
|
gen = get_db()
|
||||||
|
session = await gen.__anext__()
|
||||||
|
assert isinstance(session, AsyncSession)
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestOffsetPagination:
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_returns_all_articles(self, client: AsyncClient, ex_db_session):
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/offset")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["pagination"]["total_count"] == 3
|
||||||
|
assert len(body["data"]) == 3
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_pagination_page_size(self, client: AsyncClient, ex_db_session):
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/offset?items_per_page=2&page=1")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert len(body["data"]) == 2
|
||||||
|
assert body["pagination"]["total_count"] == 3
|
||||||
|
assert body["pagination"]["has_more"] is True
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_full_text_search(self, client: AsyncClient, ex_db_session):
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/offset?search=fastapi")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["pagination"]["total_count"] == 1
|
||||||
|
assert body["data"][0]["title"] == "FastAPI tips"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_search_traverses_relationship(
|
||||||
|
self, client: AsyncClient, ex_db_session
|
||||||
|
):
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
# "python" matches Category.name, not Article.title or body
|
||||||
|
resp = await client.get("/articles/offset?search=python")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["pagination"]["total_count"] == 1
|
||||||
|
assert body["data"][0]["title"] == "FastAPI tips"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_facet_filter_scalar(self, client: AsyncClient, ex_db_session):
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/offset?status=published")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["pagination"]["total_count"] == 2
|
||||||
|
assert all(a["status"] == "published" for a in body["data"])
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_facet_filter_multi_value(self, client: AsyncClient, ex_db_session):
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/offset?status=published&status=draft")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["pagination"]["total_count"] == 3
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filter_attributes_in_response(
|
||||||
|
self, client: AsyncClient, ex_db_session
|
||||||
|
):
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/offset")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
fa = body["filter_attributes"]
|
||||||
|
assert set(fa["status"]) == {"draft", "published"}
|
||||||
|
# "name" is unique across all facet fields — no prefix needed
|
||||||
|
assert set(fa["name"]) == {"backend", "python"}
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filter_attributes_scoped_to_filter(
|
||||||
|
self, client: AsyncClient, ex_db_session
|
||||||
|
):
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/offset?status=published")
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
# draft is filtered out → should not appear in filter_attributes
|
||||||
|
assert "draft" not in body["filter_attributes"]["status"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_search_and_filter_combined(self, client: AsyncClient, ex_db_session):
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/offset?search=async&status=published")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["pagination"]["total_count"] == 1
|
||||||
|
assert body["data"][0]["title"] == "SQLAlchemy async"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCursorPagination:
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_first_page(self, client: AsyncClient, ex_db_session):
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/cursor?items_per_page=2")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert len(body["data"]) == 2
|
||||||
|
assert body["pagination"]["has_more"] is True
|
||||||
|
assert body["pagination"]["next_cursor"] is not None
|
||||||
|
assert body["pagination"]["prev_cursor"] is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_second_page(self, client: AsyncClient, ex_db_session):
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
first = await client.get("/articles/cursor?items_per_page=2")
|
||||||
|
next_cursor = first.json()["pagination"]["next_cursor"]
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
f"/articles/cursor?items_per_page=2&cursor={next_cursor}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert len(body["data"]) == 1
|
||||||
|
assert body["pagination"]["has_more"] is False
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_facet_filter(self, client: AsyncClient, ex_db_session):
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/cursor?status=draft")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert len(body["data"]) == 1
|
||||||
|
assert body["data"][0]["status"] == "draft"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_full_text_search(self, client: AsyncClient, ex_db_session):
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/cursor?search=sqlalchemy")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert len(body["data"]) == 1
|
||||||
|
assert body["data"][0]["title"] == "SQLAlchemy async"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOffsetSorting:
|
||||||
|
"""Tests for order_by / order query parameters on the offset endpoint."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_default_order_uses_created_at_asc(
|
||||||
|
self, client: AsyncClient, ex_db_session
|
||||||
|
):
|
||||||
|
"""No order_by → default field (created_at) ASC."""
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/offset")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
titles = [a["title"] for a in resp.json()["data"]]
|
||||||
|
assert titles == ["FastAPI tips", "SQLAlchemy async", "Draft notes"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_order_by_title_asc(self, client: AsyncClient, ex_db_session):
|
||||||
|
"""order_by=title&order=asc returns alphabetical order."""
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/offset?order_by=title&order=asc")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
titles = [a["title"] for a in resp.json()["data"]]
|
||||||
|
assert titles == ["Draft notes", "FastAPI tips", "SQLAlchemy async"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_order_by_title_desc(self, client: AsyncClient, ex_db_session):
|
||||||
|
"""order_by=title&order=desc returns reverse alphabetical order."""
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/offset?order_by=title&order=desc")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
titles = [a["title"] for a in resp.json()["data"]]
|
||||||
|
assert titles == ["SQLAlchemy async", "FastAPI tips", "Draft notes"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_order_by_created_at_desc(self, client: AsyncClient, ex_db_session):
|
||||||
|
"""order_by=created_at&order=desc returns newest-first."""
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/offset?order_by=created_at&order=desc")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
titles = [a["title"] for a in resp.json()["data"]]
|
||||||
|
assert titles == ["Draft notes", "SQLAlchemy async", "FastAPI tips"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_invalid_order_by_returns_422(
|
||||||
|
self, client: AsyncClient, ex_db_session
|
||||||
|
):
|
||||||
|
"""Unknown order_by field returns 422 with SORT-422 error code."""
|
||||||
|
resp = await client.get("/articles/offset?order_by=nonexistent_field")
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
body = resp.json()
|
||||||
|
assert body["error_code"] == "SORT-422"
|
||||||
|
assert body["status"] == "FAIL"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCursorSorting:
|
||||||
|
"""Tests for order_by / order query parameters on the cursor endpoint.
|
||||||
|
|
||||||
|
In cursor_paginate the cursor_column is always the primary sort; order_by
|
||||||
|
acts as a secondary tiebreaker. With the seeded articles (all having unique
|
||||||
|
created_at values) the overall ordering is always created_at ASC regardless
|
||||||
|
of the order_by value — only the valid/invalid field check and the response
|
||||||
|
shape are meaningful here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_default_order_uses_created_at_asc(
|
||||||
|
self, client: AsyncClient, ex_db_session
|
||||||
|
):
|
||||||
|
"""No order_by → default field (created_at) ASC."""
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/cursor")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
titles = [a["title"] for a in resp.json()["data"]]
|
||||||
|
assert titles == ["FastAPI tips", "SQLAlchemy async", "Draft notes"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_order_by_title_asc_accepted(
|
||||||
|
self, client: AsyncClient, ex_db_session
|
||||||
|
):
|
||||||
|
"""order_by=title is a valid field — request succeeds and returns all articles."""
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/cursor?order_by=title&order=asc")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()["data"]) == 3
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_order_by_title_desc_accepted(
|
||||||
|
self, client: AsyncClient, ex_db_session
|
||||||
|
):
|
||||||
|
"""order_by=title&order=desc is valid — request succeeds and returns all articles."""
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/cursor?order_by=title&order=desc")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()["data"]) == 3
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_invalid_order_by_returns_422(
|
||||||
|
self, client: AsyncClient, ex_db_session
|
||||||
|
):
|
||||||
|
"""Unknown order_by field returns 422 with SORT-422 error code."""
|
||||||
|
resp = await client.get("/articles/cursor?order_by=nonexistent_field")
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
body = resp.json()
|
||||||
|
assert body["error_code"] == "SORT-422"
|
||||||
|
assert body["status"] == "FAIL"
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import (
|
from fastapi_toolsets.exceptions import (
|
||||||
ApiException,
|
ApiException,
|
||||||
ConflictError,
|
ConflictError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
|
InvalidOrderFieldError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
generate_error_responses,
|
generate_error_responses,
|
||||||
@@ -35,8 +37,8 @@ class TestApiException:
|
|||||||
assert error.api_error.msg == "I'm a teapot"
|
assert error.api_error.msg == "I'm a teapot"
|
||||||
assert str(error) == "I'm a teapot"
|
assert str(error) == "I'm a teapot"
|
||||||
|
|
||||||
def test_custom_detail_message(self):
|
def test_detail_overrides_msg_and_str(self):
|
||||||
"""Custom detail overrides default message."""
|
"""detail sets both str(exc) and api_error.msg; class-level msg is unchanged."""
|
||||||
|
|
||||||
class CustomError(ApiException):
|
class CustomError(ApiException):
|
||||||
api_error = ApiError(
|
api_error = ApiError(
|
||||||
@@ -46,8 +48,172 @@ class TestApiException:
|
|||||||
err_code="BAD-400",
|
err_code="BAD-400",
|
||||||
)
|
)
|
||||||
|
|
||||||
error = CustomError("Custom message")
|
error = CustomError("Widget not found")
|
||||||
assert str(error) == "Custom message"
|
assert str(error) == "Widget not found"
|
||||||
|
assert error.api_error.msg == "Widget not found"
|
||||||
|
assert CustomError.api_error.msg == "Bad Request" # class unchanged
|
||||||
|
|
||||||
|
def test_desc_override(self):
|
||||||
|
"""desc kwarg overrides api_error.desc on the instance only."""
|
||||||
|
|
||||||
|
class MyError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||||
|
)
|
||||||
|
|
||||||
|
err = MyError(desc="Custom desc.")
|
||||||
|
assert err.api_error.desc == "Custom desc."
|
||||||
|
assert MyError.api_error.desc == "Default." # class unchanged
|
||||||
|
|
||||||
|
def test_data_override(self):
|
||||||
|
"""data kwarg sets api_error.data on the instance only."""
|
||||||
|
|
||||||
|
class MyError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||||
|
)
|
||||||
|
|
||||||
|
err = MyError(data={"key": "value"})
|
||||||
|
assert err.api_error.data == {"key": "value"}
|
||||||
|
assert MyError.api_error.data is None # class unchanged
|
||||||
|
|
||||||
|
def test_desc_and_data_override(self):
|
||||||
|
"""detail, desc and data can all be overridden together."""
|
||||||
|
|
||||||
|
class MyError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||||
|
)
|
||||||
|
|
||||||
|
err = MyError("custom msg", desc="New desc.", data={"x": 1})
|
||||||
|
assert str(err) == "custom msg"
|
||||||
|
assert err.api_error.msg == "custom msg" # detail also updates msg
|
||||||
|
assert err.api_error.desc == "New desc."
|
||||||
|
assert err.api_error.data == {"x": 1}
|
||||||
|
assert err.api_error.code == 400 # other fields unchanged
|
||||||
|
|
||||||
|
def test_class_api_error_not_mutated_after_instance_override(self):
|
||||||
|
"""Raising with desc/data does not mutate the class-level api_error."""
|
||||||
|
|
||||||
|
class MyError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||||
|
)
|
||||||
|
|
||||||
|
MyError(desc="Changed", data={"x": 1})
|
||||||
|
assert MyError.api_error.desc == "Default."
|
||||||
|
assert MyError.api_error.data is None
|
||||||
|
|
||||||
|
def test_subclass_uses_super_with_desc_and_data(self):
|
||||||
|
"""Subclasses can delegate detail/desc/data to super().__init__()."""
|
||||||
|
|
||||||
|
class BuildValidationError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=422,
|
||||||
|
msg="Build Validation Error",
|
||||||
|
desc="The build configuration is invalid.",
|
||||||
|
err_code="BUILD-422",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *errors: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
f"{len(errors)} validation error(s)",
|
||||||
|
desc=", ".join(errors),
|
||||||
|
data={"errors": [{"message": e} for e in errors]},
|
||||||
|
)
|
||||||
|
|
||||||
|
err = BuildValidationError("Field A is required", "Field B is invalid")
|
||||||
|
assert str(err) == "2 validation error(s)"
|
||||||
|
assert err.api_error.msg == "2 validation error(s)" # detail set msg
|
||||||
|
assert err.api_error.desc == "Field A is required, Field B is invalid"
|
||||||
|
assert err.api_error.data == {
|
||||||
|
"errors": [
|
||||||
|
{"message": "Field A is required"},
|
||||||
|
{"message": "Field B is invalid"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert err.api_error.code == 422 # other fields unchanged
|
||||||
|
|
||||||
|
def test_detail_desc_data_in_http_response(self):
|
||||||
|
"""detail/desc/data overrides all appear correctly in the FastAPI HTTP response."""
|
||||||
|
|
||||||
|
class DynamicError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, message: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
message,
|
||||||
|
desc=f"Detail: {message}",
|
||||||
|
data={"reason": message},
|
||||||
|
)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/error")
|
||||||
|
async def raise_error():
|
||||||
|
raise DynamicError("something went wrong")
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/error")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
body = response.json()
|
||||||
|
assert body["message"] == "something went wrong"
|
||||||
|
assert body["description"] == "Detail: something went wrong"
|
||||||
|
assert body["data"] == {"reason": "something went wrong"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiExceptionGuard:
|
||||||
|
"""Tests for the __init_subclass__ api_error guard."""
|
||||||
|
|
||||||
|
def test_missing_api_error_raises_type_error(self):
|
||||||
|
"""Defining a subclass without api_error raises TypeError at class creation time."""
|
||||||
|
with pytest.raises(
|
||||||
|
TypeError, match="must define an 'api_error' class attribute"
|
||||||
|
):
|
||||||
|
|
||||||
|
class BrokenError(ApiException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_abstract_subclass_skips_guard(self):
|
||||||
|
"""abstract=True allows intermediate base classes without api_error."""
|
||||||
|
|
||||||
|
class BaseGroupError(ApiException, abstract=True):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Concrete child must still define it
|
||||||
|
class ConcreteError(BaseGroupError):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Error", desc="Desc.", err_code="ERR-400"
|
||||||
|
)
|
||||||
|
|
||||||
|
err = ConcreteError()
|
||||||
|
assert err.api_error.code == 400
|
||||||
|
|
||||||
|
def test_abstract_child_still_requires_api_error_on_concrete(self):
|
||||||
|
"""Concrete subclass of an abstract class must define api_error."""
|
||||||
|
|
||||||
|
class Base(ApiException, abstract=True):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
TypeError, match="must define an 'api_error' class attribute"
|
||||||
|
):
|
||||||
|
|
||||||
|
class Concrete(Base):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_inherited_api_error_satisfies_guard(self):
|
||||||
|
"""Subclass that inherits api_error from a parent does not need its own."""
|
||||||
|
|
||||||
|
class ConcreteError(NotFoundError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
err = ConcreteError()
|
||||||
|
assert err.api_error.code == 404
|
||||||
|
|
||||||
|
|
||||||
class TestBuiltInExceptions:
|
class TestBuiltInExceptions:
|
||||||
@@ -89,7 +255,7 @@ class TestGenerateErrorResponses:
|
|||||||
assert responses[404]["description"] == "Not Found"
|
assert responses[404]["description"] == "Not Found"
|
||||||
|
|
||||||
def test_generates_multiple_responses(self):
|
def test_generates_multiple_responses(self):
|
||||||
"""Generates responses for multiple exceptions."""
|
"""Generates responses for multiple exceptions with distinct status codes."""
|
||||||
responses = generate_error_responses(
|
responses = generate_error_responses(
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
@@ -100,15 +266,24 @@ class TestGenerateErrorResponses:
|
|||||||
assert 403 in responses
|
assert 403 in responses
|
||||||
assert 404 in responses
|
assert 404 in responses
|
||||||
|
|
||||||
def test_response_has_example(self):
|
def test_response_has_named_example(self):
|
||||||
"""Generated response includes example."""
|
"""Generated response uses named examples keyed by err_code."""
|
||||||
responses = generate_error_responses(NotFoundError)
|
responses = generate_error_responses(NotFoundError)
|
||||||
example = responses[404]["content"]["application/json"]["example"]
|
examples = responses[404]["content"]["application/json"]["examples"]
|
||||||
|
|
||||||
assert example["status"] == "FAIL"
|
assert "RES-404" in examples
|
||||||
assert example["error_code"] == "RES-404"
|
value = examples["RES-404"]["value"]
|
||||||
assert example["message"] == "Not Found"
|
assert value["status"] == "FAIL"
|
||||||
assert example["data"] is None
|
assert value["error_code"] == "RES-404"
|
||||||
|
assert value["message"] == "Not Found"
|
||||||
|
assert value["data"] is None
|
||||||
|
|
||||||
|
def test_response_example_has_summary(self):
|
||||||
|
"""Each named example carries a summary equal to api_error.msg."""
|
||||||
|
responses = generate_error_responses(NotFoundError)
|
||||||
|
example = responses[404]["content"]["application/json"]["examples"]["RES-404"]
|
||||||
|
|
||||||
|
assert example["summary"] == "Not Found"
|
||||||
|
|
||||||
def test_response_example_with_data(self):
|
def test_response_example_with_data(self):
|
||||||
"""Generated response includes data when set on ApiError."""
|
"""Generated response includes data when set on ApiError."""
|
||||||
@@ -123,9 +298,49 @@ class TestGenerateErrorResponses:
|
|||||||
)
|
)
|
||||||
|
|
||||||
responses = generate_error_responses(ErrorWithData)
|
responses = generate_error_responses(ErrorWithData)
|
||||||
example = responses[400]["content"]["application/json"]["example"]
|
value = responses[400]["content"]["application/json"]["examples"]["BAD-400"][
|
||||||
|
"value"
|
||||||
|
]
|
||||||
|
|
||||||
assert example["data"] == {"details": "some context"}
|
assert value["data"] == {"details": "some context"}
|
||||||
|
|
||||||
|
def test_two_errors_same_code_both_present(self):
|
||||||
|
"""Two exceptions with the same HTTP code produce two named examples."""
|
||||||
|
|
||||||
|
class BadRequestA(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Bad A", desc="Reason A.", err_code="ERR-A"
|
||||||
|
)
|
||||||
|
|
||||||
|
class BadRequestB(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Bad B", desc="Reason B.", err_code="ERR-B"
|
||||||
|
)
|
||||||
|
|
||||||
|
responses = generate_error_responses(BadRequestA, BadRequestB)
|
||||||
|
|
||||||
|
assert 400 in responses
|
||||||
|
examples = responses[400]["content"]["application/json"]["examples"]
|
||||||
|
assert "ERR-A" in examples
|
||||||
|
assert "ERR-B" in examples
|
||||||
|
assert examples["ERR-A"]["value"]["message"] == "Bad A"
|
||||||
|
assert examples["ERR-B"]["value"]["message"] == "Bad B"
|
||||||
|
|
||||||
|
def test_two_errors_same_code_single_top_level_entry(self):
|
||||||
|
"""Two exceptions with the same HTTP code produce exactly one top-level entry."""
|
||||||
|
|
||||||
|
class BadRequestA(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Bad A", desc="Reason A.", err_code="ERR-A"
|
||||||
|
)
|
||||||
|
|
||||||
|
class BadRequestB(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Bad B", desc="Reason B.", err_code="ERR-B"
|
||||||
|
)
|
||||||
|
|
||||||
|
responses = generate_error_responses(BadRequestA, BadRequestB)
|
||||||
|
assert len([k for k in responses if k == 400]) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestInitExceptionsHandlers:
|
class TestInitExceptionsHandlers:
|
||||||
@@ -249,13 +464,68 @@ class TestInitExceptionsHandlers:
|
|||||||
assert data["status"] == "FAIL"
|
assert data["status"] == "FAIL"
|
||||||
assert data["error_code"] == "SERVER-500"
|
assert data["error_code"] == "SERVER-500"
|
||||||
|
|
||||||
def test_custom_openapi_schema(self):
|
def test_handles_http_exception(self):
|
||||||
"""Customizes OpenAPI schema for 422 responses."""
|
"""Handles starlette HTTPException with consistent ErrorResponse envelope."""
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
init_exceptions_handlers(app)
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/protected")
|
||||||
|
async def protected():
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/protected")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "FAIL"
|
||||||
|
assert data["error_code"] == "HTTP-403"
|
||||||
|
assert data["message"] == "Forbidden"
|
||||||
|
|
||||||
|
def test_handles_http_exception_404_from_route(self):
|
||||||
|
"""HTTPException(404) raised inside a route uses the consistent ErrorResponse envelope."""
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/items/{item_id}")
|
||||||
|
async def get_item(item_id: int):
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/items/99")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "FAIL"
|
||||||
|
assert data["error_code"] == "HTTP-404"
|
||||||
|
assert data["message"] == "Item not found"
|
||||||
|
|
||||||
|
def test_handles_http_exception_forwards_headers(self):
|
||||||
|
"""HTTPException with WWW-Authenticate header forwards it in the response."""
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/secure")
|
||||||
|
async def secure():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/secure")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.headers.get("www-authenticate") == "Bearer"
|
||||||
|
|
||||||
|
def test_custom_openapi_schema(self):
|
||||||
|
"""Customises OpenAPI schema for 422 responses using named examples."""
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
class Item(BaseModel):
|
class Item(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
@@ -268,8 +538,128 @@ class TestInitExceptionsHandlers:
|
|||||||
post_op = openapi["paths"]["/items"]["post"]
|
post_op = openapi["paths"]["/items"]["post"]
|
||||||
assert "422" in post_op["responses"]
|
assert "422" in post_op["responses"]
|
||||||
resp_422 = post_op["responses"]["422"]
|
resp_422 = post_op["responses"]["422"]
|
||||||
example = resp_422["content"]["application/json"]["example"]
|
examples = resp_422["content"]["application/json"]["examples"]
|
||||||
assert example["error_code"] == "VAL-422"
|
assert "VAL-422" in examples
|
||||||
|
assert examples["VAL-422"]["value"]["error_code"] == "VAL-422"
|
||||||
|
|
||||||
|
def test_custom_openapi_preserves_app_metadata(self):
|
||||||
|
"""_patched_openapi preserves custom FastAPI app-level metadata."""
|
||||||
|
app = FastAPI(
|
||||||
|
title="My API",
|
||||||
|
version="2.0.0",
|
||||||
|
description="Custom description",
|
||||||
|
)
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
schema = app.openapi()
|
||||||
|
assert schema["info"]["title"] == "My API"
|
||||||
|
assert schema["info"]["version"] == "2.0.0"
|
||||||
|
|
||||||
|
def test_handles_response_validation_error(self):
|
||||||
|
"""Handles ResponseValidationError with a structured 422 response."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class CountResponse(BaseModel):
|
||||||
|
count: int
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/broken", response_model=CountResponse)
|
||||||
|
async def broken():
|
||||||
|
return {"count": "not-a-number"} # triggers ResponseValidationError
|
||||||
|
|
||||||
|
client = TestClient(app, raise_server_exceptions=False)
|
||||||
|
response = client.get("/broken")
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "FAIL"
|
||||||
|
assert data["error_code"] == "VAL-422"
|
||||||
|
assert "errors" in data["data"]
|
||||||
|
|
||||||
|
def test_handles_validation_error_with_non_standard_loc(self):
|
||||||
|
"""Validation error with empty loc tuple maps the field to 'root'."""
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/root-error")
|
||||||
|
async def root_error():
|
||||||
|
raise RequestValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "custom",
|
||||||
|
"loc": (),
|
||||||
|
"msg": "root level error",
|
||||||
|
"input": None,
|
||||||
|
"url": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/root-error")
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert data["data"]["errors"][0]["field"] == "root"
|
||||||
|
|
||||||
|
def test_openapi_schema_cached_after_first_call(self):
|
||||||
|
"""app.openapi() returns the cached schema on subsequent calls."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
class Item(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
@app.post("/items")
|
||||||
|
async def create_item(item: Item):
|
||||||
|
return item
|
||||||
|
|
||||||
|
schema_first = app.openapi()
|
||||||
|
schema_second = app.openapi()
|
||||||
|
assert schema_first is schema_second
|
||||||
|
|
||||||
|
def test_openapi_skips_operations_without_422(self):
|
||||||
|
"""_patched_openapi leaves operations that have no 422 response unchanged."""
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/ping")
|
||||||
|
async def ping():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
schema = app.openapi()
|
||||||
|
get_op = schema["paths"]["/ping"]["get"]
|
||||||
|
assert "422" not in get_op["responses"]
|
||||||
|
assert "200" in get_op["responses"]
|
||||||
|
|
||||||
|
def test_openapi_skips_non_dict_path_item_values(self):
|
||||||
|
"""_patched_openapi ignores non-dict values in path items (e.g. path-level parameters)."""
|
||||||
|
from fastapi_toolsets.exceptions.handler import _patched_openapi
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
def fake_openapi() -> dict:
|
||||||
|
return {
|
||||||
|
"paths": {
|
||||||
|
"/items": {
|
||||||
|
"parameters": [
|
||||||
|
{"name": "q", "in": "query"}
|
||||||
|
], # list, not a dict
|
||||||
|
"get": {"responses": {"200": {"description": "OK"}}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schema = _patched_openapi(app, fake_openapi)
|
||||||
|
# The list value was skipped without error; the GET operation is intact
|
||||||
|
assert schema["paths"]["/items"]["parameters"] == [{"name": "q", "in": "query"}]
|
||||||
|
assert "422" not in schema["paths"]["/items"]["get"]["responses"]
|
||||||
|
|
||||||
|
|
||||||
class TestExceptionIntegration:
|
class TestExceptionIntegration:
|
||||||
@@ -334,3 +724,43 @@ class TestExceptionIntegration:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"id": 1}
|
assert response.json() == {"id": 1}
|
||||||
|
|
||||||
|
|
||||||
|
class TestInvalidOrderFieldError:
|
||||||
|
"""Tests for InvalidOrderFieldError exception."""
|
||||||
|
|
||||||
|
def test_api_error_attributes(self):
|
||||||
|
"""InvalidOrderFieldError has correct api_error metadata."""
|
||||||
|
assert InvalidOrderFieldError.api_error.code == 422
|
||||||
|
assert InvalidOrderFieldError.api_error.err_code == "SORT-422"
|
||||||
|
assert InvalidOrderFieldError.api_error.msg == "Invalid Order Field"
|
||||||
|
|
||||||
|
def test_stores_field_and_valid_fields(self):
|
||||||
|
"""InvalidOrderFieldError stores field and valid_fields on the instance."""
|
||||||
|
error = InvalidOrderFieldError("unknown", ["name", "created_at"])
|
||||||
|
assert error.field == "unknown"
|
||||||
|
assert error.valid_fields == ["name", "created_at"]
|
||||||
|
|
||||||
|
def test_description_contains_field_and_valid_fields(self):
|
||||||
|
"""api_error.desc mentions the bad field and valid options."""
|
||||||
|
error = InvalidOrderFieldError("bad_field", ["name", "email"])
|
||||||
|
assert "bad_field" in error.api_error.desc
|
||||||
|
assert "name" in error.api_error.desc
|
||||||
|
assert "email" in error.api_error.desc
|
||||||
|
|
||||||
|
def test_handled_as_422_by_exception_handler(self):
|
||||||
|
"""init_exceptions_handlers turns InvalidOrderFieldError into a 422 response."""
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/items")
|
||||||
|
async def list_items():
|
||||||
|
raise InvalidOrderFieldError("bad", ["name"])
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/items")
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert data["error_code"] == "SORT-422"
|
||||||
|
assert data["status"] == "FAIL"
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ from fastapi_toolsets.fixtures import (
|
|||||||
load_fixtures_by_context,
|
load_fixtures_by_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .conftest import Role, User
|
from fastapi_toolsets.fixtures.utils import _get_primary_key
|
||||||
|
|
||||||
|
from .conftest import IntRole, Permission, Role, User
|
||||||
|
|
||||||
|
|
||||||
class TestContext:
|
class TestContext:
|
||||||
@@ -597,6 +599,46 @@ class TestLoadFixtures:
|
|||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_skip_existing_skips_if_record_exists(self, db_session: AsyncSession):
|
||||||
|
"""SKIP_EXISTING returns empty loaded list when the record already exists."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
role_id = uuid.uuid4()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def roles():
|
||||||
|
return [Role(id=role_id, name="admin")]
|
||||||
|
|
||||||
|
# First load — inserts the record.
|
||||||
|
result1 = await load_fixtures(
|
||||||
|
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||||
|
)
|
||||||
|
assert len(result1["roles"]) == 1
|
||||||
|
|
||||||
|
# Remove from identity map so session.get() queries the DB in the second load.
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
# Second load — record exists in DB, nothing should be added.
|
||||||
|
result2 = await load_fixtures(
|
||||||
|
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||||
|
)
|
||||||
|
assert result2["roles"] == []
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_skip_existing_null_pk_inserts(self, db_session: AsyncSession):
|
||||||
|
"""SKIP_EXISTING inserts when the instance has no PK set (auto-increment)."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def int_roles():
|
||||||
|
# No id provided — PK is None before INSERT (autoincrement).
|
||||||
|
return [IntRole(name="member")]
|
||||||
|
|
||||||
|
result = await load_fixtures(
|
||||||
|
db_session, registry, "int_roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||||
|
)
|
||||||
|
assert len(result["int_roles"]) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestLoadFixturesByContext:
|
class TestLoadFixturesByContext:
|
||||||
"""Tests for load_fixtures_by_context function."""
|
"""Tests for load_fixtures_by_context function."""
|
||||||
@@ -755,3 +797,19 @@ class TestGetObjByAttr:
|
|||||||
"""Raises StopIteration when value type doesn't match."""
|
"""Raises StopIteration when value type doesn't match."""
|
||||||
with pytest.raises(StopIteration):
|
with pytest.raises(StopIteration):
|
||||||
get_obj_by_attr(self.roles, "id", "not-a-uuid")
|
get_obj_by_attr(self.roles, "id", "not-a-uuid")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPrimaryKey:
|
||||||
|
"""Unit tests for the _get_primary_key helper (composite PK paths)."""
|
||||||
|
|
||||||
|
def test_composite_pk_all_set(self):
|
||||||
|
"""Returns a tuple when all composite PK values are set."""
|
||||||
|
instance = Permission(subject="post", action="read")
|
||||||
|
pk = _get_primary_key(instance)
|
||||||
|
assert pk == ("post", "read")
|
||||||
|
|
||||||
|
def test_composite_pk_partial_none(self):
|
||||||
|
"""Returns None when any composite PK value is None."""
|
||||||
|
instance = Permission(subject="post") # action is None
|
||||||
|
pk = _get_primary_key(instance)
|
||||||
|
assert pk is None
|
||||||
|
|||||||
@@ -159,6 +159,42 @@ class TestMetricsRegistry:
|
|||||||
assert registry.get_all()[0].func is second
|
assert registry.get_all()[0].func is second
|
||||||
|
|
||||||
|
|
||||||
|
class TestGet:
|
||||||
|
"""Tests for MetricsRegistry.get method."""
|
||||||
|
|
||||||
|
def test_get_returns_instance_after_init(self):
|
||||||
|
"""get() returns the metric instance stored by init_metrics."""
|
||||||
|
app = FastAPI()
|
||||||
|
registry = MetricsRegistry()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def my_gauge():
|
||||||
|
return Gauge("get_test_gauge", "A test gauge")
|
||||||
|
|
||||||
|
init_metrics(app, registry)
|
||||||
|
|
||||||
|
instance = registry.get("my_gauge")
|
||||||
|
assert isinstance(instance, Gauge)
|
||||||
|
|
||||||
|
def test_get_raises_for_registered_but_not_initialized(self):
|
||||||
|
"""get() raises KeyError with an informative message when init_metrics was not called."""
|
||||||
|
registry = MetricsRegistry()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def my_counter():
|
||||||
|
return Counter("get_uninit_counter", "A counter")
|
||||||
|
|
||||||
|
with pytest.raises(KeyError, match="not been initialized yet"):
|
||||||
|
registry.get("my_counter")
|
||||||
|
|
||||||
|
def test_get_raises_for_unknown_name(self):
|
||||||
|
"""get() raises KeyError when the metric name is not registered at all."""
|
||||||
|
registry = MetricsRegistry()
|
||||||
|
|
||||||
|
with pytest.raises(KeyError, match="Unknown metric"):
|
||||||
|
registry.get("nonexistent")
|
||||||
|
|
||||||
|
|
||||||
class TestIncludeRegistry:
|
class TestIncludeRegistry:
|
||||||
"""Tests for MetricsRegistry.include_registry method."""
|
"""Tests for MetricsRegistry.include_registry method."""
|
||||||
|
|
||||||
|
|||||||
247
tests/test_models.py
Normal file
247
tests/test_models.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""Tests for fastapi_toolsets.models mixins."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import String
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
from fastapi_toolsets.models import (
|
||||||
|
CreatedAtMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
UUIDMixin,
|
||||||
|
UpdatedAtMixin,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .conftest import DATABASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
class MixinBase(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDModel(MixinBase, UUIDMixin):
|
||||||
|
__tablename__ = "mixin_uuid_models"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatedAtModel(MixinBase, UpdatedAtMixin):
|
||||||
|
__tablename__ = "mixin_updated_at_models"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
|
||||||
|
class CreatedAtModel(MixinBase, CreatedAtMixin):
|
||||||
|
__tablename__ = "mixin_created_at_models"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampModel(MixinBase, TimestampMixin):
|
||||||
|
__tablename__ = "mixin_timestamp_models"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
|
||||||
|
class FullMixinModel(MixinBase, UUIDMixin, UpdatedAtMixin):
|
||||||
|
__tablename__ = "mixin_full_models"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
async def mixin_session():
|
||||||
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(MixinBase.metadata.create_all)
|
||||||
|
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
session = session_factory()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(MixinBase.metadata.drop_all)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUUIDMixin:
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_uuid_generated_by_db(self, mixin_session):
|
||||||
|
"""UUID is generated server-side and populated after flush."""
|
||||||
|
obj = UUIDModel(name="test")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.flush()
|
||||||
|
|
||||||
|
assert obj.id is not None
|
||||||
|
assert isinstance(obj.id, uuid.UUID)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_uuid_is_primary_key(self):
|
||||||
|
"""UUIDMixin adds id as primary key column."""
|
||||||
|
pk_cols = [c.name for c in UUIDModel.__table__.primary_key]
|
||||||
|
assert pk_cols == ["id"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_each_row_gets_unique_uuid(self, mixin_session):
|
||||||
|
"""Each inserted row gets a distinct UUID."""
|
||||||
|
a = UUIDModel(name="a")
|
||||||
|
b = UUIDModel(name="b")
|
||||||
|
mixin_session.add_all([a, b])
|
||||||
|
await mixin_session.flush()
|
||||||
|
|
||||||
|
assert a.id != b.id
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_uuid_server_default_set(self):
|
||||||
|
"""Column has gen_random_uuid() as server default."""
|
||||||
|
col = UUIDModel.__table__.c["id"]
|
||||||
|
assert col.server_default is not None
|
||||||
|
assert "gen_random_uuid" in str(col.server_default.arg)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdatedAtMixin:
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_updated_at_set_on_insert(self, mixin_session):
|
||||||
|
"""updated_at is populated after insert."""
|
||||||
|
obj = UpdatedAtModel(name="initial")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.refresh(obj)
|
||||||
|
|
||||||
|
assert obj.updated_at is not None
|
||||||
|
assert obj.updated_at.tzinfo is not None # timezone-aware
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_updated_at_changes_on_update(self, mixin_session):
|
||||||
|
"""updated_at is updated when the row is modified."""
|
||||||
|
obj = UpdatedAtModel(name="initial")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.refresh(obj)
|
||||||
|
|
||||||
|
original_ts = obj.updated_at
|
||||||
|
|
||||||
|
obj.name = "modified"
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.refresh(obj)
|
||||||
|
|
||||||
|
assert obj.updated_at >= original_ts
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_updated_at_column_is_not_nullable(self):
|
||||||
|
"""updated_at column is non-nullable."""
|
||||||
|
col = UpdatedAtModel.__table__.c["updated_at"]
|
||||||
|
assert not col.nullable
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_updated_at_has_server_default(self):
|
||||||
|
"""updated_at column has a server-side default."""
|
||||||
|
col = UpdatedAtModel.__table__.c["updated_at"]
|
||||||
|
assert col.server_default is not None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_updated_at_has_onupdate(self):
|
||||||
|
"""updated_at column has an onupdate clause."""
|
||||||
|
col = UpdatedAtModel.__table__.c["updated_at"]
|
||||||
|
assert col.onupdate is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreatedAtMixin:
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_created_at_set_on_insert(self, mixin_session):
|
||||||
|
"""created_at is populated after insert."""
|
||||||
|
obj = CreatedAtModel(name="new")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.refresh(obj)
|
||||||
|
|
||||||
|
assert obj.created_at is not None
|
||||||
|
assert obj.created_at.tzinfo is not None # timezone-aware
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_created_at_not_changed_on_update(self, mixin_session):
|
||||||
|
"""created_at is not modified when the row is updated."""
|
||||||
|
obj = CreatedAtModel(name="original")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.refresh(obj)
|
||||||
|
|
||||||
|
original_ts = obj.created_at
|
||||||
|
|
||||||
|
obj.name = "updated"
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.refresh(obj)
|
||||||
|
|
||||||
|
assert obj.created_at == original_ts
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_created_at_column_is_not_nullable(self):
|
||||||
|
"""created_at column is non-nullable."""
|
||||||
|
col = CreatedAtModel.__table__.c["created_at"]
|
||||||
|
assert not col.nullable
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_created_at_has_no_onupdate(self):
|
||||||
|
"""created_at column has no onupdate clause."""
|
||||||
|
col = CreatedAtModel.__table__.c["created_at"]
|
||||||
|
assert col.onupdate is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimestampMixin:
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_both_columns_set_on_insert(self, mixin_session):
|
||||||
|
"""created_at and updated_at are both populated after insert."""
|
||||||
|
obj = TimestampModel(name="new")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.refresh(obj)
|
||||||
|
|
||||||
|
assert obj.created_at is not None
|
||||||
|
assert obj.updated_at is not None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_created_at_stable_updated_at_changes_on_update(self, mixin_session):
|
||||||
|
"""On update: created_at stays the same, updated_at advances."""
|
||||||
|
obj = TimestampModel(name="original")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.refresh(obj)
|
||||||
|
|
||||||
|
original_created = obj.created_at
|
||||||
|
original_updated = obj.updated_at
|
||||||
|
|
||||||
|
obj.name = "modified"
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.refresh(obj)
|
||||||
|
|
||||||
|
assert obj.created_at == original_created
|
||||||
|
assert obj.updated_at >= original_updated
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_timestamp_mixin_has_both_columns(self):
|
||||||
|
"""TimestampModel exposes both created_at and updated_at columns."""
|
||||||
|
col_names = {c.name for c in TimestampModel.__table__.columns}
|
||||||
|
assert "created_at" in col_names
|
||||||
|
assert "updated_at" in col_names
|
||||||
|
|
||||||
|
|
||||||
|
class TestFullMixinModel:
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_combined_mixins_work_together(self, mixin_session):
|
||||||
|
"""UUIDMixin and UpdatedAtMixin can be combined on the same model."""
|
||||||
|
obj = FullMixinModel(name="combined")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.refresh(obj)
|
||||||
|
|
||||||
|
assert isinstance(obj.id, uuid.UUID)
|
||||||
|
assert obj.updated_at is not None
|
||||||
|
assert obj.updated_at.tzinfo is not None
|
||||||
@@ -8,11 +8,10 @@ from httpx import AsyncClient
|
|||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from fastapi_toolsets.fixtures import Context, FixtureRegistry
|
from fastapi_toolsets.fixtures import Context, FixtureRegistry
|
||||||
from fastapi_toolsets.pytest import (
|
from fastapi_toolsets.pytest import (
|
||||||
cleanup_tables,
|
|
||||||
create_async_client,
|
create_async_client,
|
||||||
create_db_session,
|
create_db_session,
|
||||||
create_worker_database,
|
create_worker_database,
|
||||||
@@ -406,7 +405,6 @@ class TestCreateWorkerDatabase:
|
|||||||
) as url:
|
) as url:
|
||||||
assert make_url(url).database == expected_db
|
assert make_url(url).database == expected_db
|
||||||
|
|
||||||
# Verify the database exists while inside the context
|
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -416,7 +414,6 @@ class TestCreateWorkerDatabase:
|
|||||||
assert result.scalar() == 1
|
assert result.scalar() == 1
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
# After context exit the database should be dropped
|
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -439,7 +436,6 @@ class TestCreateWorkerDatabase:
|
|||||||
async with create_worker_database(DATABASE_URL) as url:
|
async with create_worker_database(DATABASE_URL) as url:
|
||||||
assert make_url(url).database == expected_db
|
assert make_url(url).database == expected_db
|
||||||
|
|
||||||
# Verify the database exists while inside the context
|
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -449,7 +445,6 @@ class TestCreateWorkerDatabase:
|
|||||||
assert result.scalar() == 1
|
assert result.scalar() == 1
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
# After context exit the database should be dropped
|
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -467,18 +462,15 @@ class TestCreateWorkerDatabase:
|
|||||||
worker_database_url(DATABASE_URL, default_test_db="unused")
|
worker_database_url(DATABASE_URL, default_test_db="unused")
|
||||||
).database
|
).database
|
||||||
|
|
||||||
# Pre-create the database to simulate a stale leftover
|
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {expected_db}"))
|
await conn.execute(text(f"DROP DATABASE IF EXISTS {expected_db}"))
|
||||||
await conn.execute(text(f"CREATE DATABASE {expected_db}"))
|
await conn.execute(text(f"CREATE DATABASE {expected_db}"))
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
# Should succeed despite the database already existing
|
|
||||||
async with create_worker_database(DATABASE_URL) as url:
|
async with create_worker_database(DATABASE_URL) as url:
|
||||||
assert make_url(url).database == expected_db
|
assert make_url(url).database == expected_db
|
||||||
|
|
||||||
# Verify cleanup after context exit
|
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -487,49 +479,3 @@ class TestCreateWorkerDatabase:
|
|||||||
)
|
)
|
||||||
assert result.scalar() is None
|
assert result.scalar() is None
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
class TestCleanupTables:
|
|
||||||
"""Tests for cleanup_tables helper."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_truncates_all_tables(self):
|
|
||||||
"""All table rows are removed after cleanup_tables."""
|
|
||||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
|
||||||
role = Role(id=uuid.uuid4(), name="cleanup_role")
|
|
||||||
session.add(role)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
user = User(
|
|
||||||
id=uuid.uuid4(),
|
|
||||||
username="cleanup_user",
|
|
||||||
email="cleanup@test.com",
|
|
||||||
role_id=role.id,
|
|
||||||
)
|
|
||||||
session.add(user)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Verify rows exist
|
|
||||||
roles_count = await RoleCrud.count(session)
|
|
||||||
users_count = await UserCrud.count(session)
|
|
||||||
assert roles_count == 1
|
|
||||||
assert users_count == 1
|
|
||||||
|
|
||||||
await cleanup_tables(session, Base)
|
|
||||||
|
|
||||||
# Verify tables are empty
|
|
||||||
roles_count = await RoleCrud.count(session)
|
|
||||||
users_count = await UserCrud.count(session)
|
|
||||||
assert roles_count == 0
|
|
||||||
assert users_count == 0
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_noop_for_empty_metadata(self):
|
|
||||||
"""cleanup_tables does not raise when metadata has no tables."""
|
|
||||||
|
|
||||||
class EmptyBase(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
|
||||||
# Should not raise
|
|
||||||
await cleanup_tables(session, EmptyBase)
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ from pydantic import ValidationError
|
|||||||
|
|
||||||
from fastapi_toolsets.schemas import (
|
from fastapi_toolsets.schemas import (
|
||||||
ApiError,
|
ApiError,
|
||||||
|
CursorPagination,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
|
OffsetPagination,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Pagination,
|
|
||||||
Response,
|
Response,
|
||||||
ResponseStatus,
|
ResponseStatus,
|
||||||
)
|
)
|
||||||
@@ -154,12 +155,12 @@ class TestErrorResponse:
|
|||||||
assert data["description"] == "Details"
|
assert data["description"] == "Details"
|
||||||
|
|
||||||
|
|
||||||
class TestPagination:
|
class TestOffsetPagination:
|
||||||
"""Tests for Pagination schema."""
|
"""Tests for OffsetPagination schema (canonical name for offset-based pagination)."""
|
||||||
|
|
||||||
def test_create_pagination(self):
|
def test_create_pagination(self):
|
||||||
"""Create Pagination with all fields."""
|
"""Create OffsetPagination with all fields."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=100,
|
total_count=100,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -173,7 +174,7 @@ class TestPagination:
|
|||||||
|
|
||||||
def test_last_page_has_more_false(self):
|
def test_last_page_has_more_false(self):
|
||||||
"""Last page has has_more=False."""
|
"""Last page has has_more=False."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=25,
|
total_count=25,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=3,
|
page=3,
|
||||||
@@ -183,8 +184,8 @@ class TestPagination:
|
|||||||
assert pagination.has_more is False
|
assert pagination.has_more is False
|
||||||
|
|
||||||
def test_serialization(self):
|
def test_serialization(self):
|
||||||
"""Pagination serializes correctly."""
|
"""OffsetPagination serializes correctly."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=50,
|
total_count=50,
|
||||||
items_per_page=20,
|
items_per_page=20,
|
||||||
page=2,
|
page=2,
|
||||||
@@ -198,12 +199,69 @@ class TestPagination:
|
|||||||
assert data["has_more"] is True
|
assert data["has_more"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestCursorPagination:
|
||||||
|
"""Tests for CursorPagination schema."""
|
||||||
|
|
||||||
|
def test_create_with_next_cursor(self):
|
||||||
|
"""CursorPagination with a next cursor indicates more pages."""
|
||||||
|
pagination = CursorPagination(
|
||||||
|
next_cursor="eyJ2YWx1ZSI6ICIxMjMifQ==",
|
||||||
|
items_per_page=20,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
assert pagination.next_cursor == "eyJ2YWx1ZSI6ICIxMjMifQ=="
|
||||||
|
assert pagination.prev_cursor is None
|
||||||
|
assert pagination.items_per_page == 20
|
||||||
|
assert pagination.has_more is True
|
||||||
|
|
||||||
|
def test_create_last_page(self):
|
||||||
|
"""CursorPagination for the last page has next_cursor=None and has_more=False."""
|
||||||
|
pagination = CursorPagination(
|
||||||
|
next_cursor=None,
|
||||||
|
items_per_page=20,
|
||||||
|
has_more=False,
|
||||||
|
)
|
||||||
|
assert pagination.next_cursor is None
|
||||||
|
assert pagination.has_more is False
|
||||||
|
|
||||||
|
def test_prev_cursor_defaults_to_none(self):
|
||||||
|
"""prev_cursor defaults to None."""
|
||||||
|
pagination = CursorPagination(
|
||||||
|
next_cursor=None, items_per_page=10, has_more=False
|
||||||
|
)
|
||||||
|
assert pagination.prev_cursor is None
|
||||||
|
|
||||||
|
def test_prev_cursor_can_be_set(self):
|
||||||
|
"""prev_cursor can be explicitly set."""
|
||||||
|
pagination = CursorPagination(
|
||||||
|
next_cursor="next123",
|
||||||
|
prev_cursor="prev456",
|
||||||
|
items_per_page=10,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
assert pagination.prev_cursor == "prev456"
|
||||||
|
|
||||||
|
def test_serialization(self):
|
||||||
|
"""CursorPagination serializes correctly."""
|
||||||
|
pagination = CursorPagination(
|
||||||
|
next_cursor="abc123",
|
||||||
|
prev_cursor="xyz789",
|
||||||
|
items_per_page=20,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
data = pagination.model_dump()
|
||||||
|
assert data["next_cursor"] == "abc123"
|
||||||
|
assert data["prev_cursor"] == "xyz789"
|
||||||
|
assert data["items_per_page"] == 20
|
||||||
|
assert data["has_more"] is True
|
||||||
|
|
||||||
|
|
||||||
class TestPaginatedResponse:
|
class TestPaginatedResponse:
|
||||||
"""Tests for PaginatedResponse schema."""
|
"""Tests for PaginatedResponse schema."""
|
||||||
|
|
||||||
def test_create_paginated_response(self):
|
def test_create_paginated_response(self):
|
||||||
"""Create PaginatedResponse with data and pagination."""
|
"""Create PaginatedResponse with data and pagination."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=30,
|
total_count=30,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -214,13 +272,14 @@ class TestPaginatedResponse:
|
|||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(response.pagination, OffsetPagination)
|
||||||
assert len(response.data) == 2
|
assert len(response.data) == 2
|
||||||
assert response.pagination.total_count == 30
|
assert response.pagination.total_count == 30
|
||||||
assert response.status == ResponseStatus.SUCCESS
|
assert response.status == ResponseStatus.SUCCESS
|
||||||
|
|
||||||
def test_with_custom_message(self):
|
def test_with_custom_message(self):
|
||||||
"""PaginatedResponse with custom message."""
|
"""PaginatedResponse with custom message."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=5,
|
total_count=5,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -236,7 +295,7 @@ class TestPaginatedResponse:
|
|||||||
|
|
||||||
def test_empty_data(self):
|
def test_empty_data(self):
|
||||||
"""PaginatedResponse with empty data."""
|
"""PaginatedResponse with empty data."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=0,
|
total_count=0,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -247,6 +306,7 @@ class TestPaginatedResponse:
|
|||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(response.pagination, OffsetPagination)
|
||||||
assert response.data == []
|
assert response.data == []
|
||||||
assert response.pagination.total_count == 0
|
assert response.pagination.total_count == 0
|
||||||
|
|
||||||
@@ -257,7 +317,7 @@ class TestPaginatedResponse:
|
|||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=1,
|
total_count=1,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -272,7 +332,7 @@ class TestPaginatedResponse:
|
|||||||
|
|
||||||
def test_serialization(self):
|
def test_serialization(self):
|
||||||
"""PaginatedResponse serializes correctly."""
|
"""PaginatedResponse serializes correctly."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=100,
|
total_count=100,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=5,
|
page=5,
|
||||||
@@ -290,6 +350,26 @@ class TestPaginatedResponse:
|
|||||||
assert data["data"] == ["item1", "item2"]
|
assert data["data"] == ["item1", "item2"]
|
||||||
assert data["pagination"]["page"] == 5
|
assert data["pagination"]["page"] == 5
|
||||||
|
|
||||||
|
def test_pagination_field_accepts_offset_pagination(self):
|
||||||
|
"""PaginatedResponse.pagination accepts OffsetPagination."""
|
||||||
|
response = PaginatedResponse(
|
||||||
|
data=[1, 2],
|
||||||
|
pagination=OffsetPagination(
|
||||||
|
total_count=2, items_per_page=10, page=1, has_more=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert isinstance(response.pagination, OffsetPagination)
|
||||||
|
|
||||||
|
def test_pagination_field_accepts_cursor_pagination(self):
|
||||||
|
"""PaginatedResponse.pagination accepts CursorPagination."""
|
||||||
|
response = PaginatedResponse(
|
||||||
|
data=[1, 2],
|
||||||
|
pagination=CursorPagination(
|
||||||
|
next_cursor=None, items_per_page=10, has_more=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert isinstance(response.pagination, CursorPagination)
|
||||||
|
|
||||||
|
|
||||||
class TestFromAttributes:
|
class TestFromAttributes:
|
||||||
"""Tests for from_attributes config (ORM mode)."""
|
"""Tests for from_attributes config (ORM mode)."""
|
||||||
|
|||||||
148
uv.lock
generated
148
uv.lock
generated
@@ -235,7 +235,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.129.0"
|
version = "0.135.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -244,14 +244,14 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
|
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "1.0.0"
|
version = "2.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
@@ -413,30 +413,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "griffe"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "griffecli" },
|
|
||||||
{ name = "griffelib" },
|
|
||||||
]
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214, upload-time = "2026-02-09T19:09:44.105Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "griffecli"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "colorama" },
|
|
||||||
{ name = "griffelib" },
|
|
||||||
]
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345, upload-time = "2026-02-09T19:09:42.554Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "griffelib"
|
name = "griffelib"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -696,16 +672,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mkdocstrings-python"
|
name = "mkdocstrings-python"
|
||||||
version = "2.0.2"
|
version = "2.0.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "griffe" },
|
{ name = "griffelib" },
|
||||||
{ name = "mkdocs-autorefs" },
|
{ name = "mkdocs-autorefs" },
|
||||||
{ name = "mkdocstrings" },
|
{ name = "mkdocstrings" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/25/84/78243847ad9d5c21d30a2842720425b17e880d99dfe824dee11d6b2149b4/mkdocstrings_python-2.0.2.tar.gz", hash = "sha256:4a32ccfc4b8d29639864698e81cfeb04137bce76bb9f3c251040f55d4b6e1ad8", size = 199124, upload-time = "2026-02-09T15:12:01.543Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/31/7ee938abbde2322e553a2cb5f604cdd1e4728e08bba39c7ee6fae9af840b/mkdocstrings_python-2.0.2-py3-none-any.whl", hash = "sha256:31241c0f43d85a69306d704d5725786015510ea3f3c4bdfdb5a5731d83cdc2b0", size = 104900, upload-time = "2026-02-09T15:12:00.166Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1037,27 +1013,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.1"
|
version = "0.15.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
|
{ url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
|
{ url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1201,31 +1177,31 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "ty"
|
||||||
version = "0.0.17"
|
version = "0.0.20"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c3/41ae6346443eedb65b96761abfab890a48ce2aa5a8a27af69c5c5d99064d/ty-0.0.17.tar.gz", hash = "sha256:847ed6c120913e280bf9b54d8eaa7a1049708acb8824ad234e71498e8ad09f97", size = 5167209, upload-time = "2026-02-13T13:26:36.835Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/56/95/8de69bb98417227b01f1b1d743c819d6456c9fd140255b6124b05b17dfd6/ty-0.0.20.tar.gz", hash = "sha256:ebba6be7974c14efbb2a9adda6ac59848f880d7259f089dfa72a093039f1dcc6", size = 5262529, upload-time = "2026-03-02T15:51:36.587Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/01/0ef15c22a1c54b0f728ceff3f62d478dbf8b0dcf8ff7b80b954f79584f3e/ty-0.0.17-py3-none-linux_armv6l.whl", hash = "sha256:64a9a16555cc8867d35c2647c2f1afbd3cae55f68fd95283a574d1bb04fe93e0", size = 10192793, upload-time = "2026-02-13T13:27:13.943Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/2c/718abe48393e521bf852cd6b0f984766869b09c258d6e38a118768a91731/ty-0.0.20-py3-none-linux_armv6l.whl", hash = "sha256:7cc12769c169c9709a829c2248ee2826b7aae82e92caeac813d856f07c021eae", size = 10333656, upload-time = "2026-03-02T15:51:56.461Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/2c/f4c322d9cded56edc016b1092c14b95cf58c8a33b4787316ea752bb9418e/ty-0.0.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eb2dbd8acd5c5a55f4af0d479523e7c7265a88542efe73ed3d696eb1ba7b6454", size = 10051977, upload-time = "2026-02-13T13:26:57.741Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/0e/eb1c4cc4a12862e2327b72657bcebb10b7d9f17046f1bdcd6457a0211615/ty-0.0.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b777c1bf13bc0a95985ebb8a324b8668a4a9b2e514dde5ccf09e4d55d2ff232", size = 10168505, upload-time = "2026-03-02T15:51:51.895Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/a5/43746c1ff81e784f5fc303afc61fe5bcd85d0fcf3ef65cb2cef78c7486c7/ty-0.0.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f18f5fd927bc628deb9ea2df40f06b5f79c5ccf355db732025a3e8e7152801f6", size = 9564639, upload-time = "2026-02-13T13:26:42.781Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/7f/10230798e673f0dd3094dfd16e43bfd90e9494e7af6e8e7db516fb431ddf/ty-0.0.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b2a4a7db48bf8cba30365001bc2cad7fd13c1a5aacdd704cc4b7925de8ca5eb3", size = 9678510, upload-time = "2026-03-02T15:51:48.451Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/b8/280b04e14a9c0474af574f929fba2398b5e1c123c1e7735893b4cd73d13c/ty-0.0.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5383814d1d7a5cc53b3b07661856bab04bb2aac7a677c8d33c55169acdaa83df", size = 10061204, upload-time = "2026-02-13T13:27:00.152Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/3d/59d9159577494edd1728f7db77b51bb07884bd21384f517963114e3ab5f6/ty-0.0.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6846427b8b353a43483e9c19936dc6a25612573b44c8f7d983dfa317e7f00d4c", size = 10162926, upload-time = "2026-03-02T15:51:40.558Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/d7/493e1607d8dfe48288d8a768a2adc38ee27ef50e57f0af41ff273987cda0/ty-0.0.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c20423b8744b484f93e7bf2ef8a9724bca2657873593f9f41d08bd9f83444c9", size = 10013116, upload-time = "2026-02-13T13:26:34.543Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/a8/b7273eec3e802f78eb913fbe0ce0c16ef263723173e06a5776a8359b2c66/ty-0.0.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:245ceef5bd88df366869385cf96411cb14696334f8daa75597cf7e41c3012eb8", size = 10171702, upload-time = "2026-03-02T15:51:44.069Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/ef/22f3ed401520afac90dbdf1f9b8b7755d85b0d5c35c1cb35cf5bd11b59c2/ty-0.0.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6f5b1aba97db9af86517b911674b02f5bc310750485dc47603a105bd0e83ddd", size = 10533623, upload-time = "2026-02-13T13:26:31.449Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/32/5f1144f2f04a275109db06e3498450c4721554215b80ae73652ef412eeab/ty-0.0.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4d21d1cdf67a444d3c37583c17291ddba9382a9871021f3f5d5735e09e85efe", size = 10682552, upload-time = "2026-03-02T15:51:33.102Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/ce/744b15279a11ac7138832e3a55595706b4a8a209c9f878e3ab8e571d9032/ty-0.0.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:488bce1a9bea80b851a97cd34c4d2ffcd69593d6c3f54a72ae02e5c6e47f3d0c", size = 11069750, upload-time = "2026-02-13T13:26:48.638Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/db/9f1f637310792f12bd6ed37d5fc8ab39ba1a9b0c6c55a33865e9f1cad840/ty-0.0.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd4ffd907d1bd70e46af9e9a2f88622f215e1bf44658ea43b32c2c0b357299e4", size = 11242605, upload-time = "2026-03-02T15:51:34.895Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/be/1133c91f15a0e00d466c24f80df486d630d95d1b2af63296941f7473812f/ty-0.0.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df66b91ec84239420985ec215e7f7549bfda2ac036a3b3c065f119d1c06825a", size = 10870862, upload-time = "2026-02-13T13:26:54.715Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/68/cc9cae2e732fcfd20ccdffc508407905a023fc8493b8771c392d915528dc/ty-0.0.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6594b58d8b0e9d16a22b3045fc1305db4b132c8d70c17784ab8c7a7cc986807", size = 10974655, upload-time = "2026-03-02T15:51:46.011Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/4a/a2ed209ef215b62b2d3246e07e833081e07d913adf7e0448fc204be443d6/ty-0.0.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:002139e807c53002790dfefe6e2f45ab0e04012e76db3d7c8286f96ec121af8f", size = 10628118, upload-time = "2026-02-13T13:26:45.439Z" },
|
{ url = "https://files.pythonhosted.org/packages/1c/c1/b9e3e3f28fe63486331e653f6aeb4184af8b1fe80542fcf74d2dda40a93d/ty-0.0.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3662f890518ce6cf4d7568f57d03906912d2afbf948a01089a28e325b1ef198c", size = 10761325, upload-time = "2026-03-02T15:51:26.818Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/0c/87476004cb5228e9719b98afffad82c3ef1f84334bde8527bcacba7b18cb/ty-0.0.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6c4e01f05ce82e5d489ab3900ca0899a56c4ccb52659453780c83e5b19e2b64c", size = 10038185, upload-time = "2026-02-13T13:27:02.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/9e/67db935bdedf219a00fb69ec5437ba24dab66e0f2e706dd54a4eca234b84/ty-0.0.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e3ffbae58f9f0d17cdc4ac6d175ceae560b7ed7d54f9ddfb1c9f31054bcdc2c", size = 10145793, upload-time = "2026-03-02T15:51:38.562Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/4b/98f0b3ba9aef53c1f0305519536967a4aa793a69ed72677b0a625c5313ac/ty-0.0.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2b226dd1e99c0d2152d218c7e440150d1a47ce3c431871f0efa073bbf899e881", size = 10047644, upload-time = "2026-02-13T13:27:05.474Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/de/b0eb815d4dc5a819c7e4faddc2a79058611169f7eef07ccc006531ce228c/ty-0.0.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:176e52bc8bb00b0e84efd34583962878a447a3a0e34ecc45fd7097a37554261b", size = 10189640, upload-time = "2026-03-02T15:51:50.202Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/e0/06737bb80aa1a9103b8651d2eb691a7e53f1ed54111152be25f4a02745db/ty-0.0.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8b11f1da7859e0ad69e84b3c5ef9a7b055ceed376a432fad44231bdfc48061c2", size = 10231140, upload-time = "2026-02-13T13:27:10.844Z" },
|
{ url = "https://files.pythonhosted.org/packages/b8/71/63734923965cbb70df1da3e93e4b8875434e326b89e9f850611122f279bf/ty-0.0.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b2bc73025418e976ca4143dde71fb9025a90754a08ac03e6aa9b80d4bed1294b", size = 10370568, upload-time = "2026-03-02T15:51:42.295Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/79/e2a606bd8852383ba9abfdd578f4a227bd18504145381a10a5f886b4e751/ty-0.0.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c04e196809ff570559054d3e011425fd7c04161529eb551b3625654e5f2434cb", size = 10718344, upload-time = "2026-02-13T13:26:51.66Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/a0/a532c2048533347dff48e9ca98bd86d2c224356e101688a8edaf8d6973fb/ty-0.0.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52f7c9ec6e363e094b3c389c344d5a140401f14a77f0625e3f28c21918552f5", size = 10853999, upload-time = "2026-03-02T15:51:58.963Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/2d/2663984ac11de6d78f74432b8b14ba64d170b45194312852b7543cf7fd56/ty-0.0.17-py3-none-win32.whl", hash = "sha256:305b6ed150b2740d00a817b193373d21f0767e10f94ac47abfc3b2e5a5aec809", size = 9672932, upload-time = "2026-02-13T13:27:08.522Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/88/36c652c658fe96658043e4abc8ea97801de6fb6e63ab50aaa82807bff1d8/ty-0.0.20-py3-none-win32.whl", hash = "sha256:c7d32bfe93f8fcaa52b6eef3f1b930fd7da410c2c94e96f7412c30cfbabf1d17", size = 9744206, upload-time = "2026-03-02T15:51:54.183Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/b5/39be78f30b31ee9f5a585969930c7248354db90494ff5e3d0756560fb731/ty-0.0.17-py3-none-win_amd64.whl", hash = "sha256:531828267527aee7a63e972f54e5eee21d9281b72baf18e5c2850c6b862add83", size = 10542138, upload-time = "2026-02-13T13:27:17.084Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/a7/a4a13bed1d7fd9d97aaa3c5bb5e6d3e9a689e6984806cbca2ab4c9233cac/ty-0.0.20-py3-none-win_amd64.whl", hash = "sha256:a5e10f40fc4a0a1cbcb740a4aad5c7ce35d79f030836ea3183b7a28f43170248", size = 10711999, upload-time = "2026-03-02T15:51:29.212Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/b7/f875c729c5d0079640c75bad2c7e5d43edc90f16ba242f28a11966df8f65/ty-0.0.17-py3-none-win_arm64.whl", hash = "sha256:de9810234c0c8d75073457e10a84825b9cd72e6629826b7f01c7a0b266ae25b1", size = 10023068, upload-time = "2026-02-13T13:26:39.637Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/7e/6bfd748a9f4ff9267ed3329b86a0f02cdf6ab49f87bc36c8a164852f99fc/ty-0.0.20-py3-none-win_arm64.whl", hash = "sha256:53f7a5c12c960e71f160b734f328eff9a35d578af4b67a36b0bb5990ac5cdc27", size = 10150143, upload-time = "2026-03-02T15:51:31.283Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typer"
|
name = "typer"
|
||||||
version = "0.24.0"
|
version = "0.24.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -1233,9 +1209,9 @@ dependencies = [
|
|||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "shellingham" },
|
{ name = "shellingham" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/b6/3e681d3b6bb22647509bdbfdd18055d5adc0dce5c5585359fa46ff805fdc/typer-0.24.0.tar.gz", hash = "sha256:f9373dc4eff901350694f519f783c29b6d7a110fc0dcc11b1d7e353b85ca6504", size = 118380, upload-time = "2026-02-16T22:08:48.496Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/d0/4da85c2a45054bb661993c93524138ace4956cb075a7ae0c9d1deadc331b/typer-0.24.0-py3-none-any.whl", hash = "sha256:5fc435a9c8356f6160ed6e85a6301fdd6e3d8b2851da502050d1f92c5e9eddc8", size = 56441, upload-time = "2026-02-16T22:08:47.535Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1288,7 +1264,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.23"
|
version = "0.0.24"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@@ -1298,18 +1274,18 @@ dependencies = [
|
|||||||
{ name = "pymdown-extensions" },
|
{ name = "pymdown-extensions" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/ab/a65452b4e769552fd5a78c4996d6cf322630d896ddfd55c5433d96485e8b/zensical-0.0.23.tar.gz", hash = "sha256:5c4fc3aaf075df99d8cf41b9f2566e4d588180d9a89493014d3607dfe50ac4bc", size = 3822451, upload-time = "2026-02-11T21:24:38.373Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/3b/96/9c6cbdd7b351d1023cdbbcf7872d4cb118b0334cfe5821b99e0dd18e3f00/zensical-0.0.24.tar.gz", hash = "sha256:b5d99e225329bf4f98c8022bdf0a0ee9588c2fada7b4df1b7b896fcc62b37ec3", size = 3840688, upload-time = "2026-02-26T09:43:44.557Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/86/035aa02bd36d26a03a1885bc22a73d4fe61ba0e21d0033cc42baf13d24f6/zensical-0.0.23-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35d6d3eb803fe73a67187a1a25443408bd02a8dd50e151f4a4bafd40de3f0928", size = 12242966, upload-time = "2026-02-11T21:24:05.894Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/aa/b8201af30e376a67566f044a1c56210edac5ae923fd986a836d2cf593c9c/zensical-0.0.24-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d390c5453a5541ca35d4f9e1796df942b6612c546e3153dd928236d3b758409a", size = 12263407, upload-time = "2026-02-26T09:43:14.716Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/68/335dfbb7efc972964f0610736a0ad243dd8a5dcc2ec76b9ddb84c847a4a4/zensical-0.0.23-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:5973267460a190f348f24d445ff0c01e8ed334fd075947687b305e68257f6b18", size = 12125173, upload-time = "2026-02-11T21:24:08.507Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/8e/3d910214471ade604fd39b080db3696864acc23678b5b4b8475c7dbfd2ce/zensical-0.0.24-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:81ac072869cf4d280853765b2bfb688653da0dfb9408f3ab15aca96455ab8223", size = 12142610, upload-time = "2026-02-26T09:43:17.546Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/9c/d567da04fbeb077df5cf06a94f947af829ebef0ff5ca7d0ba4910a6cbdf6/zensical-0.0.23-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:953adf1f0b346a6c65fc6e05e6cc1c38a6440fec29c50c76fb29700cc1927006", size = 12489636, upload-time = "2026-02-11T21:24:10.91Z" },
|
{ url = "https://files.pythonhosted.org/packages/cf/d7/eb0983640aa0419ddf670298cfbcf8b75629b6484925429b857851e00784/zensical-0.0.24-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5eb1dfa84cae8e960bfa2c6851d2bc8e9710c4c4c683bd3aaf23185f646ae46", size = 12508380, upload-time = "2026-02-26T09:43:20.114Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/6e/481a3ecf8a7b63a35c67f5be1ea548185d55bb1dacead54f76a9550197b2/zensical-0.0.23-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49c1cbd6131dafa056be828e081759184f9b8dd24b99bf38d1e77c8c31b0c720", size = 12421313, upload-time = "2026-02-11T21:24:13.9Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/04/4405b9e6f937a75db19f0d875798a7eb70817d6a3bec2a2d289a2d5e8aea/zensical-0.0.24-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57d7c9e589da99c1879a1c703e67c85eaa6be4661cdc6ce6534f7bb3575983f4", size = 12440807, upload-time = "2026-02-26T09:43:22.679Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/aa/a95481547f708432636f5f8155917c90d877c244c62124a084f7448b60b2/zensical-0.0.23-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b7fe22c5d33b2b91899c5df7631ad4ce9cccfabac2560cc92ba73eafe2d297", size = 12761031, upload-time = "2026-02-11T21:24:17.016Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/dc/a7ca2a4224b3072a2c2998b6611ad7fd4f8f131ceae7aa23238d97d26e22/zensical-0.0.24-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42fcc121c3095734b078a95a0dae4d4924fb8fbf16bf730456146ad6cab48ad0", size = 12782727, upload-time = "2026-02-26T09:43:25.347Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/9f/ce1c5af9afd11fe3521a90441aba48c484f98730c6d833d69ee4387ae2e9/zensical-0.0.23-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a3679d6bf6374f503afb74d9f6061da5de83c25922f618042b63a30b16f0389", size = 12527415, upload-time = "2026-02-11T21:24:19.558Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/37/22f1727da356ed3fcbd31f68d4a477f15c232997c87e270cfffb927459ac/zensical-0.0.24-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4a2a051b9f49561031a2986ace502326f82d9a401ddf125530d30025fdd4", size = 12547616, upload-time = "2026-02-26T09:43:28.031Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/b8/13a5d4d99f3b77e7bf4e791ef991a611ca2f108ed7eddf20858544ab0a91/zensical-0.0.23-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54d981e21a19c3dcec6e7fa77c4421db47389dfdff20d29fea70df8e1be4062e", size = 12665352, upload-time = "2026-02-11T21:24:22.703Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/ff/c75ff111b8e12157901d00752beef9d691dbb5a034b6a77359972262416a/zensical-0.0.24-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e5fea3bb61238dba9f930f52669db67b0c26be98e1c8386a05eb2b1e3cb875dc", size = 12684883, upload-time = "2026-02-26T09:43:30.642Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/84/3d0a187ed941826ca26b19a661c41685d8017b2a019afa0d353eb2ebbdba/zensical-0.0.23-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:afde7865cc3c79c99f6df4a911d638fb2c3b472a1b81367d47163f8e3c36f910", size = 12689042, upload-time = "2026-02-11T21:24:26.118Z" },
|
{ url = "https://files.pythonhosted.org/packages/b9/92/4f6ea066382e3d068d3cadbed99e9a71af25e46c84a403e0f747960472a2/zensical-0.0.24-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:75eef0428eec2958590633fdc82dc2a58af124879e29573aa7e153b662978073", size = 12713825, upload-time = "2026-02-26T09:43:33.273Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/65/12466408f428f2cf7140b32d484753db0891debae3c956f4c076b51eeb17/zensical-0.0.23-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c484674d7b0a3e6d39db83914db932249bccdef2efaf8a5669671c66c16f584d", size = 12834779, upload-time = "2026-02-11T21:24:28.788Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/fb/bf735b19bce0034b1f3b8e1c50b2896ebbd0c5d92d462777e759e78bb083/zensical-0.0.24-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3c6b39659156394ff805b4831dac108c839483d9efa4c9b901eaa913efee1ac7", size = 12854318, upload-time = "2026-02-26T09:43:35.632Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/ab/0771ac6ffb30e4f04c20374e3beca9e71c3f81112219cdbd86cdc0e3d337/zensical-0.0.23-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:927d12fe2851f355fb3206809e04641d6651bdd2ff4afe9c205721aa3a32aa82", size = 12797057, upload-time = "2026-02-11T21:24:31.383Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/28/0ddab6c1237e3625e7763ff666806f31e5760bb36d18624135a6bb6e8643/zensical-0.0.24-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9eef82865a18b3ca4c3cd13e245dff09a865d1da3c861e2fc86eaa9253a90f02", size = 12818270, upload-time = "2026-02-26T09:43:37.749Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/ce/fbd45c00a1cba15508ea3c29b121b4be010254eb65c1512bf11f4478496c/zensical-0.0.23-cp310-abi3-win32.whl", hash = "sha256:ffb79db4244324e9cc063d16adff25a40b145153e5e76d75e0012ba3c05af25d", size = 11837823, upload-time = "2026-02-11T21:24:33.869Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/93/d2cef3705d4434896feadffb5b3e44744ef9f1204bc41202c1b84a4eeef6/zensical-0.0.24-cp310-abi3-win32.whl", hash = "sha256:f4d0ff47d505c786a26c9332317aa3e9ad58d1382f55212a10dc5bafcca97864", size = 11857695, upload-time = "2026-02-26T09:43:39.906Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/82/0aebaa8e7d2e6314a85d9b7ff3f7fc74837a94086b56a9d5d8f2240e9b9c/zensical-0.0.23-cp310-abi3-win_amd64.whl", hash = "sha256:a8cfe240dca75231e8e525985366d010d09ee73aec0937930e88f7230694ce01", size = 12036837, upload-time = "2026-02-11T21:24:36.163Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/26/9707587c0f6044dd1e1cc5bc3b9fa5fed81ce6c7bcdb09c21a9795e802d9/zensical-0.0.24-cp310-abi3-win_amd64.whl", hash = "sha256:e00a62cf04526dbed665e989b8f448eb976247f077a76dfdd84699ace4aa3ac3", size = 12057762, upload-time = "2026-02-26T09:43:42.627Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
351
zensical.toml
351
zensical.toml
@@ -1,265 +1,35 @@
|
|||||||
# ============================================================================
|
|
||||||
#
|
|
||||||
# The configuration produced by default is meant to highlight the features
|
|
||||||
# that Zensical provides and to serve as a starting point for your own
|
|
||||||
# projects.
|
|
||||||
#
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
|
|
||||||
# The site_name is shown in the page header and the browser window title
|
|
||||||
#
|
|
||||||
# Read more: https://zensical.org/docs/setup/basics/#site_name
|
|
||||||
site_name = "FastAPI Toolsets"
|
site_name = "FastAPI Toolsets"
|
||||||
|
|
||||||
# The site_description is included in the HTML head and should contain a
|
|
||||||
# meaningful description of the site content for use by search engines.
|
|
||||||
#
|
|
||||||
# Read more: https://zensical.org/docs/setup/basics/#site_description
|
|
||||||
site_description = "Production-ready utilities for FastAPI applications."
|
site_description = "Production-ready utilities for FastAPI applications."
|
||||||
|
|
||||||
# The site_author attribute. This is used in the HTML head element.
|
|
||||||
#
|
|
||||||
# Read more: https://zensical.org/docs/setup/basics/#site_author
|
|
||||||
site_author = "d3vyce"
|
site_author = "d3vyce"
|
||||||
|
|
||||||
# The site_url is the canonical URL for your site. When building online
|
|
||||||
# documentation you should set this.
|
|
||||||
# Read more: https://zensical.org/docs/setup/basics/#site_url
|
|
||||||
site_url = "https://fastapi-toolsets.d3vyce.fr"
|
site_url = "https://fastapi-toolsets.d3vyce.fr"
|
||||||
|
copyright = "Copyright © 2026 d3vyce"
|
||||||
# The copyright notice appears in the page footer and can contain an HTML
|
|
||||||
# fragment.
|
|
||||||
#
|
|
||||||
# Read more: https://zensical.org/docs/setup/basics/#copyright
|
|
||||||
copyright = """
|
|
||||||
Copyright © 2026 d3vyce
|
|
||||||
"""
|
|
||||||
|
|
||||||
repo_url = "https://github.com/d3vyce/fastapi-toolsets"
|
repo_url = "https://github.com/d3vyce/fastapi-toolsets"
|
||||||
|
|
||||||
# Zensical supports both implicit navigation and explicitly defined navigation.
|
|
||||||
# If you decide not to define a navigation here then Zensical will simply
|
|
||||||
# derive the navigation structure from the directory structure of your
|
|
||||||
# "docs_dir". The definition below demonstrates how a navigation structure
|
|
||||||
# can be defined using TOML syntax.
|
|
||||||
#
|
|
||||||
# Read more: https://zensical.org/docs/setup/navigation/
|
|
||||||
# nav = [
|
|
||||||
# { "Get started" = "index.md" },
|
|
||||||
# { "Markdown in 5min" = "markdown.md" },
|
|
||||||
# ]
|
|
||||||
|
|
||||||
# With the "extra_css" option you can add your own CSS styling to customize
|
|
||||||
# your Zensical project according to your needs. You can add any number of
|
|
||||||
# CSS files.
|
|
||||||
#
|
|
||||||
# The path provided should be relative to the "docs_dir".
|
|
||||||
#
|
|
||||||
# Read more: https://zensical.org/docs/customization/#additional-css
|
|
||||||
#
|
|
||||||
#extra_css = ["stylesheets/extra.css"]
|
|
||||||
|
|
||||||
# With the `extra_javascript` option you can add your own JavaScript to your
|
|
||||||
# project to customize the behavior according to your needs.
|
|
||||||
#
|
|
||||||
# The path provided should be relative to the "docs_dir".
|
|
||||||
#
|
|
||||||
# Read more: https://zensical.org/docs/customization/#additional-javascript
|
|
||||||
#extra_javascript = ["javascripts/extra.js"]
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
# Section for configuring theme options
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
[project.theme]
|
[project.theme]
|
||||||
|
|
||||||
# change this to "classic" to use the traditional Material for MkDocs look.
|
|
||||||
#variant = "classic"
|
|
||||||
|
|
||||||
# Zensical allows you to override specific blocks, partials, or whole
|
|
||||||
# templates as well as to define your own templates. To do this, uncomment
|
|
||||||
# the custom_dir setting below and set it to a directory in which you
|
|
||||||
# keep your template overrides.
|
|
||||||
#
|
|
||||||
# Read more:
|
|
||||||
# - https://zensical.org/docs/customization/#extending-the-theme
|
|
||||||
#
|
|
||||||
custom_dir = "docs/overrides"
|
custom_dir = "docs/overrides"
|
||||||
|
|
||||||
# With the "favicon" option you can set your own image to use as the icon
|
|
||||||
# browsers will use in the browser title bar or tab bar. The path provided
|
|
||||||
# must be relative to the "docs_dir".
|
|
||||||
#
|
|
||||||
# Read more:
|
|
||||||
# - https://zensical.org/docs/setup/logo-and-icons/#favicon
|
|
||||||
# - https://developer.mozilla.org/en-US/docs/Glossary/Favicon
|
|
||||||
#
|
|
||||||
#favicon = "images/favicon.png"
|
|
||||||
|
|
||||||
# Zensical supports more than 60 different languages. This means that the
|
|
||||||
# labels and tooltips that Zensical's templates produce are translated.
|
|
||||||
# The "language" option allows you to set the language used. This language
|
|
||||||
# is also indicated in the HTML head element to help with accessibility
|
|
||||||
# and guide search engines and translation tools.
|
|
||||||
#
|
|
||||||
# The default language is "en" (English). It is possible to create
|
|
||||||
# sites with multiple languages and configure a language selector. See
|
|
||||||
# the documentation for details.
|
|
||||||
#
|
|
||||||
# Read more:
|
|
||||||
# - https://zensical.org/docs/setup/language/
|
|
||||||
#
|
|
||||||
language = "en"
|
language = "en"
|
||||||
|
|
||||||
# Zensical provides a number of feature toggles that change the behavior
|
|
||||||
# of the documentation site.
|
|
||||||
features = [
|
features = [
|
||||||
# Zensical includes an announcement bar. This feature allows users to
|
|
||||||
# dismiss it when they have read the announcement.
|
|
||||||
# https://zensical.org/docs/setup/header/#announcement-bar
|
|
||||||
"announce.dismiss",
|
"announce.dismiss",
|
||||||
|
|
||||||
# If you have a repository configured and turn on this feature, Zensical
|
|
||||||
# will generate an edit button for the page. This works for common
|
|
||||||
# repository hosting services.
|
|
||||||
# https://zensical.org/docs/setup/repository/#content-actions
|
|
||||||
#"content.action.edit",
|
|
||||||
|
|
||||||
# If you have a repository configured and turn on this feature, Zensical
|
|
||||||
# will generate a button that allows the user to view the Markdown
|
|
||||||
# code for the current page.
|
|
||||||
# https://zensical.org/docs/setup/repository/#content-actions
|
|
||||||
"content.action.view",
|
"content.action.view",
|
||||||
|
|
||||||
# Code annotations allow you to add an icon with a tooltip to your
|
|
||||||
# code blocks to provide explanations at crucial points.
|
|
||||||
# https://zensical.org/docs/authoring/code-blocks/#code-annotations
|
|
||||||
"content.code.annotate",
|
"content.code.annotate",
|
||||||
|
|
||||||
# This feature turns on a button in code blocks that allow users to
|
|
||||||
# copy the content to their clipboard without first selecting it.
|
|
||||||
# https://zensical.org/docs/authoring/code-blocks/#code-copy-button
|
|
||||||
"content.code.copy",
|
"content.code.copy",
|
||||||
|
|
||||||
# Code blocks can include a button to allow for the selection of line
|
|
||||||
# ranges by the user.
|
|
||||||
# https://zensical.org/docs/authoring/code-blocks/#code-selection-button
|
|
||||||
"content.code.select",
|
"content.code.select",
|
||||||
|
|
||||||
# Zensical can render footnotes as inline tooltips, so the user can read
|
|
||||||
# the footnote without leaving the context of the document.
|
|
||||||
# https://zensical.org/docs/authoring/footnotes/#footnote-tooltips
|
|
||||||
"content.footnote.tooltips",
|
"content.footnote.tooltips",
|
||||||
|
|
||||||
# If you have many content tabs that have the same titles (e.g., "Python",
|
|
||||||
# "JavaScript", "Cobol"), this feature causes all of them to switch to
|
|
||||||
# at the same time when the user chooses their language in one.
|
|
||||||
# https://zensical.org/docs/authoring/content-tabs/#linked-content-tabs
|
|
||||||
"content.tabs.link",
|
"content.tabs.link",
|
||||||
|
|
||||||
# With this feature enabled users can add tooltips to links that will be
|
|
||||||
# displayed when the mouse pointer hovers the link.
|
|
||||||
# https://zensical.org/docs/authoring/tooltips/#improved-tooltips
|
|
||||||
"content.tooltips",
|
"content.tooltips",
|
||||||
|
|
||||||
# With this feature enabled, Zensical will automatically hide parts
|
|
||||||
# of the header when the user scrolls past a certain point.
|
|
||||||
# https://zensical.org/docs/setup/header/#automatic-hiding
|
|
||||||
# "header.autohide",
|
|
||||||
|
|
||||||
# Turn on this feature to expand all collapsible sections in the
|
|
||||||
# navigation sidebar by default.
|
|
||||||
# https://zensical.org/docs/setup/navigation/#navigation-expansion
|
|
||||||
# "navigation.expand",
|
|
||||||
|
|
||||||
# This feature turns on navigation elements in the footer that allow the
|
|
||||||
# user to navigate to a next or previous page.
|
|
||||||
# https://zensical.org/docs/setup/footer/#navigation
|
|
||||||
"navigation.footer",
|
"navigation.footer",
|
||||||
|
|
||||||
# When section index pages are enabled, documents can be directly attached
|
|
||||||
# to sections, which is particularly useful for providing overview pages.
|
|
||||||
# https://zensical.org/docs/setup/navigation/#section-index-pages
|
|
||||||
"navigation.indexes",
|
"navigation.indexes",
|
||||||
|
|
||||||
# When instant navigation is enabled, clicks on all internal links will be
|
|
||||||
# intercepted and dispatched via XHR without fully reloading the page.
|
|
||||||
# https://zensical.org/docs/setup/navigation/#instant-navigation
|
|
||||||
"navigation.instant",
|
"navigation.instant",
|
||||||
|
|
||||||
# With instant prefetching, your site will start to fetch a page once the
|
|
||||||
# user hovers over a link. This will reduce the perceived loading time
|
|
||||||
# for the user.
|
|
||||||
# https://zensical.org/docs/setup/navigation/#instant-prefetching
|
|
||||||
"navigation.instant.prefetch",
|
"navigation.instant.prefetch",
|
||||||
|
|
||||||
# In order to provide a better user experience on slow connections when
|
|
||||||
# using instant navigation, a progress indicator can be enabled.
|
|
||||||
# https://zensical.org/docs/setup/navigation/#progress-indicator
|
|
||||||
#"navigation.instant.progress",
|
|
||||||
|
|
||||||
# When navigation paths are activated, a breadcrumb navigation is rendered
|
|
||||||
# above the title of each page
|
|
||||||
# https://zensical.org/docs/setup/navigation/#navigation-path
|
|
||||||
"navigation.path",
|
"navigation.path",
|
||||||
|
|
||||||
# When pruning is enabled, only the visible navigation items are included
|
|
||||||
# in the rendered HTML, reducing the size of the built site by 33% or more.
|
|
||||||
# https://zensical.org/docs/setup/navigation/#navigation-pruning
|
|
||||||
#"navigation.prune",
|
|
||||||
|
|
||||||
# When sections are enabled, top-level sections are rendered as groups in
|
|
||||||
# the sidebar for viewports above 1220px, but remain as-is on mobile.
|
|
||||||
# https://zensical.org/docs/setup/navigation/#navigation-sections
|
|
||||||
"navigation.sections",
|
"navigation.sections",
|
||||||
|
|
||||||
# When tabs are enabled, top-level sections are rendered in a menu layer
|
|
||||||
# below the header for viewports above 1220px, but remain as-is on mobile.
|
|
||||||
# https://zensical.org/docs/setup/navigation/#navigation-tabs
|
|
||||||
"navigation.tabs",
|
"navigation.tabs",
|
||||||
|
|
||||||
# When sticky tabs are enabled, navigation tabs will lock below the header
|
|
||||||
# and always remain visible when scrolling down.
|
|
||||||
# https://zensical.org/docs/setup/navigation/#sticky-navigation-tabs
|
|
||||||
#"navigation.tabs.sticky",
|
|
||||||
|
|
||||||
# A back-to-top button can be shown when the user, after scrolling down,
|
|
||||||
# starts to scroll up again.
|
|
||||||
# https://zensical.org/docs/setup/navigation/#back-to-top-button
|
|
||||||
"navigation.top",
|
"navigation.top",
|
||||||
|
|
||||||
# When anchor tracking is enabled, the URL in the address bar is
|
|
||||||
# automatically updated with the active anchor as highlighted in the table
|
|
||||||
# of contents.
|
|
||||||
# https://zensical.org/docs/setup/navigation/#anchor-tracking
|
|
||||||
"navigation.tracking",
|
"navigation.tracking",
|
||||||
|
|
||||||
# When search highlighting is enabled and a user clicks on a search result,
|
|
||||||
# Zensical will highlight all occurrences after following the link.
|
|
||||||
# https://zensical.org/docs/setup/search/#search-highlighting
|
|
||||||
"search.highlight",
|
"search.highlight",
|
||||||
|
|
||||||
# When anchor following for the table of contents is enabled, the sidebar
|
|
||||||
# is automatically scrolled so that the active anchor is always visible.
|
|
||||||
# https://zensical.org/docs/setup/navigation/#anchor-following
|
|
||||||
# "toc.follow",
|
|
||||||
|
|
||||||
# When navigation integration for the table of contents is enabled, it is
|
|
||||||
# always rendered as part of the navigation sidebar on the left.
|
|
||||||
# https://zensical.org/docs/setup/navigation/#navigation-integration
|
|
||||||
#"toc.integrate",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
# In the "palette" subsection you can configure options for the color scheme.
|
|
||||||
# You can configure different color # schemes, e.g., to turn on dark mode,
|
|
||||||
# that the user can switch between. Each color scheme can be further
|
|
||||||
# customized.
|
|
||||||
#
|
|
||||||
# Read more:
|
|
||||||
# - https://zensical.org/docs/setup/colors/
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
[[project.theme.palette]]
|
[[project.theme.palette]]
|
||||||
scheme = "default"
|
scheme = "default"
|
||||||
toggle.icon = "lucide/sun"
|
toggle.icon = "lucide/sun"
|
||||||
@@ -270,43 +40,13 @@ scheme = "slate"
|
|||||||
toggle.icon = "lucide/moon"
|
toggle.icon = "lucide/moon"
|
||||||
toggle.name = "Switch to light mode"
|
toggle.name = "Switch to light mode"
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
# In the "font" subsection you can configure the fonts used. By default, fonts
|
|
||||||
# are loaded from Google Fonts, giving you a wide range of choices from a set
|
|
||||||
# of suitably licensed fonts. There are options for a normal text font and for
|
|
||||||
# a monospaced font used in code blocks.
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
[project.theme.font]
|
[project.theme.font]
|
||||||
text = "Inter"
|
text = "Inter"
|
||||||
code = "Jetbrains Mono"
|
code = "Jetbrains Mono"
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
# You can configure your own logo to be shown in the header using the "logo"
|
|
||||||
# option in the "icons" subsection. The logo can be a path to a file in your
|
|
||||||
# "docs_dir" or it can be a path to an icon.
|
|
||||||
#
|
|
||||||
# Likewise, you can customize the logo used for the repository section of the
|
|
||||||
# header. Zensical derives the default logo for this from the repository URL.
|
|
||||||
# See below...
|
|
||||||
#
|
|
||||||
# There are other icons you can customize. See the documentation for details.
|
|
||||||
#
|
|
||||||
# Read more:
|
|
||||||
# - https://zensical.org/docs/setup/logo-and-icons
|
|
||||||
# - https://zensical.org/docs/authoring/icons-emojis/#search
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
[project.theme.icon]
|
[project.theme.icon]
|
||||||
#logo = "lucide/smile"
|
|
||||||
repo = "fontawesome/brands/github"
|
repo = "fontawesome/brands/github"
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
# The "extra" section contains miscellaneous settings.
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
#[[project.extra.social]]
|
|
||||||
#icon = "fontawesome/brands/github"
|
|
||||||
#link = "https://github.com/user/repo"
|
|
||||||
|
|
||||||
|
|
||||||
[project.plugins.mkdocstrings.handlers.python]
|
[project.plugins.mkdocstrings.handlers.python]
|
||||||
inventories = ["https://docs.python.org/3/objects.inv"]
|
inventories = ["https://docs.python.org/3/objects.inv"]
|
||||||
paths = ["src"]
|
paths = ["src"]
|
||||||
@@ -316,3 +56,92 @@ docstring_style = "google"
|
|||||||
inherited_members = true
|
inherited_members = true
|
||||||
show_source = false
|
show_source = false
|
||||||
show_root_heading = true
|
show_root_heading = true
|
||||||
|
|
||||||
|
[project.markdown_extensions]
|
||||||
|
abbr = {}
|
||||||
|
admonition = {}
|
||||||
|
attr_list = {}
|
||||||
|
def_list = {}
|
||||||
|
footnotes = {}
|
||||||
|
md_in_html = {}
|
||||||
|
"pymdownx.arithmatex" = {generic = true}
|
||||||
|
"pymdownx.betterem" = {}
|
||||||
|
"pymdownx.caret" = {}
|
||||||
|
"pymdownx.details" = {}
|
||||||
|
"pymdownx.emoji" = {}
|
||||||
|
"pymdownx.inlinehilite" = {}
|
||||||
|
"pymdownx.keys" = {}
|
||||||
|
"pymdownx.magiclink" = {}
|
||||||
|
"pymdownx.mark" = {}
|
||||||
|
"pymdownx.smartsymbols" = {}
|
||||||
|
"pymdownx.tasklist" = {custom_checkbox = true}
|
||||||
|
"pymdownx.tilde" = {}
|
||||||
|
|
||||||
|
[project.markdown_extensions.pymdownx.emoji]
|
||||||
|
emoji_index = "zensical.extensions.emoji.twemoji"
|
||||||
|
emoji_generator = "zensical.extensions.emoji.to_svg"
|
||||||
|
|
||||||
|
[project.markdown_extensions."pymdownx.highlight"]
|
||||||
|
anchor_linenums = true
|
||||||
|
line_spans = "__span"
|
||||||
|
pygments_lang_class = true
|
||||||
|
|
||||||
|
[project.markdown_extensions."pymdownx.superfences"]
|
||||||
|
custom_fences = [{name = "mermaid", class = "mermaid"}]
|
||||||
|
|
||||||
|
[project.markdown_extensions."pymdownx.tabbed"]
|
||||||
|
alternate_style = true
|
||||||
|
combine_header_slug = true
|
||||||
|
|
||||||
|
[project.markdown_extensions."toc"]
|
||||||
|
permalink = true
|
||||||
|
|
||||||
|
[project.markdown_extensions."pymdownx.snippets"]
|
||||||
|
base_path = ["."]
|
||||||
|
check_paths = true
|
||||||
|
|
||||||
|
[[project.nav]]
|
||||||
|
Home = "index.md"
|
||||||
|
|
||||||
|
[[project.nav]]
|
||||||
|
Modules = [
|
||||||
|
{CLI = "module/cli.md"},
|
||||||
|
{CRUD = "module/crud.md"},
|
||||||
|
{Database = "module/db.md"},
|
||||||
|
{Dependencies = "module/dependencies.md"},
|
||||||
|
{Exceptions = "module/exceptions.md"},
|
||||||
|
{Fixtures = "module/fixtures.md"},
|
||||||
|
{Logger = "module/logger.md"},
|
||||||
|
{Metrics = "module/metrics.md"},
|
||||||
|
{Models = "module/models.md"},
|
||||||
|
{Pytest = "module/pytest.md"},
|
||||||
|
{Schemas = "module/schemas.md"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[project.nav]]
|
||||||
|
Reference = [
|
||||||
|
{CLI = "reference/cli.md"},
|
||||||
|
{CRUD = "reference/crud.md"},
|
||||||
|
{Database = "reference/db.md"},
|
||||||
|
{Dependencies = "reference/dependencies.md"},
|
||||||
|
{Exceptions = "reference/exceptions.md"},
|
||||||
|
{Fixtures = "reference/fixtures.md"},
|
||||||
|
{Logger = "reference/logger.md"},
|
||||||
|
{Metrics = "reference/metrics.md"},
|
||||||
|
{Models = "reference/models.md"},
|
||||||
|
{Pytest = "reference/pytest.md"},
|
||||||
|
{Schemas = "reference/schemas.md"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[project.nav]]
|
||||||
|
Examples = [
|
||||||
|
{"Pagination & Search" = "examples/pagination-search.md"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[project.nav]]
|
||||||
|
Migration = [
|
||||||
|
{"v2.0" = "migration/v2.md"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[project.nav]]
|
||||||
|
"Changelog ↗" = "https://github.com/d3vyce/fastapi-toolsets/releases"
|
||||||
|
|||||||
Reference in New Issue
Block a user