mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-04 15:44:09 +00:00
fix: ruff warnings
This commit is contained in:
+10
-3
@@ -85,13 +85,17 @@ The `paginate` shorthand was an alias for `offset_paginate`. It has been removed
|
|||||||
=== "Before (`v1`)"
|
=== "Before (`v1`)"
|
||||||
|
|
||||||
```python
|
```python
|
||||||
result = await UserCrud.paginate(session=session, page=2, items_per_page=20, schema=UserRead)
|
result = await UserCrud.paginate(
|
||||||
|
session=session, page=2, items_per_page=20, schema=UserRead
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "Now (`v2`)"
|
=== "Now (`v2`)"
|
||||||
|
|
||||||
```python
|
```python
|
||||||
result = await UserCrud.offset_paginate(session=session, page=2, items_per_page=20, schema=UserRead)
|
result = await UserCrud.offset_paginate(
|
||||||
|
session=session, page=2, items_per_page=20, schema=UserRead
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -124,8 +128,11 @@ For shared base classes that are not meant to be raised directly, use `abstract=
|
|||||||
class BillingError(ApiException, abstract=True):
|
class BillingError(ApiException, abstract=True):
|
||||||
"""Base for all billing-related errors — not raised directly."""
|
"""Base for all billing-related errors — not raised directly."""
|
||||||
|
|
||||||
|
|
||||||
class PaymentRequiredError(BillingError):
|
class PaymentRequiredError(BillingError):
|
||||||
api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402")
|
api_error = ApiError(
|
||||||
|
code=402, msg="Payment Required", desc="...", err_code="BILLING-402"
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+13
-2
@@ -45,12 +45,16 @@ Each new method accepts `search`, `filter`, and `order` boolean toggles (all `Tr
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.crud import OrderByClause
|
from fastapi_toolsets.crud import OrderByClause
|
||||||
|
|
||||||
|
|
||||||
@router.get("/offset")
|
@router.get("/offset")
|
||||||
async def list_articles_offset(
|
async def list_articles_offset(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))],
|
params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))],
|
||||||
filter_by: Annotated[dict, Depends(ArticleCrud.filter_params())],
|
filter_by: Annotated[dict, Depends(ArticleCrud.filter_params())],
|
||||||
order_by: Annotated[OrderByClause | None, Depends(ArticleCrud.order_params(default_field=Article.created_at))],
|
order_by: Annotated[
|
||||||
|
OrderByClause | None,
|
||||||
|
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||||
|
],
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
) -> OffsetPaginatedResponse[ArticleRead]:
|
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||||
return await ArticleCrud.offset_paginate(
|
return await ArticleCrud.offset_paginate(
|
||||||
@@ -79,7 +83,9 @@ Each new method accepts `search`, `filter`, and `order` boolean toggles (all `Tr
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
) -> OffsetPaginatedResponse[ArticleRead]:
|
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||||
return await ArticleCrud.offset_paginate(session=session, **params, schema=ArticleRead)
|
return await ArticleCrud.offset_paginate(
|
||||||
|
session=session, **params, schema=ArticleRead
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
The same pattern applies to `cursor_paginate_params()` and `paginate_params()`. To disable a feature, pass the toggle:
|
The same pattern applies to `cursor_paginate_params()` and `paginate_params()`. To disable a feature, pass the toggle:
|
||||||
@@ -109,6 +115,7 @@ Model method callbacks (`on_create`, `on_delete`, `on_update`) and the `@watch`
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.models import WatchedFieldsMixin, watch
|
from fastapi_toolsets.models import WatchedFieldsMixin, watch
|
||||||
|
|
||||||
|
|
||||||
@watch("status")
|
@watch("status")
|
||||||
class Order(Base, UUIDMixin, WatchedFieldsMixin):
|
class Order(Base, UUIDMixin, WatchedFieldsMixin):
|
||||||
__tablename__ = "orders"
|
__tablename__ = "orders"
|
||||||
@@ -131,21 +138,25 @@ Model method callbacks (`on_create`, `on_delete`, `on_update`) and the `@watch`
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
|
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
|
||||||
|
|
||||||
|
|
||||||
class Order(Base, UUIDMixin):
|
class Order(Base, UUIDMixin):
|
||||||
__tablename__ = "orders"
|
__tablename__ = "orders"
|
||||||
__watched_fields__ = ("status",)
|
__watched_fields__ = ("status",)
|
||||||
|
|
||||||
status: Mapped[str]
|
status: Mapped[str]
|
||||||
|
|
||||||
|
|
||||||
@listens_for(Order, [ModelEvent.CREATE])
|
@listens_for(Order, [ModelEvent.CREATE])
|
||||||
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
|
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
|
||||||
await notify_new_order(order.id)
|
await notify_new_order(order.id)
|
||||||
|
|
||||||
|
|
||||||
@listens_for(Order, [ModelEvent.UPDATE])
|
@listens_for(Order, [ModelEvent.UPDATE])
|
||||||
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
|
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
|
||||||
if "status" in changes:
|
if "status" in changes:
|
||||||
await notify_status_change(order.id, changes["status"])
|
await notify_status_change(order.id, changes["status"])
|
||||||
|
|
||||||
|
|
||||||
@listens_for(Order, [ModelEvent.DELETE])
|
@listens_for(Order, [ModelEvent.DELETE])
|
||||||
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
|
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
|
||||||
await notify_order_cancelled(order.id)
|
await notify_order_cancelled(order.id)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ The function creates and manages its own **dedicated session** internally, yield
|
|||||||
user.balance += 100
|
user.balance += 100
|
||||||
|
|
||||||
# With a custom lock mode
|
# With a custom lock mode
|
||||||
async with lock_tables(session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE) as session:
|
async with lock_tables(
|
||||||
|
session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE
|
||||||
|
) as session:
|
||||||
await process_order(session, order_id)
|
await process_order(session, order_id)
|
||||||
```
|
```
|
||||||
|
|||||||
+10
-5
@@ -24,9 +24,10 @@ Build one `Database` with your URL (or an existing `engine=`), then use the inst
|
|||||||
get_db = create_db_dependency(session_maker=SessionLocal)
|
get_db = create_db_dependency(session_maker=SessionLocal)
|
||||||
get_db_context = create_db_context(session_maker=SessionLocal)
|
get_db_context = create_db_context(session_maker=SessionLocal)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/users")
|
@app.get("/users")
|
||||||
async def list_users(session: AsyncSession = Depends(get_db)):
|
async def list_users(session: AsyncSession = Depends(get_db)): ...
|
||||||
...
|
|
||||||
|
|
||||||
async def seed():
|
async def seed():
|
||||||
async with get_db_context() as session:
|
async with get_db_context() as session:
|
||||||
@@ -40,9 +41,10 @@ Build one `Database` with your URL (or an existing `engine=`), then use the inst
|
|||||||
|
|
||||||
db = Database(url="postgresql+asyncpg://...")
|
db = Database(url="postgresql+asyncpg://...")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/users")
|
@app.get("/users")
|
||||||
async def list_users(session: AsyncSession = Depends(db)):
|
async def list_users(session: AsyncSession = Depends(db)): ...
|
||||||
...
|
|
||||||
|
|
||||||
async def seed():
|
async def seed():
|
||||||
async with db.session() as session:
|
async with db.session() as session:
|
||||||
@@ -89,7 +91,9 @@ The free `lock_tables(session_maker, tables, ...)` function still exists for cal
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import lock_tables, LockMode
|
from fastapi_toolsets.db import lock_tables, LockMode
|
||||||
|
|
||||||
async with lock_tables(session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE) as session:
|
async with lock_tables(
|
||||||
|
session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE
|
||||||
|
) as session:
|
||||||
await process_order(session, order_id)
|
await process_order(session, order_id)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -127,6 +131,7 @@ Both also change their first argument: instead of the fixture *function*, pass t
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.fixtures import get_obj_by_attr, get_field_by_attr
|
from fastapi_toolsets.fixtures import get_obj_by_attr, get_field_by_attr
|
||||||
|
|
||||||
|
|
||||||
@fixtures.register(depends_on=["roles"])
|
@fixtures.register(depends_on=["roles"])
|
||||||
def users():
|
def users():
|
||||||
admin_role = get_obj_by_attr(fixtures=roles, attr_name="name", value="admin")
|
admin_role = get_obj_by_attr(fixtures=roles, attr_name="name", value="admin")
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ import typer
|
|||||||
|
|
||||||
cli = typer.Typer()
|
cli = typer.Typer()
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
def hello():
|
def hello():
|
||||||
print("Hello from my app!")
|
print("Hello from my app!")
|
||||||
|
|||||||
+26
-6
@@ -30,6 +30,7 @@ UserCrud = CrudFactory(model=User)
|
|||||||
from fastapi_toolsets.crud.factory import AsyncCrud
|
from fastapi_toolsets.crud.factory import AsyncCrud
|
||||||
from myapp.models import User
|
from myapp.models import User
|
||||||
|
|
||||||
|
|
||||||
class UserCrud(AsyncCrud[User]):
|
class UserCrud(AsyncCrud[User]):
|
||||||
model = User
|
model = User
|
||||||
searchable_fields = [User.username, User.email]
|
searchable_fields = [User.username, User.email]
|
||||||
@@ -61,6 +62,7 @@ from fastapi_toolsets.crud.factory import AsyncCrud
|
|||||||
|
|
||||||
T = TypeVar("T", bound=DeclarativeBase)
|
T = TypeVar("T", bound=DeclarativeBase)
|
||||||
|
|
||||||
|
|
||||||
class AuditedCrud(AsyncCrud[T], Generic[T]):
|
class AuditedCrud(AsyncCrud[T], Generic[T]):
|
||||||
"""Base CRUD with custom function"""
|
"""Base CRUD with custom function"""
|
||||||
|
|
||||||
@@ -101,7 +103,9 @@ user = await UserCrud.first(session=session, filters=[User.email == email])
|
|||||||
users = await UserCrud.get_multi(session=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=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=session, filters=[User.id == user_id])
|
await UserCrud.delete(session=session, filters=[User.id == user_id])
|
||||||
@@ -160,10 +164,14 @@ user = await UserCrud.get(session, [User.id == user_id], with_for_update=True)
|
|||||||
user = await UserCrud.get(session, [User.id == user_id], with_for_update="nowait")
|
user = await UserCrud.get(session, [User.id == user_id], with_for_update="nowait")
|
||||||
|
|
||||||
# Skip rows already locked by another transaction (e.g. job queues)
|
# Skip rows already locked by another transaction (e.g. job queues)
|
||||||
rows = await JobCrud.get_multi(session, filters=[Job.status == "pending"], with_for_update="skip_locked")
|
rows = await JobCrud.get_multi(
|
||||||
|
session, filters=[Job.status == "pending"], with_for_update="skip_locked"
|
||||||
|
)
|
||||||
|
|
||||||
# Lock atomically as part of update (prevents race between SELECT and UPDATE)
|
# Lock atomically as part of update (prevents race between SELECT and UPDATE)
|
||||||
user = await UserCrud.update(session, UserUpdate(credits=10), [User.id == user_id], with_for_update=True)
|
user = await UserCrud.update(
|
||||||
|
session, UserUpdate(credits=10), [User.id == user_id], with_for_update=True
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
@@ -193,6 +201,7 @@ Three pagination methods are available. All return a typed response whose `pagi
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_users(
|
async def get_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
@@ -228,7 +237,9 @@ By default `offset_paginate` runs two queries: one for the page items and one `C
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_users(
|
async def get_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(include_total=False))],
|
params: Annotated[
|
||||||
|
dict, Depends(UserCrud.offset_paginate_params(include_total=False))
|
||||||
|
],
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||||
```
|
```
|
||||||
@@ -303,6 +314,7 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.schemas import PaginatedResponse
|
from fastapi_toolsets.schemas import PaginatedResponse
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
@@ -446,6 +458,7 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model_exclude_none=True)
|
@router.get("", response_model_exclude_none=True)
|
||||||
async def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
@@ -540,6 +553,7 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
@@ -632,7 +646,9 @@ PostCrud = CrudFactory(
|
|||||||
m2m_fields={"tag_ids": Post.tags},
|
m2m_fields={"tag_ids": Post.tags},
|
||||||
)
|
)
|
||||||
|
|
||||||
post = await PostCrud.create(session=session, obj=PostCreateSchema(title="Hello", tag_ids=[1, 2, 3]))
|
post = await PostCrud.create(
|
||||||
|
session=session, obj=PostCreateSchema(title="Hello", tag_ids=[1, 2, 3])
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Upsert
|
## Upsert
|
||||||
@@ -659,6 +675,7 @@ class UserRead(PydanticBase):
|
|||||||
id: UUID
|
id: UUID
|
||||||
username: str
|
username: str
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{uuid}",
|
"/{uuid}",
|
||||||
responses=generate_error_responses(NotFoundError),
|
responses=generate_error_responses(NotFoundError),
|
||||||
@@ -670,12 +687,15 @@ async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
|
|||||||
schema=UserRead,
|
schema=UserRead,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[dict, Depends(crud.UserCrud.offset_paginate_params())],
|
params: Annotated[dict, Depends(crud.UserCrud.offset_paginate_params())],
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
return await crud.UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
return await crud.UserCrud.offset_paginate(
|
||||||
|
session=session, **params, schema=UserRead
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.
|
The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.
|
||||||
|
|||||||
+9
-3
@@ -24,9 +24,9 @@ db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
|||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
db.install(app) # commit middleware + engine disposal on shutdown
|
db.install(app) # commit middleware + engine disposal on shutdown
|
||||||
|
|
||||||
|
|
||||||
@app.get("/users")
|
@app.get("/users")
|
||||||
async def list_users(session: AsyncSession = Depends(db)):
|
async def list_users(session: AsyncSession = Depends(db)): ...
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The `Database` instance **is** the dependency: use it directly as `Depends(db)`. The whole request runs as a single transaction (CRUD writes use savepoints under it).
|
The `Database` instance **is** the dependency: use it directly as `Depends(db)`. The whole request runs as a single transaction (CRUD writes use savepoints under it).
|
||||||
@@ -37,9 +37,11 @@ The **URL** may be a plain string or a Pydantic [`PostgresDsn`](https://docs.pyd
|
|||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from pydantic import PostgresDsn
|
from pydantic import PostgresDsn
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
database_url: PostgresDsn
|
database_url: PostgresDsn
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
db = Database(
|
db = Database(
|
||||||
@@ -72,12 +74,14 @@ Without `install`, the session commits in the dependency teardown, which runs af
|
|||||||
```python
|
```python
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app):
|
async def lifespan(app):
|
||||||
await warm_cache() # your startup
|
await warm_cache() # your startup
|
||||||
yield
|
yield
|
||||||
await flush_metrics() # your shutdown
|
await flush_metrics() # your shutdown
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
db.install(app) # your shutdown runs first, then the engine is disposed
|
db.install(app) # your shutdown runs first, then the engine is disposed
|
||||||
```
|
```
|
||||||
@@ -101,6 +105,7 @@ async def seed():
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import transaction
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
|
|
||||||
async def create_user_with_role(session):
|
async def create_user_with_role(session):
|
||||||
async with transaction(session):
|
async with transaction(session):
|
||||||
...
|
...
|
||||||
@@ -207,6 +212,7 @@ For test isolation with automatic cleanup, use [`create_worker_database`](../ref
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db.testing import cleanup_tables
|
from fastapi_toolsets.db.testing import cleanup_tables
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
async def clean(db_session):
|
async def clean(db_session):
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ UserDep = PathDependency(model=User, field=User.id, session_dep=get_db)
|
|||||||
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
||||||
UserDep = PathDependency(model=User, field=User.id, session_dep=SessionDep)
|
UserDep = PathDependency(model=User, field=User.id, session_dep=SessionDep)
|
||||||
|
|
||||||
|
|
||||||
@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
|
||||||
@@ -30,6 +31,7 @@ By default the parameter name is inferred from the field (`user_id` for `User.id
|
|||||||
```python
|
```python
|
||||||
UserDep = PathDependency(model=User, field=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):
|
||||||
return user
|
return user
|
||||||
@@ -43,11 +45,15 @@ async def get_user(user: User = UserDep):
|
|||||||
from fastapi_toolsets.dependencies import BodyDependency
|
from fastapi_toolsets.dependencies import BodyDependency
|
||||||
|
|
||||||
# Plain callable
|
# Plain callable
|
||||||
RoleDep = BodyDependency(model=Role, field=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"
|
||||||
|
)
|
||||||
|
|
||||||
# Annotated
|
# Annotated
|
||||||
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
||||||
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=SessionDep, body_field="role_id")
|
RoleDep = BodyDependency(
|
||||||
|
model=Role, field=Role.id, session_dep=SessionDep, body_field="role_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users")
|
@router.post("/users")
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ All built-in exceptions accept optional keyword arguments to customise the respo
|
|||||||
| `data` | Overrides the `data` field |
|
| `data` | Overrides the `data` field |
|
||||||
|
|
||||||
```python
|
```python
|
||||||
raise NotFoundError(detail="User 42 not found", desc="No user with that ID exists in the database.")
|
raise NotFoundError(
|
||||||
|
detail="User 42 not found", desc="No user with that ID exists in the database."
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Custom exceptions
|
## Custom exceptions
|
||||||
@@ -64,6 +66,7 @@ Subclass [`ApiException`](../reference/exceptions.md#fastapi_toolsets.exceptions
|
|||||||
from fastapi_toolsets.exceptions import ApiException
|
from fastapi_toolsets.exceptions import ApiException
|
||||||
from fastapi_toolsets.schemas import ApiError
|
from fastapi_toolsets.schemas import ApiError
|
||||||
|
|
||||||
|
|
||||||
class PaymentRequiredError(ApiException):
|
class PaymentRequiredError(ApiException):
|
||||||
api_error = ApiError(
|
api_error = ApiError(
|
||||||
code=402,
|
code=402,
|
||||||
@@ -105,11 +108,17 @@ Use `abstract=True` when creating a shared base that is not meant to be raised d
|
|||||||
class BillingError(ApiException, abstract=True):
|
class BillingError(ApiException, abstract=True):
|
||||||
"""Base for all billing-related errors."""
|
"""Base for all billing-related errors."""
|
||||||
|
|
||||||
|
|
||||||
class PaymentRequiredError(BillingError):
|
class PaymentRequiredError(BillingError):
|
||||||
api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402")
|
api_error = ApiError(
|
||||||
|
code=402, msg="Payment Required", desc="...", err_code="BILLING-402"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionExpiredError(BillingError):
|
class SubscriptionExpiredError(BillingError):
|
||||||
api_error = ApiError(code=402, msg="Subscription Expired", desc="...", err_code="BILLING-402-EXP")
|
api_error = ApiError(
|
||||||
|
code=402, msg="Subscription Expired", desc="...", err_code="BILLING-402-EXP"
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## OpenAPI response documentation
|
## OpenAPI response documentation
|
||||||
|
|||||||
+18
-5
@@ -13,6 +13,7 @@ from fastapi_toolsets.fixtures import FixtureRegistry, Context
|
|||||||
|
|
||||||
fixtures = FixtureRegistry()
|
fixtures = FixtureRegistry()
|
||||||
|
|
||||||
|
|
||||||
@fixtures.register
|
@fixtures.register
|
||||||
def roles():
|
def roles():
|
||||||
return [
|
return [
|
||||||
@@ -20,6 +21,7 @@ def roles():
|
|||||||
Role(id=2, name="user"),
|
Role(id=2, name="user"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
|
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
|
||||||
def test_users():
|
def test_users():
|
||||||
return [
|
return [
|
||||||
@@ -79,14 +81,17 @@ Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is
|
|||||||
```python
|
```python
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
class AppContext(str, Enum):
|
class AppContext(str, Enum):
|
||||||
STAGING = "staging"
|
STAGING = "staging"
|
||||||
DEMO = "demo"
|
DEMO = "demo"
|
||||||
|
|
||||||
|
|
||||||
@fixtures.register(contexts=[AppContext.STAGING])
|
@fixtures.register(contexts=[AppContext.STAGING])
|
||||||
def staging_data():
|
def staging_data():
|
||||||
return [Config(key="feature_x", enabled=True)]
|
return [Config(key="feature_x", enabled=True)]
|
||||||
|
|
||||||
|
|
||||||
# loads staging_data plus any Context.BASE fixtures
|
# loads staging_data plus any Context.BASE fixtures
|
||||||
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
|
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
|
||||||
```
|
```
|
||||||
@@ -98,7 +103,8 @@ Pass `contexts` to `FixtureRegistry` to set a default for all fixtures registere
|
|||||||
```python
|
```python
|
||||||
testing_registry = FixtureRegistry(contexts=[Context.TESTING])
|
testing_registry = FixtureRegistry(contexts=[Context.TESTING])
|
||||||
|
|
||||||
@testing_registry.register # implicitly contexts=[Context.TESTING]
|
|
||||||
|
@testing_registry.register # implicitly contexts=[Context.TESTING]
|
||||||
def test_orders():
|
def test_orders():
|
||||||
return [Order(id=1, total=99)]
|
return [Order(id=1, total=99)]
|
||||||
```
|
```
|
||||||
@@ -112,10 +118,12 @@ The same fixture name may be registered under different (non-overlapping) contex
|
|||||||
def users():
|
def users():
|
||||||
return [User(id=1, username="admin")]
|
return [User(id=1, username="admin")]
|
||||||
|
|
||||||
|
|
||||||
@fixtures.register(contexts=[Context.TESTING])
|
@fixtures.register(contexts=[Context.TESTING])
|
||||||
def users():
|
def users():
|
||||||
return [User(id=2, username="tester")]
|
return [User(id=2, username="tester")]
|
||||||
|
|
||||||
|
|
||||||
# loads both admin and tester (Context.BASE is included automatically)
|
# loads both admin and tester (Context.BASE is included automatically)
|
||||||
await load_fixtures_by_context(session, fixtures, Context.TESTING)
|
await load_fixtures_by_context(session, fixtures, Context.TESTING)
|
||||||
```
|
```
|
||||||
@@ -171,7 +179,9 @@ Looking the fixture up by name (instead of importing the `roles` function direct
|
|||||||
```python
|
```python
|
||||||
@fixtures.register(depends_on=["roles"])
|
@fixtures.register(depends_on=["roles"])
|
||||||
def users():
|
def users():
|
||||||
return [User(id=1, username="alice", role_id=fixtures.field("roles", "name", "admin"))]
|
return [
|
||||||
|
User(id=1, username="alice", role_id=fixtures.field("roles", "name", "admin"))
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
Both raise `StopIteration` if no matching instance is found, and `KeyError` if the fixture name isn't registered.
|
Both raise `StopIteration` if no matching instance is found, and `KeyError` if the fixture name isn't registered.
|
||||||
@@ -189,18 +199,21 @@ from app.models import Base
|
|||||||
|
|
||||||
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/test_db"
|
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/test_db"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def db_session():
|
async def db_session():
|
||||||
async with create_db_session(database_url=DATABASE_URL, base=Base, cleanup=True) as session:
|
async with create_db_session(
|
||||||
|
database_url=DATABASE_URL, base=Base, cleanup=True
|
||||||
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
register_fixtures(registry=registry, namespace=globals())
|
register_fixtures(registry=registry, namespace=globals())
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# test_users.py
|
# test_users.py
|
||||||
async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Role]):
|
async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Role]): ...
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The load order is resolved automatically from the `depends_on` declarations in your registry. Each generated fixture receives `db_session` as a dependency and returns the list of loaded model instances.
|
The load order is resolved automatically from the `depends_on` declarations in your registry. Each generated fixture receives `db_session` as a dependency and returns the list of loaded model instances.
|
||||||
|
|||||||
@@ -47,10 +47,12 @@ If neither of these applies to you, declaring metrics at module level (e.g. `HTT
|
|||||||
```python
|
```python
|
||||||
from prometheus_client import Counter, Histogram
|
from prometheus_client import Counter, Histogram
|
||||||
|
|
||||||
|
|
||||||
@metrics.register
|
@metrics.register
|
||||||
def http_requests():
|
def http_requests():
|
||||||
return Counter("http_requests_total", "Total HTTP requests", ["method", "status"])
|
return Counter("http_requests_total", "Total HTTP requests", ["method", "status"])
|
||||||
|
|
||||||
|
|
||||||
@metrics.register
|
@metrics.register
|
||||||
def request_duration():
|
def request_duration():
|
||||||
return Histogram("request_duration_seconds", "Request duration")
|
return Histogram("request_duration_seconds", "Request duration")
|
||||||
@@ -79,6 +81,7 @@ from prometheus_client import Gauge
|
|||||||
|
|
||||||
_queue_depth = Gauge("queue_depth", "Current queue depth")
|
_queue_depth = Gauge("queue_depth", "Current queue depth")
|
||||||
|
|
||||||
|
|
||||||
@metrics.register(collect=True)
|
@metrics.register(collect=True)
|
||||||
def collect_queue_depth():
|
def collect_queue_depth():
|
||||||
_queue_depth.set(get_current_queue_depth())
|
_queue_depth.set(get_current_queue_depth())
|
||||||
|
|||||||
+19
-1
@@ -11,6 +11,7 @@ The `models` module provides mixins that each add a single, well-defined column
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class Article(Base, UUIDMixin, TimestampMixin):
|
class Article(Base, UUIDMixin, TimestampMixin):
|
||||||
__tablename__ = "articles"
|
__tablename__ = "articles"
|
||||||
|
|
||||||
@@ -31,11 +32,13 @@ Adds a `id: UUID` primary key generated server-side by PostgreSQL using `gen_ran
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.models import UUIDMixin
|
from fastapi_toolsets.models import UUIDMixin
|
||||||
|
|
||||||
|
|
||||||
class User(Base, UUIDMixin):
|
class User(Base, UUIDMixin):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
username: Mapped[str]
|
username: Mapped[str]
|
||||||
|
|
||||||
|
|
||||||
# id is None before flush
|
# id is None before flush
|
||||||
user = User(username="alice")
|
user = User(username="alice")
|
||||||
session.add(user)
|
session.add(user)
|
||||||
@@ -54,11 +57,13 @@ Adds a `id: UUID` primary key generated server-side by PostgreSQL using `uuidv7(
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.models import UUIDv7Mixin
|
from fastapi_toolsets.models import UUIDv7Mixin
|
||||||
|
|
||||||
|
|
||||||
class Event(Base, UUIDv7Mixin):
|
class Event(Base, UUIDv7Mixin):
|
||||||
__tablename__ = "events"
|
__tablename__ = "events"
|
||||||
|
|
||||||
name: Mapped[str]
|
name: Mapped[str]
|
||||||
|
|
||||||
|
|
||||||
# id is None before flush
|
# id is None before flush
|
||||||
event = Event(name="user.signup")
|
event = Event(name="user.signup")
|
||||||
session.add(event)
|
session.add(event)
|
||||||
@@ -73,6 +78,7 @@ Adds a `created_at: datetime` column set to `clock_timestamp()` on insert. The c
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin
|
from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
class Order(Base, UUIDMixin, CreatedAtMixin):
|
class Order(Base, UUIDMixin, CreatedAtMixin):
|
||||||
__tablename__ = "orders"
|
__tablename__ = "orders"
|
||||||
|
|
||||||
@@ -86,11 +92,13 @@ Adds an `updated_at: datetime` column set to `clock_timestamp()` on insert and a
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin
|
from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
class Post(Base, UUIDMixin, UpdatedAtMixin):
|
class Post(Base, UUIDMixin, UpdatedAtMixin):
|
||||||
__tablename__ = "posts"
|
__tablename__ = "posts"
|
||||||
|
|
||||||
title: Mapped[str]
|
title: Mapped[str]
|
||||||
|
|
||||||
|
|
||||||
post = Post(title="Hello")
|
post = Post(title="Hello")
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await session.refresh(post)
|
await session.refresh(post)
|
||||||
@@ -111,6 +119,7 @@ Convenience mixin that combines [`CreatedAtMixin`](../reference/models.md#fastap
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class Article(Base, UUIDMixin, TimestampMixin):
|
class Article(Base, UUIDMixin, TimestampMixin):
|
||||||
__tablename__ = "articles"
|
__tablename__ = "articles"
|
||||||
|
|
||||||
@@ -166,10 +175,12 @@ class Order(Base, UUIDMixin):
|
|||||||
__watched_fields__ = ("status",)
|
__watched_fields__ = ("status",)
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class UrgentOrder(Order):
|
class UrgentOrder(Order):
|
||||||
# inherits __watched_fields__ = ("status",)
|
# inherits __watched_fields__ = ("status",)
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class PriorityOrder(Order):
|
class PriorityOrder(Order):
|
||||||
__watched_fields__ = ("priority",)
|
__watched_fields__ = ("priority",)
|
||||||
# overrides parent — UPDATE fires only for priority changes
|
# overrides parent — UPDATE fires only for priority changes
|
||||||
@@ -183,20 +194,24 @@ Register handlers with the [`listens_for`](../reference/models.md#fastapi_toolse
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
|
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
|
||||||
|
|
||||||
|
|
||||||
class Order(Base, UUIDMixin):
|
class Order(Base, UUIDMixin):
|
||||||
__tablename__ = "orders"
|
__tablename__ = "orders"
|
||||||
__watched_fields__ = ("status",)
|
__watched_fields__ = ("status",)
|
||||||
|
|
||||||
status: Mapped[str]
|
status: Mapped[str]
|
||||||
|
|
||||||
|
|
||||||
@listens_for(Order, [ModelEvent.CREATE])
|
@listens_for(Order, [ModelEvent.CREATE])
|
||||||
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
|
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
|
||||||
await notify_new_order(order.id)
|
await notify_new_order(order.id)
|
||||||
|
|
||||||
|
|
||||||
@listens_for(Order, [ModelEvent.DELETE])
|
@listens_for(Order, [ModelEvent.DELETE])
|
||||||
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
|
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
|
||||||
await notify_order_cancelled(order.id)
|
await notify_order_cancelled(order.id)
|
||||||
|
|
||||||
|
|
||||||
@listens_for(Order, [ModelEvent.UPDATE])
|
@listens_for(Order, [ModelEvent.UPDATE])
|
||||||
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
|
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
|
||||||
if "status" in changes:
|
if "status" in changes:
|
||||||
@@ -212,8 +227,11 @@ A single handler can listen for multiple events at once. When `event_types` is o
|
|||||||
async def on_order_changed(order: Order, event_type: ModelEvent, changes: dict | None):
|
async def on_order_changed(order: Order, event_type: ModelEvent, changes: dict | None):
|
||||||
await invalidate_cache(order.id)
|
await invalidate_cache(order.id)
|
||||||
|
|
||||||
|
|
||||||
@listens_for(Order) # all events
|
@listens_for(Order) # all events
|
||||||
async def on_any_order_event(order: Order, event_type: ModelEvent, changes: dict | None):
|
async def on_any_order_event(
|
||||||
|
order: Order, event_type: ModelEvent, changes: dict | None
|
||||||
|
):
|
||||||
await audit_log(order.id, event_type)
|
await audit_log(order.id, event_type)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -21,6 +21,7 @@ Use [`create_async_client`](../reference/pytest.md#fastapi_toolsets.pytest.utils
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.pytest import create_async_client
|
from fastapi_toolsets.pytest import create_async_client
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def http_client(db_session):
|
async def http_client(db_session):
|
||||||
async def _override_get_db():
|
async def _override_get_db():
|
||||||
@@ -52,6 +53,7 @@ Use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.ut
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.pytest import create_worker_database, create_db_session
|
from fastapi_toolsets.pytest import create_worker_database, create_db_session
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def worker_db_url():
|
async def worker_db_url():
|
||||||
async with create_worker_database(
|
async with create_worker_database(
|
||||||
@@ -96,11 +98,17 @@ Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.pytest import worker_database_url
|
from fastapi_toolsets.pytest import worker_database_url
|
||||||
|
|
||||||
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test")
|
url = worker_database_url(
|
||||||
|
"postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test"
|
||||||
|
)
|
||||||
# → "postgresql+asyncpg://user:pass@localhost/gw0" under xdist
|
# → "postgresql+asyncpg://user:pass@localhost/gw0" under xdist
|
||||||
# → "postgresql+asyncpg://user:pass@localhost/test" otherwise
|
# → "postgresql+asyncpg://user:pass@localhost/test" otherwise
|
||||||
|
|
||||||
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test", prefix="myapp")
|
url = worker_database_url(
|
||||||
|
"postgresql+asyncpg://user:pass@localhost/myapp",
|
||||||
|
default_test_db="test",
|
||||||
|
prefix="myapp",
|
||||||
|
)
|
||||||
# → "postgresql+asyncpg://user:pass@localhost/myapp_gw0" under xdist
|
# → "postgresql+asyncpg://user:pass@localhost/myapp_gw0" under xdist
|
||||||
# → "postgresql+asyncpg://user:pass@localhost/myapp_test" otherwise
|
# → "postgresql+asyncpg://user:pass@localhost/myapp_test" otherwise
|
||||||
```
|
```
|
||||||
@@ -112,6 +120,7 @@ url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", defa
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.pytest import cleanup_tables
|
from fastapi_toolsets.pytest import cleanup_tables
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
async def clean(db_session):
|
async def clean(db_session):
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ The most common wrapper for a single resource response.
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.schemas import Response
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/{id}")
|
@router.get("/users/{id}")
|
||||||
async def get_user(user: User = UserDep) -> Response[UserSchema]:
|
async def get_user(user: User = UserDep) -> Response[UserSchema]:
|
||||||
return Response(data=user, message="User retrieved")
|
return Response(data=user, message="User retrieved")
|
||||||
@@ -39,6 +40,7 @@ Use as the return type when the endpoint always uses [`offset_paginate`](crud.md
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.schemas import OffsetPaginatedResponse
|
from fastapi_toolsets.schemas import OffsetPaginatedResponse
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users")
|
@router.get("/users")
|
||||||
async def list_users(
|
async def list_users(
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
@@ -74,6 +76,7 @@ Use as the return type when the endpoint always uses [`cursor_paginate`](crud.md
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.schemas import CursorPaginatedResponse
|
from fastapi_toolsets.schemas import CursorPaginatedResponse
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events")
|
@router.get("/events")
|
||||||
async def list_events(
|
async def list_events(
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
@@ -110,6 +113,7 @@ When used as a return annotation, `PaginatedResponse[T]` automatically expands t
|
|||||||
from fastapi_toolsets.crud import PaginationType
|
from fastapi_toolsets.crud import PaginationType
|
||||||
from fastapi_toolsets.schemas import PaginatedResponse
|
from fastapi_toolsets.schemas import PaginatedResponse
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users")
|
@router.get("/users")
|
||||||
async def list_users(
|
async def list_users(
|
||||||
pagination_type: PaginationType = PaginationType.OFFSET,
|
pagination_type: PaginationType = PaginationType.OFFSET,
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ You can import the main symbols from `fastapi_toolsets.crud`:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.crud import CrudFactory, AsyncCrud
|
from fastapi_toolsets.crud import CrudFactory, AsyncCrud
|
||||||
from fastapi_toolsets.crud.search import SearchConfig, get_searchable_fields, build_search_filters
|
from fastapi_toolsets.crud.search import (
|
||||||
|
SearchConfig,
|
||||||
|
get_searchable_fields,
|
||||||
|
build_search_filters,
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## ::: fastapi_toolsets.crud.factory.AsyncCrud
|
## ::: fastapi_toolsets.crud.factory.AsyncCrud
|
||||||
|
|||||||
@@ -94,6 +94,18 @@ docs-src = [
|
|||||||
requires = ["uv_build>=0.10,<0.12.0"]
|
requires = ["uv_build>=0.10,<0.12.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
exclude = ["*.md"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
extend-select = ["E712"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-bugbear]
|
||||||
|
extend-immutable-calls = ["fastapi.Depends"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/**" = ["RUF012", "RUF059", "SIM117", "DTZ001", "S110", "BLE001"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
filterwarnings = [
|
filterwarnings = [
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ __all__ = [
|
|||||||
"AsyncCrud",
|
"AsyncCrud",
|
||||||
"CrudFactory",
|
"CrudFactory",
|
||||||
"FacetFieldType",
|
"FacetFieldType",
|
||||||
"get_searchable_fields",
|
|
||||||
"InvalidFacetFilterError",
|
"InvalidFacetFilterError",
|
||||||
"InvalidSearchColumnError",
|
"InvalidSearchColumnError",
|
||||||
"JoinType",
|
"JoinType",
|
||||||
@@ -34,4 +33,5 @@ __all__ = [
|
|||||||
"SearchConfig",
|
"SearchConfig",
|
||||||
"SearchFieldType",
|
"SearchFieldType",
|
||||||
"UnsupportedFacetTypeError",
|
"UnsupportedFacetTypeError",
|
||||||
|
"get_searchable_fields",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1580,11 +1580,10 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
|
|
||||||
# prev_cursor: points before the first item in ascending order
|
# prev_cursor: points before the first item in ascending order
|
||||||
prev_cursor: str | None = None
|
prev_cursor: str | None = None
|
||||||
if direction is _CursorDirection.NEXT and cursor is not None and items_page:
|
if items_page and (
|
||||||
prev_cursor = _encode_cursor(
|
(direction is _CursorDirection.NEXT and cursor is not None)
|
||||||
getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
|
or (direction is _CursorDirection.PREV and has_more)
|
||||||
)
|
):
|
||||||
elif direction is _CursorDirection.PREV and has_more and items_page:
|
|
||||||
prev_cursor = _encode_cursor(
|
prev_cursor = _encode_cursor(
|
||||||
getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
|
getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ def build_filter_by(
|
|||||||
enum_class = col_type.enum_class
|
enum_class = col_type.enum_class
|
||||||
if enum_class is not None:
|
if enum_class is not None:
|
||||||
|
|
||||||
def _coerce_enum(v: Any) -> Any:
|
def _coerce_enum(v: Any, enum_class: Any = enum_class) -> Any:
|
||||||
if isinstance(v, enum_class):
|
if isinstance(v, enum_class):
|
||||||
return v
|
return v
|
||||||
return enum_class[v] # lookup by name: "PENDING", "RED"
|
return enum_class[v] # lookup by name: "PENDING", "RED"
|
||||||
|
|||||||
@@ -212,9 +212,8 @@ class Database:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _composed(app_: Any) -> AsyncGenerator[None, None]:
|
async def _composed(app_: Any) -> AsyncGenerator[None, None]:
|
||||||
async with self.lifespan(app_):
|
async with self.lifespan(app_), inner_lifespan(app_):
|
||||||
async with inner_lifespan(app_):
|
yield
|
||||||
yield
|
|
||||||
|
|
||||||
app.router.lifespan_context = _composed
|
app.router.lifespan_context = _composed
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Many-to-Many association-table helpers (direct, without loading collections)."""
|
"""Many-to-Many association-table helpers (direct, without loading collections)."""
|
||||||
|
|
||||||
from typing import Any, TypeVar, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from sqlalchemy import ColumnElement, Table, delete, tuple_
|
from sqlalchemy import ColumnElement, Table, delete, tuple_
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
@@ -8,8 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
||||||
from sqlalchemy.orm.relationships import RelationshipProperty
|
from sqlalchemy.orm.relationships import RelationshipProperty
|
||||||
|
|
||||||
_M = TypeVar("_M", bound=DeclarativeBase)
|
|
||||||
|
|
||||||
|
|
||||||
def _m2m_prop(rel_attr: QueryableAttribute) -> tuple[RelationshipProperty, Table]: # type: ignore[type-arg]
|
def _m2m_prop(rel_attr: QueryableAttribute) -> tuple[RelationshipProperty, Table]: # type: ignore[type-arg]
|
||||||
"""Return the validated M2M RelationshipProperty and its secondary table.
|
"""Return the validated M2M RelationshipProperty and its secondary table.
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ def PathDependency(
|
|||||||
name = (
|
name = (
|
||||||
param_name
|
param_name
|
||||||
if param_name is not None
|
if param_name is not None
|
||||||
else "{}_{}".format(model.__name__.lower(), field.key)
|
else f"{model.__name__.lower()}_{field.key}"
|
||||||
)
|
)
|
||||||
python_type = field.type.python_type
|
python_type = field.type.python_type
|
||||||
|
|
||||||
@@ -70,22 +70,18 @@ def PathDependency(
|
|||||||
value = kwargs[name]
|
value = kwargs[name]
|
||||||
return await crud.get(session, filters=[field == value])
|
return await crud.get(session, filters=[field == value])
|
||||||
|
|
||||||
setattr(
|
dependency.__signature__ = inspect.Signature( # ty:ignore[unresolved-attribute]
|
||||||
dependency,
|
parameters=[
|
||||||
"__signature__",
|
inspect.Parameter(
|
||||||
inspect.Signature(
|
name, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
|
||||||
parameters=[
|
),
|
||||||
inspect.Parameter(
|
inspect.Parameter(
|
||||||
name, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
|
"session",
|
||||||
),
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
inspect.Parameter(
|
annotation=AsyncSession,
|
||||||
"session",
|
default=Depends(session_callable),
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
),
|
||||||
annotation=AsyncSession,
|
]
|
||||||
default=Depends(session_callable),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
|
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
|
||||||
@@ -134,22 +130,18 @@ def BodyDependency(
|
|||||||
value = kwargs[body_field]
|
value = kwargs[body_field]
|
||||||
return await crud.get(session, filters=[field == value])
|
return await crud.get(session, filters=[field == value])
|
||||||
|
|
||||||
setattr(
|
dependency.__signature__ = inspect.Signature( # ty:ignore[unresolved-attribute]
|
||||||
dependency,
|
parameters=[
|
||||||
"__signature__",
|
inspect.Parameter(
|
||||||
inspect.Signature(
|
body_field, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
|
||||||
parameters=[
|
),
|
||||||
inspect.Parameter(
|
inspect.Parameter(
|
||||||
body_field, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
|
"session",
|
||||||
),
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
inspect.Parameter(
|
annotation=AsyncSession,
|
||||||
"session",
|
default=Depends(session_callable),
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
),
|
||||||
annotation=AsyncSession,
|
]
|
||||||
default=Depends(session_callable),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
|
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ __all__ = [
|
|||||||
"ApiException",
|
"ApiException",
|
||||||
"ConflictError",
|
"ConflictError",
|
||||||
"ForbiddenError",
|
"ForbiddenError",
|
||||||
"generate_error_responses",
|
|
||||||
"init_exceptions_handlers",
|
|
||||||
"InvalidFacetFilterError",
|
"InvalidFacetFilterError",
|
||||||
"InvalidOrderFieldError",
|
"InvalidOrderFieldError",
|
||||||
"InvalidSearchColumnError",
|
"InvalidSearchColumnError",
|
||||||
@@ -34,4 +32,6 @@ __all__ = [
|
|||||||
"PoolExhaustedError",
|
"PoolExhaustedError",
|
||||||
"UnauthorizedError",
|
"UnauthorizedError",
|
||||||
"UnsupportedFacetTypeError",
|
"UnsupportedFacetTypeError",
|
||||||
|
"generate_error_responses",
|
||||||
|
"init_exceptions_handlers",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -146,35 +146,34 @@ def _patched_openapi(
|
|||||||
|
|
||||||
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():
|
||||||
if isinstance(operation, dict) and "responses" in operation:
|
if isinstance(operation, dict) and "422" in operation.get("responses", {}):
|
||||||
if "422" in operation["responses"]:
|
operation["responses"]["422"] = {
|
||||||
operation["responses"]["422"] = {
|
"description": "Validation Error",
|
||||||
"description": "Validation Error",
|
"content": {
|
||||||
"content": {
|
"application/json": {
|
||||||
"application/json": {
|
"examples": {
|
||||||
"examples": {
|
"VAL-422": {
|
||||||
"VAL-422": {
|
"summary": "Validation Error",
|
||||||
"summary": "Validation Error",
|
"value": {
|
||||||
"value": {
|
"data": {
|
||||||
"data": {
|
"errors": [
|
||||||
"errors": [
|
{
|
||||||
{
|
"field": "field_name",
|
||||||
"field": "field_name",
|
"message": "value is not valid",
|
||||||
"message": "value is not valid",
|
"type": "value_error",
|
||||||
"type": "value_error",
|
}
|
||||||
}
|
]
|
||||||
]
|
|
||||||
},
|
|
||||||
"status": ResponseStatus.FAIL.value,
|
|
||||||
"message": "Validation Error",
|
|
||||||
"description": "1 validation error(s) detected",
|
|
||||||
"error_code": "VAL-422",
|
|
||||||
},
|
},
|
||||||
}
|
"status": ResponseStatus.FAIL.value,
|
||||||
|
"message": "Validation Error",
|
||||||
|
"description": "1 validation error(s) detected",
|
||||||
|
"error_code": "VAL-422",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
}
|
},
|
||||||
|
}
|
||||||
|
|
||||||
app.openapi_schema = openapi_schema
|
app.openapi_schema = openapi_schema
|
||||||
return app.openapi_schema
|
return app.openapi_schema
|
||||||
|
|||||||
@@ -3,19 +3,19 @@
|
|||||||
from .columns import (
|
from .columns import (
|
||||||
CreatedAtMixin,
|
CreatedAtMixin,
|
||||||
TimestampMixin,
|
TimestampMixin,
|
||||||
|
UpdatedAtMixin,
|
||||||
UUIDMixin,
|
UUIDMixin,
|
||||||
UUIDv7Mixin,
|
UUIDv7Mixin,
|
||||||
UpdatedAtMixin,
|
|
||||||
)
|
)
|
||||||
from .watched import EventSession, ModelEvent, listens_for
|
from .watched import EventSession, ModelEvent, listens_for
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"CreatedAtMixin",
|
||||||
"EventSession",
|
"EventSession",
|
||||||
"ModelEvent",
|
"ModelEvent",
|
||||||
|
"TimestampMixin",
|
||||||
"UUIDMixin",
|
"UUIDMixin",
|
||||||
"UUIDv7Mixin",
|
"UUIDv7Mixin",
|
||||||
"CreatedAtMixin",
|
|
||||||
"UpdatedAtMixin",
|
"UpdatedAtMixin",
|
||||||
"TimestampMixin",
|
|
||||||
"listens_for",
|
"listens_for",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ async def _batch_reload(
|
|||||||
class EventSession(AsyncSession):
|
class EventSession(AsyncSession):
|
||||||
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
||||||
|
|
||||||
async def commit(self) -> None: # noqa: C901
|
async def commit(self) -> None:
|
||||||
await super().commit()
|
await super().commit()
|
||||||
|
|
||||||
creates: list[Any] = self.info.pop(_SESSION_CREATES, [])
|
creates: list[Any] = self.info.pop(_SESSION_CREATES, [])
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, Union
|
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
@@ -10,11 +10,11 @@ from .types import DataT
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ApiError",
|
"ApiError",
|
||||||
"CursorPagination",
|
|
||||||
"CursorPaginatedResponse",
|
"CursorPaginatedResponse",
|
||||||
|
"CursorPagination",
|
||||||
"ErrorResponse",
|
"ErrorResponse",
|
||||||
"OffsetPagination",
|
|
||||||
"OffsetPaginatedResponse",
|
"OffsetPaginatedResponse",
|
||||||
|
"OffsetPagination",
|
||||||
"PaginatedResponse",
|
"PaginatedResponse",
|
||||||
"PaginationType",
|
"PaginationType",
|
||||||
"PydanticBase",
|
"PydanticBase",
|
||||||
@@ -174,7 +174,7 @@ class PaginatedResponse(BaseResponse, Generic[DataT]):
|
|||||||
cached = cls._discriminated_union_cache.get(item)
|
cached = cls._discriminated_union_cache.get(item)
|
||||||
if cached is None:
|
if cached is None:
|
||||||
cached = Annotated[
|
cached = Annotated[
|
||||||
Union[CursorPaginatedResponse[item], OffsetPaginatedResponse[item]], # ty:ignore[invalid-type-form]
|
CursorPaginatedResponse[item] | OffsetPaginatedResponse[item], # ty:ignore[invalid-type-form]
|
||||||
Field(discriminator="pagination_type"),
|
Field(discriminator="pagination_type"),
|
||||||
]
|
]
|
||||||
cls._discriminated_union_cache[item] = cached
|
cls._discriminated_union_cache[item] = cached
|
||||||
|
|||||||
+6
-7
@@ -1,27 +1,28 @@
|
|||||||
"""Shared pytest fixtures for fastapi-utils tests."""
|
"""Shared pytest fixtures for fastapi-utils tests."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import decimal
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import datetime
|
|
||||||
import decimal
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
Column,
|
Column,
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum as SAEnum,
|
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
JSON,
|
|
||||||
Numeric,
|
Numeric,
|
||||||
String,
|
String,
|
||||||
Table,
|
Table,
|
||||||
Uuid,
|
Uuid,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy import (
|
||||||
|
Enum as SAEnum,
|
||||||
|
)
|
||||||
from sqlalchemy.dialects.postgresql import ARRAY
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
@@ -38,8 +39,6 @@ DATABASE_URL = os.getenv(
|
|||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
"""Base class for test models."""
|
"""Base class for test models."""
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Role(Base):
|
class Role(Base):
|
||||||
"""Test role model."""
|
"""Test role model."""
|
||||||
|
|||||||
@@ -554,7 +554,6 @@ class TestAsyncCommand:
|
|||||||
@async_command
|
@async_command
|
||||||
async def async_func() -> None:
|
async def async_func() -> None:
|
||||||
"""This is a docstring."""
|
"""This is a docstring."""
|
||||||
pass
|
|
||||||
|
|
||||||
assert async_func.__doc__ == """This is a docstring."""
|
assert async_func.__doc__ == """This is a docstring."""
|
||||||
|
|
||||||
|
|||||||
@@ -2261,7 +2261,7 @@ class TestOffsetPaginateParamsSchema:
|
|||||||
def test_dependency_name_includes_model_name(self):
|
def test_dependency_name_includes_model_name(self):
|
||||||
"""Dependency function is named after the model."""
|
"""Dependency function is named after the model."""
|
||||||
dep = RoleCrud.offset_paginate_params(search=False, filter=False, order=False)
|
dep = RoleCrud.offset_paginate_params(search=False, filter=False, order=False)
|
||||||
assert getattr(dep, "__name__") == "RoleOffsetPaginateParams"
|
assert dep.__name__ == "RoleOffsetPaginateParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||||
|
|
||||||
def test_default_page_size_reflected_in_items_per_page_default(self):
|
def test_default_page_size_reflected_in_items_per_page_default(self):
|
||||||
"""default_page_size is used as the default for items_per_page."""
|
"""default_page_size is used as the default for items_per_page."""
|
||||||
@@ -2391,7 +2391,7 @@ class TestCursorPaginateParamsSchema:
|
|||||||
dep = RoleCursorCrud.cursor_paginate_params(
|
dep = RoleCursorCrud.cursor_paginate_params(
|
||||||
search=False, filter=False, order=False
|
search=False, filter=False, order=False
|
||||||
)
|
)
|
||||||
assert getattr(dep, "__name__") == "RoleCursorPaginateParams"
|
assert dep.__name__ == "RoleCursorPaginateParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||||
|
|
||||||
def test_default_page_size_reflected_in_items_per_page_default(self):
|
def test_default_page_size_reflected_in_items_per_page_default(self):
|
||||||
"""default_page_size is used as the default for items_per_page."""
|
"""default_page_size is used as the default for items_per_page."""
|
||||||
@@ -2461,7 +2461,7 @@ class TestPaginateParamsSchema:
|
|||||||
def test_dependency_name_includes_model_name(self):
|
def test_dependency_name_includes_model_name(self):
|
||||||
"""Dependency function is named after the model."""
|
"""Dependency function is named after the model."""
|
||||||
dep = RoleCursorCrud.paginate_params(search=False, filter=False, order=False)
|
dep = RoleCursorCrud.paginate_params(search=False, filter=False, order=False)
|
||||||
assert getattr(dep, "__name__") == "RolePaginateParams"
|
assert dep.__name__ == "RolePaginateParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||||
|
|
||||||
def test_default_pagination_type(self):
|
def test_default_pagination_type(self):
|
||||||
"""default_pagination_type is reflected in pagination_type default."""
|
"""default_pagination_type is reflected in pagination_type default."""
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ async def seed(session: AsyncSession):
|
|||||||
session.add_all([python, backend])
|
session.add_all([python, backend])
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
now = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
|
now = datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC)
|
||||||
session.add_all(
|
session.add_all(
|
||||||
[
|
[
|
||||||
Article(
|
Article(
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ class TestDbExceptions:
|
|||||||
def test_pool_exhausted_handled_as_503(self):
|
def test_pool_exhausted_handled_as_503(self):
|
||||||
"""init_exceptions_handlers turns PoolExhaustedError into a 503 response."""
|
"""init_exceptions_handlers turns PoolExhaustedError into a 503 response."""
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -267,6 +268,7 @@ class TestDbExceptions:
|
|||||||
def test_lock_timeout_handled_as_503(self):
|
def test_lock_timeout_handled_as_503(self):
|
||||||
"""init_exceptions_handlers turns LockTimeoutError into a 503 response."""
|
"""init_exceptions_handlers turns LockTimeoutError into a 503 response."""
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class TestCustomEnumContext:
|
|||||||
"""Python prohibits extending an Enum that already has members."""
|
"""Python prohibits extending an Enum that already has members."""
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
|
|
||||||
class MyContext(Context): # noqa: F841 # ty: ignore[subclass-of-final-class]
|
class MyContext(Context): # ty: ignore[subclass-of-final-class]
|
||||||
STAGING = "staging"
|
STAGING = "staging"
|
||||||
|
|
||||||
def test_custom_enum_values_interchangeable_with_context(self):
|
def test_custom_enum_values_interchangeable_with_context(self):
|
||||||
|
|||||||
@@ -197,8 +197,8 @@ class TestCliImportGuard:
|
|||||||
importlib.import_module("fastapi_toolsets.cli.app")
|
importlib.import_module("fastapi_toolsets.cli.app")
|
||||||
finally:
|
finally:
|
||||||
for key in list(sys.modules):
|
for key in list(sys.modules):
|
||||||
if key.startswith("fastapi_toolsets.cli.app") or key.startswith(
|
if key.startswith(
|
||||||
"fastapi_toolsets.cli.config"
|
("fastapi_toolsets.cli.app", "fastapi_toolsets.cli.config")
|
||||||
):
|
):
|
||||||
sys.modules.pop(key, None)
|
sys.modules.pop(key, None)
|
||||||
sys.modules.update(saved)
|
sys.modules.update(saved)
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|||||||
|
|
||||||
from fastapi_toolsets.db import transaction
|
from fastapi_toolsets.db import transaction
|
||||||
from fastapi_toolsets.fixtures import Context, FixtureRegistry, LoadStrategy
|
from fastapi_toolsets.fixtures import Context, FixtureRegistry, LoadStrategy
|
||||||
|
from fastapi_toolsets.fixtures.utils import (
|
||||||
|
_get_primary_key,
|
||||||
|
_relationship_load_options,
|
||||||
|
_reload_with_relationships,
|
||||||
|
)
|
||||||
from fastapi_toolsets.pytest import (
|
from fastapi_toolsets.pytest import (
|
||||||
create_async_client,
|
create_async_client,
|
||||||
create_db_session,
|
create_db_session,
|
||||||
@@ -20,11 +25,6 @@ from fastapi_toolsets.pytest import (
|
|||||||
register_fixtures,
|
register_fixtures,
|
||||||
worker_database_url,
|
worker_database_url,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.fixtures.utils import (
|
|
||||||
_get_primary_key,
|
|
||||||
_relationship_load_options,
|
|
||||||
_reload_with_relationships,
|
|
||||||
)
|
|
||||||
from fastapi_toolsets.pytest.utils import _get_xdist_worker
|
from fastapi_toolsets.pytest.utils import _get_xdist_worker
|
||||||
|
|
||||||
from .conftest import (
|
from .conftest import (
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ from pydantic import ValidationError
|
|||||||
|
|
||||||
from fastapi_toolsets.schemas import (
|
from fastapi_toolsets.schemas import (
|
||||||
ApiError,
|
ApiError,
|
||||||
CursorPagination,
|
|
||||||
CursorPaginatedResponse,
|
CursorPaginatedResponse,
|
||||||
|
CursorPagination,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
OffsetPagination,
|
|
||||||
OffsetPaginatedResponse,
|
OffsetPaginatedResponse,
|
||||||
|
OffsetPagination,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
PaginationType,
|
PaginationType,
|
||||||
Response,
|
Response,
|
||||||
|
|||||||
Reference in New Issue
Block a user