mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-03-01 17:00:48 +01:00
Version 1.0.0 (#80)
* docs: fix typos * chore: build docs only when release * Version 1.0.0
This commit is contained in:
8
.github/workflows/docs.yml
vendored
8
.github/workflows/docs.yml
vendored
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# CRUD
|
# CRUD
|
||||||
|
|
||||||
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. This module has features that are only compatible with Postgres.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
This module has been coded and tested to be compatible with PostgreSQL only.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -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,56 @@ 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
|
||||||
|
|
||||||
```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,
|
async def get_users(
|
||||||
items_per_page=20,
|
session: SessionDep,
|
||||||
search="alice",
|
items_per_page: int = 50,
|
||||||
search_fields=[User.username, User.email],
|
page: int = 1,
|
||||||
|
):
|
||||||
|
return await crud.UserCrud.paginate(
|
||||||
|
session=session,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
page=page,
|
||||||
)
|
)
|
||||||
# result.data: list of users
|
|
||||||
# result.pagination: Pagination(total_count, items_per_page, page, has_more)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The [`paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate) function will return a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse).
|
||||||
|
|
||||||
## Search
|
## Search
|
||||||
|
|
||||||
Declare searchable fields on the CRUD class. Relationship traversal is supported via tuples:
|
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 +82,38 @@ PostCrud = CrudFactory(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This allow to do a search with the [`paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate) function:
|
||||||
|
|
||||||
|
```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.paginate(
|
||||||
|
session=session,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
page=page,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
## 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,7 +122,7 @@ 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"},
|
||||||
@@ -106,11 +131,22 @@ await UserCrud.upsert(
|
|||||||
|
|
||||||
## `as_response`
|
## `as_response`
|
||||||
|
|
||||||
Pass `as_response=True` to any write operation to get a [`Response[ModelType]`](../reference/schemas.md#fastapi_toolsets.schemas.Response) back directly:
|
Pass `as_response=True` to any write operation to get a [`Response[ModelType]`](../reference/schemas.md#fastapi_toolsets.schemas.Response) back directly for API usage:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
response = await UserCrud.create(session, obj=schema, as_response=True)
|
@router.get(
|
||||||
# response: Response[User]
|
"/{uuid}",
|
||||||
|
response_model=Response[User],
|
||||||
|
responses=generate_error_responses(NotFoundError),
|
||||||
|
)
|
||||||
|
async def get_user(session: SessionDep, uuid: UUID):
|
||||||
|
return await crud.UserCrud.get(
|
||||||
|
session=session,
|
||||||
|
filters=[User.id == uuid],
|
||||||
|
as_response=True,
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/crud.md)
|
[:material-api: API Reference](../reference/crud.md)
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,7 +15,7 @@ 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:
|
||||||
@@ -36,11 +36,11 @@ This registers handlers for:
|
|||||||
| [`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 |
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.exceptions import NotFoundError, ForbiddenError
|
from fastapi_toolsets.exceptions import NotFoundError
|
||||||
|
|
||||||
@router.get("/users/{id}")
|
@router.get("/users/{id}")
|
||||||
async def get_user(id: int, session: AsyncSession = Depends(get_db)):
|
async def get_user(id: int, session: AsyncSession = Depends(get_db)):
|
||||||
user = await UserCrud.first(session, filters=[User.id == id])
|
user = await UserCrud.first(session=session, filters=[User.id == id])
|
||||||
if not user:
|
if not user:
|
||||||
raise NotFoundError
|
raise NotFoundError
|
||||||
return user
|
return user
|
||||||
@@ -77,6 +77,9 @@ from fastapi_toolsets.exceptions import generate_error_responses, NotFoundError,
|
|||||||
async def get_user(...): ...
|
async def get_user(...): ...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
The pydantic validation error is automatically added by FastAPI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/exceptions.md)
|
[:material-api: API Reference](../reference/exceptions.md)
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 +20,7 @@ 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.
|
||||||
|
|
||||||
@@ -40,15 +40,10 @@ async def list_users() -> PaginatedResponse[UserSchema]:
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### `ErrorResponse`
|
### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse)
|
||||||
|
|
||||||
Returned automatically by the exceptions handler. Can also be used as a response model for OpenAPI docs.
|
Returned automatically by the exceptions handler.
|
||||||
|
|
||||||
```python
|
---
|
||||||
from fastapi_toolsets.schemas import ErrorResponse
|
|
||||||
|
|
||||||
@router.delete("/users/{id}", responses={404: {"model": ErrorResponse}})
|
|
||||||
async def delete_user(...): ...
|
|
||||||
```
|
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/schemas.md)
|
[:material-api: API Reference](../reference/schemas.md)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "0.10.0"
|
version = "1.0.0"
|
||||||
description = "Reusable tools for FastAPI: async CRUD, fixtures, CLI, and standardized responses for SQLAlchemy + PostgreSQL"
|
description = "Reusable tools for FastAPI: async CRUD, fixtures, CLI, and standardized responses for SQLAlchemy + PostgreSQL"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -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.0.0"
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -251,7 +251,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "0.10.0"
|
version = "1.0.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
|
|||||||
Reference in New Issue
Block a user