29 Commits

Author SHA1 Message Date
535548877b docs: update README 2026-03-02 11:21:31 -05:00
b6d811cd77 docs: add migration + fix icon 2026-03-02 11:19:37 -05:00
e2df00bd75 docs: fix old Paginate references 2026-03-02 10:46:15 -05:00
f45e4f5f93 refactor: simplify and deduplicate across crud, metrics, cli, and
exceptions
2026-03-02 10:37:35 -05:00
858cca576e test: add missing tests for fixtures/utils.py 2026-03-02 10:34:49 -05:00
bcf0c3becb refactor: centralize type aliases in types.py and simplify crud layer 2026-03-02 10:34:49 -05:00
fddfc98acc refactor: remove deprecated parameter and function 2026-03-02 10:34:49 -05:00
d3vyce
05b5a2c876 feat: rework Exception/ApiError (#107)
* feat: rework Exception/ApiError

* docs: update exceptions module

* fix: docstring
2026-03-02 16:34:29 +01:00
d3vyce
4a020c56d1 feat: Raise NotFoundError instead of LookupError in wait_for_row_change (#105) 2026-03-02 14:28:37 +01:00
56d365d14b Version 1.3.0 2026-03-01 05:22:16 -05:00
d3vyce
a257d85d45 Add sort_params helper in CrudFactory (#103)
* feat: add sort_params helper in CrudFactory

* docs: add sorting

* fix: change sort_by to order_by
2026-03-01 11:20:43 +01:00
117675d02f Version 1.2.1 2026-02-27 13:57:03 -05:00
d3vyce
d7ad7308c5 Add examples in documentations (#99)
* docs: fix crud

* docs: update README features

* docs: add pagination/search example

* docs: update zensical.toml

* docs: cleanup

* docs: update status to Stable + update description

* docs: add example run commands
2026-02-27 19:56:09 +01:00
8d57bf9525 Version 1.2.0 2026-02-26 09:34:35 -05:00
d3vyce
5a08ec2f57 feat: add faceted search in CrudFactory (#97)
* feat: add faceted search in CrudFactory

* feat: add filter_params_schema in CrudFactory

* fix: add missing Raises in build_search_filters docstring

* fix: faceted search

* fix: cov

* fix: documentation/filter_params
2026-02-26 15:23:07 +01:00
dependabot[bot]
433dc55fcd ⬆ Bump typer from 0.24.0 to 0.24.1 (#92)
Bumps [typer](https://github.com/fastapi/typer) from 0.24.0 to 0.24.1.
- [Release notes](https://github.com/fastapi/typer/releases)
- [Changelog](https://github.com/fastapi/typer/blob/master/docs/release-notes.md)
- [Commits](https://github.com/fastapi/typer/compare/0.24.0...0.24.1)

---
updated-dependencies:
- dependency-name: typer
  dependency-version: 0.24.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 10:53:00 +01:00
dependabot[bot]
0b2abd8c43 ⬆ Bump ruff from 0.15.1 to 0.15.2 (#93)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.1 to 0.15.2.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.1...0.15.2)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 10:52:47 +01:00
dependabot[bot]
07c99be89b ⬆ Bump mkdocstrings-python from 2.0.2 to 2.0.3 (#94)
Bumps [mkdocstrings-python](https://github.com/mkdocstrings/python) from 2.0.2 to 2.0.3.
- [Release notes](https://github.com/mkdocstrings/python/releases)
- [Changelog](https://github.com/mkdocstrings/python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mkdocstrings/python/compare/2.0.2...2.0.3)

---
updated-dependencies:
- dependency-name: mkdocstrings-python
  dependency-version: 2.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 10:52:37 +01:00
dependabot[bot]
9b75cc7dfc ⬆ Bump ty from 0.0.17 to 0.0.18 (#95)
Bumps [ty](https://github.com/astral-sh/ty) from 0.0.17 to 0.0.18.
- [Release notes](https://github.com/astral-sh/ty/releases)
- [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ty/compare/0.0.17...0.0.18)

---
updated-dependencies:
- dependency-name: ty
  dependency-version: 0.0.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 10:52:23 +01:00
dependabot[bot]
6144b383eb ⬆ Bump fastapi from 0.129.0 to 0.133.1 (#96)
Bumps [fastapi](https://github.com/fastapi/fastapi) from 0.129.0 to 0.133.1.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.129.0...0.133.1)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.133.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 10:52:02 +01:00
7ec407834a Version 1.1.2 2026-02-23 14:59:33 -05:00
d3vyce
7da34f33a2 fix: handle Date, Float, Numeric cursor column types in cursor_paginate (#90) 2026-02-23 20:58:43 +01:00
8c8911fb27 Version 1.1.1 2026-02-23 11:04:10 -05:00
d3vyce
c0c3b38054 fix: NameError in cli/config.py (#88) 2026-02-23 14:40:36 +01:00
e17d385910 Version 1.1.0 2026-02-23 08:09:59 -05:00
d3vyce
6cf7df55ef feat: add cursor based pagination in CrudFactory (#86) 2026-02-23 13:51:34 +01:00
d3vyce
7482bc5dad feat: add schema parameter to CRUD methods for typed response serialization (#84) 2026-02-23 10:02:52 +01:00
d3vyce
9d07dfea85 feat: add opt-in default_load_options parameter in CrudFactory (#82)
* feat: add opt-in default_load_options parameter in CrudFactory

* docs: add Relationship loading in CRUD
2026-02-21 12:35:15 +01:00
d3vyce
31678935aa Version 1.0.0 (#80)
* docs: fix typos

* chore: build docs only when release

* Version 1.0.0
2026-02-20 14:09:01 +01:00
52 changed files with 5062 additions and 1000 deletions

View File

@@ -1,12 +1,14 @@
name: Documentation name: Documentation
on: on:
push: release:
branches: types: [published]
- main
permissions: permissions:
contents: read contents: read
pages: write pages: write
id-token: write id-token: write
jobs: jobs:
deploy: deploy:
environment: environment:

View File

@@ -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,7 +44,7 @@ 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

View 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],
)
```

View File

@@ -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,7 +44,7 @@ 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

137
docs/migration/v2.md Normal file
View 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, page=1)
result = await UserCrud.cursor_paginate(session, cursor=token)
```
=== "Now (`v2`)"
```python
result = await UserCrud.offset_paginate(session, page=1, schema=UserRead)
result = await UserCrud.cursor_paginate(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, data, as_response=True)
user = await UserCrud.get(session, filters, as_response=True)
user = await UserCrud.update(session, data, filters, as_response=True)
```
=== "Now (`v2`)"
```python
user = await UserCrud.create(session, data, schema=UserRead)
user = await UserCrud.get(session, filters, schema=UserRead)
user = await UserCrud.update(session, 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, filters)
if ok: # True on success
...
response = await UserCrud.delete(session, filters, as_response=True)
```
=== "Now (`v2`)"
```python
await UserCrud.delete(session, filters) # returns None
response = await UserCrud.delete(session, 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, page=2, items_per_page=20, schema=UserRead)
```
=== "Now (`v2`)"
```python
result = await UserCrud.offset_paginate(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).

View File

@@ -1,6 +1,6 @@
# CLI # CLI
Typer-based command-line interface for managing your FastAPI application, with built-in fixture loading. Typer-based command-line interface for managing your FastAPI application, with built-in fixture commands integration.
## Installation ## Installation
@@ -16,7 +16,7 @@ Typer-based command-line interface for managing your FastAPI application, with b
## Overview ## Overview
The `cli` module provides a `manager` entry point built with [Typer](https://typer.tiangolo.com/). It auto-discovers fixture commands when a [`FixtureRegistry`](../reference/fixtures.md#fastapi_toolsets.fixtures.registry.FixtureRegistry) and a database context are configured. The `cli` module provides a `manager` entry point built with [Typer](https://typer.tiangolo.com/). It allow custom commands to be added in addition of the fixture commands when a [`FixtureRegistry`](../reference/fixtures.md#fastapi_toolsets.fixtures.registry.FixtureRegistry) and a database context are configured.
## Configuration ## Configuration
@@ -24,24 +24,48 @@ Configure the CLI in your `pyproject.toml`:
```toml ```toml
[tool.fastapi-toolsets] [tool.fastapi-toolsets]
cli = "myapp.cli:cli" # optional: your custom Typer app cli = "myapp.cli:cli" # Custom Typer app
fixtures = "myapp.fixtures:registry" # FixtureRegistry instance fixtures = "myapp.fixtures:registry" # FixtureRegistry instance
db_context = "myapp.db:db_context" # async context manager for sessions db_context = "myapp.db:db_context" # Async context manager for sessions
``` ```
All fields are optional. Without configuration, the `manager` command still works but only includes the built-in commands. All fields are optional. Without configuration, the `manager` command still works but no command are available.
## Usage ## Usage
```bash ```bash
# List available commands # Manager commands
manager --help manager --help
# Load fixtures for a specific context Usage: manager [OPTIONS] COMMAND [ARGS]...
manager fixtures load --context testing
# Load all fixtures (no context filter) FastAPI utilities CLI.
manager fixtures load
╭─ Options ────────────────────────────────────────────────────────────────────────╮
│ --install-completion Install completion for the current shell. │
│ --show-completion Show completion for the current shell, to copy it │
│ or customize the installation. │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────────╮
│ check-db │
│ fixtures Manage database fixtures. │
╰──────────────────────────────────────────────────────────────────────────────────╯
# Fixtures commands
manager fixtures --help
Usage: manager fixtures [OPTIONS] COMMAND [ARGS]...
Manage database fixtures.
╭─ Options ────────────────────────────────────────────────────────────────────────╮
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────────╮
│ list List all registered fixtures. │
│ load Load fixtures into the database. │
╰──────────────────────────────────────────────────────────────────────────────────╯
``` ```
## Custom CLI ## Custom CLI
@@ -64,14 +88,6 @@ def hello():
cli = "myapp.cli:cli" cli = "myapp.cli:cli"
``` ```
## Entry point
The `manager` script is registered automatically when the package is installed:
```bash
manager --help
```
--- ---
[:material-api: API Reference](../reference/cli.md) [:material-api: API Reference](../reference/cli.md)

View File

@@ -2,6 +2,9 @@
Generic async CRUD operations for SQLAlchemy models with search, pagination, and many-to-many support. Generic async CRUD operations for SQLAlchemy models with search, pagination, and many-to-many support.
!!! info
This module has been coded and tested to be compatible with PostgreSQL only.
## Overview ## Overview
The `crud` module provides [`AsyncCrud`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud), an abstract base class with a full suite of async database operations, and [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory), a convenience function to instantiate it for a given model. The `crud` module provides [`AsyncCrud`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud), an abstract base class with a full suite of async database operations, and [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory), a convenience function to instantiate it for a given model.
@@ -12,10 +15,7 @@ The `crud` module provides [`AsyncCrud`](../reference/crud.md#fastapi_toolsets.c
from fastapi_toolsets.crud import CrudFactory from fastapi_toolsets.crud import CrudFactory
from myapp.models import User from myapp.models import User
UserCrud = CrudFactory( UserCrud = CrudFactory(model=User)
User,
searchable_fields=[User.username, User.email],
)
``` ```
[`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory) dynamically creates a class named `AsyncUserCrud` with `User` as its model. [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory) dynamically creates a class named `AsyncUserCrud` with `User` as its model.
@@ -24,51 +24,164 @@ UserCrud = CrudFactory(
```python ```python
# Create # Create
user = await UserCrud.create(session, obj=UserCreateSchema(username="alice")) user = await UserCrud.create(session=session, obj=UserCreateSchema(username="alice"))
# Get one (raises NotFoundError if not found) # Get one (raises NotFoundError if not found)
user = await UserCrud.get(session, filters=[User.id == user_id]) user = await UserCrud.get(session=session, filters=[User.id == user_id])
# Get first or None # Get first or None
user = await UserCrud.first(session, filters=[User.email == email]) user = await UserCrud.first(session=session, filters=[User.email == email])
# Get multiple # Get multiple
users = await UserCrud.get_multi(session, filters=[User.is_active == True]) users = await UserCrud.get_multi(session=session, filters=[User.is_active == True])
# Update # Update
user = await UserCrud.update(session, obj=UserUpdateSchema(username="bob"), filters=[User.id == user_id]) user = await UserCrud.update(session=session, obj=UserUpdateSchema(username="bob"), filters=[User.id == user_id])
# Delete # Delete
await UserCrud.delete(session, filters=[User.id == user_id]) await UserCrud.delete(session=session, filters=[User.id == user_id])
# Count / exists # Count / exists
count = await UserCrud.count(session, filters=[User.is_active == True]) count = await UserCrud.count(session=session, filters=[User.is_active == True])
exists = await UserCrud.exists(session, filters=[User.email == email]) 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
result = await UserCrud.paginate( @router.get(
session, "",
filters=[User.is_active == True], response_model=PaginatedResponse[User],
order_by=[User.created_at.desc()],
page=1,
items_per_page=20,
search="alice",
search_fields=[User.username, User.email],
) )
# result.data: list of users async def get_users(
# result.pagination: Pagination(total_count, items_per_page, page, has_more) session: SessionDep,
items_per_page: int = 50,
page: int = 1,
):
return await crud.UserCrud.offset_paginate(
session=session,
items_per_page=items_per_page,
page=page,
)
```
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(
Post, model=Post,
searchable_fields=[ searchable_fields=[
Post.title, Post.title,
Post.content, Post.content,
@@ -77,18 +190,236 @@ PostCrud = CrudFactory(
) )
``` ```
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
@router.get(
"",
response_model=PaginatedResponse[User],
)
async def get_users(
session: SessionDep,
items_per_page: int = 50,
page: int = 1,
search: str | None = None,
):
return await crud.UserCrud.offset_paginate(
session=session,
items_per_page=items_per_page,
page=page,
search=search,
)
```
```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:
```python ```python
PostCrud = CrudFactory( PostCrud = CrudFactory(
Post, model=Post,
m2m_fields={"tag_ids": Post.tags}, m2m_fields={"tag_ids": Post.tags},
) )
# schema: PostCreateSchema(title="Hello", tag_ids=[1, 2, 3]) post = await PostCrud.create(session=session, obj=PostCreateSchema(title="Hello", tag_ids=[1, 2, 3]))
post = await PostCrud.create(session, obj=PostCreateSchema(...))
``` ```
## Upsert ## Upsert
@@ -97,20 +428,46 @@ Atomic `INSERT ... ON CONFLICT DO UPDATE` using PostgreSQL:
```python ```python
await UserCrud.upsert( await UserCrud.upsert(
session, session=session,
obj=UserCreateSchema(email="alice@example.com", username="alice"), obj=UserCreateSchema(email="alice@example.com", username="alice"),
index_elements=[User.email], index_elements=[User.email],
set_={"username"}, set_={"username"},
) )
``` ```
## `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: !!! 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
response = await UserCrud.create(session, obj=schema, as_response=True) class UserRead(PydanticBase):
# response: Response[User] id: UUID
username: str
@router.get(
"/{uuid}",
responses=generate_error_responses(NotFoundError),
)
async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
return await crud.UserCrud.get(
session=session,
filters=[User.id == uuid],
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)

View File

@@ -2,9 +2,12 @@
SQLAlchemy async session management with transactions, table locking, and row-change polling. SQLAlchemy async session management with transactions, table locking, and row-change polling.
!!! info
This module has been coded and tested to be compatible with PostgreSQL only.
## Overview ## Overview
The `db` module provides helpers to create FastAPI dependencies and context managers for `AsyncSession`, along with utilities for nested transactions, PostgreSQL advisory locks, and polling for row changes. The `db` module provides helpers to create FastAPI dependencies and context managers for `AsyncSession`, along with utilities for nested transactions, table lock and polling for row changes.
## Session dependency ## Session dependency
@@ -14,10 +17,10 @@ Use [`create_db_dependency`](../reference/db.md#fastapi_toolsets.db.create_db_de
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from fastapi_toolsets.db import create_db_dependency from fastapi_toolsets.db import create_db_dependency
engine = create_async_engine("postgresql+asyncpg://...") engine = create_async_engine(url="postgresql+asyncpg://...", future=True)
session_maker = async_sessionmaker(engine) session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
get_db = create_db_dependency(session_maker) get_db = create_db_dependency(session_maker=session_maker)
@router.get("/users") @router.get("/users")
async def list_users(session: AsyncSession = Depends(get_db)): async def list_users(session: AsyncSession = Depends(get_db)):
@@ -31,11 +34,11 @@ Use [`create_db_context`](../reference/db.md#fastapi_toolsets.db.create_db_conte
```python ```python
from fastapi_toolsets.db import create_db_context from fastapi_toolsets.db import create_db_context
db_context = create_db_context(session_maker) db_context = create_db_context(session_maker=session_maker)
async def seed(): async def seed():
async with db_context() as session: async with db_context() as session:
session.add(User(name="admin")) ...
``` ```
## Nested transactions ## Nested transactions
@@ -45,11 +48,11 @@ async def seed():
```python ```python
from fastapi_toolsets.db import get_transaction from fastapi_toolsets.db import get_transaction
async def create_user_with_role(session): async def create_user_with_role(session=session):
async with get_transaction(session): async with get_transaction(session=session):
session.add(role) ...
async with get_transaction(session): # uses savepoint async with get_transaction(session=session): # uses savepoint
session.add(user) ...
``` ```
## Table locking ## Table locking
@@ -59,7 +62,7 @@ async def create_user_with_role(session):
```python ```python
from fastapi_toolsets.db import lock_tables from fastapi_toolsets.db import lock_tables
async with lock_tables(session, tables=[User], mode="EXCLUSIVE"): async with lock_tables(session=session, tables=[User], mode="EXCLUSIVE"):
# No other transaction can modify User until this block exits # No other transaction can modify User until this block exits
... ...
``` ```
@@ -75,7 +78,7 @@ from fastapi_toolsets.db import wait_for_row_change
# Wait up to 30s for order.status to change # Wait up to 30s for order.status to change
await wait_for_row_change( await wait_for_row_change(
session, session=session,
model=Order, model=Order,
pk_value=order_id, pk_value=order_id,
columns=[Order.status], columns=[Order.status],

View File

@@ -13,17 +13,17 @@ The `dependencies` module provides two factory functions that create FastAPI dep
```python ```python
from fastapi_toolsets.dependencies import PathDependency from fastapi_toolsets.dependencies import PathDependency
UserDep = PathDependency(User, User.id, session_dep=get_db) UserDep = PathDependency(model=User, field=User.id, session_dep=get_db)
@router.get("/users/{user_id}") @router.get("/users/{user_id}")
async def get_user(user: User = UserDep): async def get_user(user: User = UserDep):
return user return user
``` ```
The parameter name is inferred from the field (`user_id` for `User.id`). You can override it: By default the parameter name is inferred from the field (`user_id` for `User.id`). You can override it:
```python ```python
UserDep = PathDependency(User, User.id, session_dep=get_db, param_name="id") UserDep = PathDependency(model=User, field=User.id, session_dep=get_db, param_name="id")
@router.get("/users/{id}") @router.get("/users/{id}")
async def get_user(user: User = UserDep): async def get_user(user: User = UserDep):
@@ -37,7 +37,7 @@ async def get_user(user: User = UserDep):
```python ```python
from fastapi_toolsets.dependencies import BodyDependency from fastapi_toolsets.dependencies import BodyDependency
RoleDep = BodyDependency(Role, Role.id, session_dep=get_db, body_field="role_id") RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=get_db, body_field="role_id")
@router.post("/users") @router.post("/users")
async def create_user(body: UserCreateSchema, role: Role = RoleDep): async def create_user(body: UserCreateSchema, role: Role = RoleDep):
@@ -45,4 +45,6 @@ async def create_user(body: UserCreateSchema, role: Role = RoleDep):
... ...
``` ```
---
[:material-api: API Reference](../reference/dependencies.md) [:material-api: API Reference](../reference/dependencies.md)

View File

@@ -4,7 +4,7 @@ Structured API exceptions with consistent error responses and automatic OpenAPI
## Overview ## Overview
The `exceptions` module provides a set of pre-built HTTP exceptions and a FastAPI exception handler that formats all errors — including validation errors — into a uniform [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse) shape. The `exceptions` module provides a set of pre-built HTTP exceptions and a FastAPI exception handler that formats all errors — including validation errors — into a uniform [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse).
## Setup ## Setup
@@ -15,35 +15,43 @@ from fastapi import FastAPI
from fastapi_toolsets.exceptions import init_exceptions_handlers from fastapi_toolsets.exceptions import init_exceptions_handlers
app = FastAPI() app = FastAPI()
init_exceptions_handlers(app) 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, ForbiddenError 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, 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,6 +124,8 @@ from fastapi_toolsets.exceptions import generate_error_responses, NotFoundError,
async def get_user(...): ... async def get_user(...): ...
``` ```
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.
--- ---
[:material-api: API Reference](../reference/exceptions.md) [:material-api: API Reference](../reference/exceptions.md)

View File

@@ -32,22 +32,22 @@ Dependencies declared via `depends_on` are resolved topologically — `roles` wi
## Loading fixtures ## Loading fixtures
### By context By context with [`load_fixtures_by_context`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.load_fixtures_by_context):
```python ```python
from fastapi_toolsets.fixtures import load_fixtures_by_context from fastapi_toolsets.fixtures import load_fixtures_by_context
async with db_context() as session: async with db_context() as session:
await load_fixtures_by_context(session, registry=fixtures, context=Context.TESTING) await load_fixtures_by_context(session=session, registry=fixtures, context=Context.TESTING)
``` ```
### Directly Directly with [`load_fixtures`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.load_fixtures):
```python ```python
from fastapi_toolsets.fixtures import load_fixtures from fastapi_toolsets.fixtures import load_fixtures
async with db_context() as session: async with db_context() as session:
await load_fixtures(session, registry=fixtures) await load_fixtures(session=session, registry=fixtures)
``` ```
## Contexts ## Contexts
@@ -60,7 +60,7 @@ async with db_context() as session:
| `Context.TESTING` | Data only loaded during tests | | `Context.TESTING` | Data only loaded during tests |
| `Context.PRODUCTION` | Data only loaded in production | | `Context.PRODUCTION` | Data only loaded in production |
A fixture with no `contexts` argument is loaded in all contexts. A fixture with no `contexts` defined takes `Context.BASE` by default.
## Load strategies ## Load strategies
@@ -72,9 +72,21 @@ A fixture with no `contexts` argument is loaded in all contexts.
| `LoadStrategy.UPSERT` | Insert or update on conflict | | `LoadStrategy.UPSERT` | Insert or update on conflict |
| `LoadStrategy.SKIP` | Skip rows that already exist | | `LoadStrategy.SKIP` | Skip rows that already exist |
## Merging registries
Split fixtures definitions across modules and merge them:
```python
from myapp.fixtures.dev import dev_fixtures
from myapp.fixtures.prod import prod_fixtures
fixtures = fixturesRegistry()
fixtures.include_registry(registry=dev_fixtures)
fixtures.include_registry(registry=prod_fixtures)
## Pytest integration ## Pytest integration
Use [`register_fixtures`](../reference/pytest.md#fastapi_toolsets.pytest.plugin.register_fixtures) to expose each fixture in your registry as an injectable pytest fixture named `fixture_{name}`: Use [`register_fixtures`](../reference/pytest.md#fastapi_toolsets.pytest.plugin.register_fixtures) to expose each fixture in your registry as an injectable pytest fixture named `fixture_{name}` by default:
```python ```python
# conftest.py # conftest.py
@@ -95,10 +107,8 @@ register_fixtures(registry=registry, namespace=globals())
```python ```python
# test_users.py # test_users.py
async def test_user_can_login(fixture_users, fixture_roles, client): async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Role]):
# fixture_roles is loaded first (dependency), then fixture_users ...
response = await client.post("/auth/login", json={"username": "alice"})
assert response.status_code == 200
``` ```

View File

@@ -34,4 +34,6 @@ When called without arguments, [`get_logger`](../reference/logger.md#fastapi_too
logger = get_logger() logger = get_logger()
``` ```
---
[:material-api: API Reference](../reference/logger.md) [:material-api: API Reference](../reference/logger.md)

View File

@@ -27,7 +27,7 @@ from fastapi_toolsets.metrics import MetricsRegistry, init_metrics
app = FastAPI() app = FastAPI()
metrics = MetricsRegistry() metrics = MetricsRegistry()
init_metrics(app, registry=metrics) init_metrics(app=app, registry=metrics)
``` ```
This mounts the `/metrics` endpoint that Prometheus can scrape. This mounts the `/metrics` endpoint that Prometheus can scrape.
@@ -70,8 +70,8 @@ from myapp.metrics.http import http_metrics
from myapp.metrics.db import db_metrics from myapp.metrics.db import db_metrics
metrics = MetricsRegistry() metrics = MetricsRegistry()
metrics.include_registry(http_metrics) metrics.include_registry(registry=http_metrics)
metrics.include_registry(db_metrics) metrics.include_registry(registry=db_metrics)
``` ```
## Multi-process mode ## Multi-process mode
@@ -81,10 +81,6 @@ Multi-process support is enabled automatically when the `PROMETHEUS_MULTIPROC_DI
!!! warning "Environment variable name" !!! warning "Environment variable name"
The correct variable is `PROMETHEUS_MULTIPROC_DIR` (not `PROMETHEUS_MULTIPROCESS_DIR`). The correct variable is `PROMETHEUS_MULTIPROC_DIR` (not `PROMETHEUS_MULTIPROCESS_DIR`).
```bash
export PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus
```
--- ---
[:material-api: API Reference](../reference/metrics.md) [:material-api: API Reference](../reference/metrics.md)

View File

@@ -26,8 +26,15 @@ Use [`create_async_client`](../reference/pytest.md#fastapi_toolsets.pytest.utils
from fastapi_toolsets.pytest import create_async_client from fastapi_toolsets.pytest import create_async_client
@pytest.fixture @pytest.fixture
async def client(app): async def http_client(db_session):
async with create_async_client(app=app) as c: async def _override_get_db():
yield db_session
async with create_async_client(
app=app,
base_url="http://127.0.0.1/api/v1",
dependency_overrides={get_db: _override_get_db},
) as c:
yield c yield c
``` ```
@@ -36,36 +43,34 @@ async def client(app):
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:
```python ```python
from fastapi_toolsets.pytest import create_db_session from fastapi_toolsets.pytest import create_db_session, create_worker_database
@pytest.fixture
async def db_session():
async with create_db_session(database_url=DATABASE_URL, base=Base, cleanup=True) as session:
yield session
```
## Parallel testing with pytest-xdist
When running tests in parallel, each worker needs its own database. Use these helpers to create and identify worker databases:
```python
from fastapi_toolsets.pytest import create_worker_database, create_db_session
# In conftest.py session-scoped fixture
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def worker_db_url(): async def worker_db_url():
async with create_worker_database(database_url=DATABASE_URL) as url: async with create_worker_database(
database_url=str(settings.SQLALCHEMY_DATABASE_URI)
) as url:
yield url yield url
@pytest.fixture @pytest.fixture
async def db_session(worker_db_url): async def db_session(worker_db_url):
async with create_db_session(database_url=worker_db_url, base=Base, cleanup=True) as session: async with create_db_session(
database_url=worker_db_url, base=Base, cleanup=True
) as session:
yield session yield session
``` ```
!!! info
In this example, the database is reset between each test using the argument `cleanup=True`.
## Parallel testing with pytest-xdist
The examples above are already compatible with parallel test execution with `pytest-xdist`.
## Cleaning up tables ## Cleaning up tables
[`cleanup_tables`](../reference/pytest.md#fastapi_toolsets.pytest.utils.cleanup_tables) truncates all tables between tests for fast isolation: 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:
```python ```python
from fastapi_toolsets.pytest import cleanup_tables from fastapi_toolsets.pytest import cleanup_tables

View File

@@ -8,7 +8,7 @@ The `schemas` module provides generic response wrappers that enforce a uniform r
## Response models ## Response models
### `Response[T]` ### [`Response[T]`](../reference/schemas.md#fastapi_toolsets.schemas.Response)
The most common wrapper for a single resource response. The most common wrapper for a single resource response.
@@ -20,18 +20,22 @@ async def get_user(user: User = UserDep) -> Response[UserSchema]:
return Response(data=user, message="User retrieved") return Response(data=user, message="User retrieved")
``` ```
### `PaginatedResponse[T]` ### [`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,15 +44,32 @@ async def list_users() -> PaginatedResponse[UserSchema]:
) )
``` ```
### `ErrorResponse` #### [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination)
Returned automatically by the exceptions handler. Can also be used as a response model for OpenAPI docs. 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 ```python
from fastapi_toolsets.schemas import ErrorResponse from fastapi_toolsets.schemas import PaginatedResponse, CursorPagination
@router.delete("/users/{id}", responses={404: {"model": ErrorResponse}}) @router.get("/events")
async def delete_user(...): ... 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)
Returned automatically by the exceptions handler.
---
[:material-api: API Reference](../reference/schemas.md) [:material-api: API Reference](../reference/schemas.md)

View File

@@ -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

View File

@@ -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
View File

View File

View 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)

View 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,
],
)

View 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)]

View 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")

View 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,
)

View 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

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "0.10.0" version = "1.3.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",

View File

@@ -21,4 +21,4 @@ Example usage:
return Response(data={"user": user.username}, message="Success") return Response(data={"user": user.username}, message="Success")
""" """
__version__ = "0.10.0" __version__ = "1.3.0"

View File

@@ -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)

View File

@@ -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

View File

@@ -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",
] ]

View File

@@ -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)

View File

@@ -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

View File

@@ -10,6 +10,8 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from .exceptions import NotFoundError
__all__ = [ __all__ = [
"LockMode", "LockMode",
"create_db_context", "create_db_context",
@@ -216,7 +218,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 +239,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 +263,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:

View File

@@ -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],

View File

@@ -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",

View File

@@ -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,
}
}
}, },
} }

View File

@@ -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",
},
}
} }
} }
}, },

View File

@@ -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

View File

@@ -53,17 +53,23 @@ def init_metrics(
logger.debug("Initialising metric provider '%s'", provider.name) logger.debug("Initialising metric provider '%s'", provider.name)
provider.func() 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)

View File

@@ -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

View 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]]

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ from fastapi_toolsets.db import (
lock_tables, lock_tables,
wait_for_row_change, wait_for_row_change,
) )
from fastapi_toolsets.exceptions import NotFoundError
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User from .conftest import DATABASE_URL, Base, Role, RoleCrud, User
@@ -307,9 +308,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 +327,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 +341,6 @@ 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

View 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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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)."""

120
uv.lock generated
View File

@@ -235,7 +235,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.129.0" version = "0.133.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/22/6f/0eafed8349eea1fa462238b54a624c8b408cd1ba2795c8e64aa6c34f8ab7/fastapi-0.133.1.tar.gz", hash = "sha256:ed152a45912f102592976fde6cbce7dae1a8a1053da94202e51dd35d184fadd6", size = 378741, upload-time = "2026-02-25T18:18:17.398Z" }
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/d2/c9/a175a7779f3599dfa4adfc97a6ce0e157237b3d7941538604aadaf97bfb6/fastapi-0.133.1-py3-none-any.whl", hash = "sha256:658f34ba334605b1617a65adf2ea6461901bdb9af3a3080d63ff791ecf7dc2e2", size = 109029, upload-time = "2026-02-25T18:18:18.578Z" },
] ]
[[package]] [[package]]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "0.10.0" version = "1.3.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.2"
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/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" }
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/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" },
{ 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/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" },
{ 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/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" },
{ 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/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" },
{ 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/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" },
{ 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/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" },
{ 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/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" },
{ 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/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" },
{ 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/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" },
{ 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/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" },
{ 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/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" },
{ 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/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" },
{ 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/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" },
{ 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/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" },
{ 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/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" },
{ 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/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" },
{ 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/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
] ]
[[package]] [[package]]
@@ -1201,31 +1177,31 @@ wheels = [
[[package]] [[package]]
name = "ty" name = "ty"
version = "0.0.17" version = "0.0.18"
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/74/15/9682700d8d60fdca7afa4febc83a2354b29cdcd56e66e19c92b521db3b39/ty-0.0.18.tar.gz", hash = "sha256:04ab7c3db5dcbcdac6ce62e48940d3a0124f377c05499d3f3e004e264ae94b83", size = 5214774, upload-time = "2026-02-20T21:51:31.173Z" }
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/ae/d8/920460d4c22ea68fcdeb0b2fb53ea2aeb9c6d7875bde9278d84f2ac767b6/ty-0.0.18-py3-none-linux_armv6l.whl", hash = "sha256:4e5e91b0a79857316ef893c5068afc4b9872f9d257627d9bc8ac4d2715750d88", size = 10280825, upload-time = "2026-02-20T21:51:25.03Z" },
{ 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/83/56/62587de582d3d20d78fcdddd0594a73822ac5a399a12ef512085eb7a4de6/ty-0.0.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee0e578b3f8416e2d5416da9553b78fd33857868aa1384cb7fefeceee5ff102d", size = 10118324, upload-time = "2026-02-20T21:51:22.27Z" },
{ 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/2f/2d/dbdace8d432a0755a7417f659bfd5b8a4261938ecbdfd7b42f4c454f5aa9/ty-0.0.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3f7a0487d36b939546a91d141f7fc3dbea32fab4982f618d5b04dc9d5b6da21e", size = 9605861, upload-time = "2026-02-20T21:51:16.066Z" },
{ 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/6b/d9/de11c0280f778d5fc571393aada7fe9b8bc1dd6a738f2e2c45702b8b3150/ty-0.0.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5e2fa8d45f57ca487a470e4bf66319c09b561150e98ae2a6b1a97ef04c1a4eb", size = 10092701, upload-time = "2026-02-20T21:51:26.862Z" },
{ 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/0f/94/068d4d591d791041732171e7b63c37a54494b2e7d28e88d2167eaa9ad875/ty-0.0.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d75652e9e937f7044b1aca16091193e7ef11dac1c7ec952b7fb8292b7ba1f5f2", size = 10109203, upload-time = "2026-02-20T21:51:11.59Z" },
{ 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/34/e4/526a4aa56dc0ca2569aaa16880a1ab105c3b416dd70e87e25a05688999f3/ty-0.0.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:563c868edceb8f6ddd5e91113c17d3676b028f0ed380bdb3829b06d9beb90e58", size = 10614200, upload-time = "2026-02-20T21:51:20.298Z" },
{ 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/fd/3d/b68ab20a34122a395880922587fbfc3adf090d22e0fb546d4d20fe8c2621/ty-0.0.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:502e2a1f948bec563a0454fc25b074bf5cf041744adba8794d024277e151d3b0", size = 11153232, upload-time = "2026-02-20T21:51:14.121Z" },
{ 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/68/ea/678243c042343fcda7e6af36036c18676c355878dcdcd517639586d2cf9e/ty-0.0.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc881dea97021a3aa29134a476937fd8054775c4177d01b94db27fcfb7aab65b", size = 10832934, upload-time = "2026-02-20T21:51:32.92Z" },
{ 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/d8/bd/7f8d647cef8b7b346c0163230a37e903c7461c7248574840b977045c77df/ty-0.0.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:421fcc3bc64cab56f48edb863c7c1c43649ec4d78ff71a1acb5366ad723b6021", size = 10700888, upload-time = "2026-02-20T21:51:09.673Z" },
{ 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/6e/06/cb3620dc48c5d335ba7876edfef636b2f4498eff4a262ff90033b9e88408/ty-0.0.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0fe5038a7136a0e638a2fb1ad06e3d3c4045314c6ba165c9c303b9aeb4623d6c", size = 10078965, upload-time = "2026-02-20T21:51:07.678Z" },
{ 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/60/27/c77a5a84533fa3b685d592de7b4b108eb1f38851c40fac4e79cc56ec7350/ty-0.0.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d123600a52372677613a719bbb780adeb9b68f47fb5f25acb09171de390e0035", size = 10134659, upload-time = "2026-02-20T21:51:18.311Z" },
{ 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/43/6e/60af6b88c73469e628ba5253a296da6984e0aa746206f3034c31f1a04ed1/ty-0.0.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb4bc11d32a1bf96a829bf6b9696545a30a196ac77bbc07cc8d3dfee35e03723", size = 10297494, upload-time = "2026-02-20T21:51:39.631Z" },
{ 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/33/90/612dc0b68224c723faed6adac2bd3f930a750685db76dfe17e6b9e534a83/ty-0.0.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dda2efbf374ba4cd704053d04e32f2f784e85c2ddc2400006b0f96f5f7e4b667", size = 10791944, upload-time = "2026-02-20T21:51:37.13Z" },
{ 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/0d/da/f4ada0fd08a9e4138fe3fd2bcd3797753593f423f19b1634a814b9b2a401/ty-0.0.18-py3-none-win32.whl", hash = "sha256:c5768607c94977dacddc2f459ace6a11a408a0f57888dd59abb62d28d4fee4f7", size = 9677964, upload-time = "2026-02-20T21:51:42.039Z" },
{ 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/5e/fa/090ed9746e5c59fc26d8f5f96dc8441825171f1f47752f1778dad690b08b/ty-0.0.18-py3-none-win_amd64.whl", hash = "sha256:b78d0fa1103d36fc2fce92f2092adace52a74654ab7884d54cdaec8eb5016a4d", size = 10636576, upload-time = "2026-02-20T21:51:29.159Z" },
{ 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/92/4f/5dd60904c8105cda4d0be34d3a446c180933c76b84ae0742e58f02133713/ty-0.0.18-py3-none-win_arm64.whl", hash = "sha256:01770c3c82137c6b216aa3251478f0b197e181054ee92243772de553d3586398", size = 10095449, upload-time = "2026-02-20T21:51:34.914Z" },
] ]
[[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]]

View File

@@ -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 &copy; 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 &copy; 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,90 @@ 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"},
{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"},
{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"