fix: ruff warnings

This commit is contained in:
2026-07-28 13:55:31 +02:00
committed by d3vyce
parent a407677be0
commit fd83a7142d
36 changed files with 255 additions and 138 deletions
+10 -3
View File
@@ -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
View File
@@ -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)
+3 -1
View File
@@ -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
View File
@@ -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")
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+8 -2
View File
@@ -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")
+12 -3
View File
@@ -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
View File
@@ -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.
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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,
+5 -1
View File
@@ -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
+12
View File
@@ -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 = [
+1 -1
View File
@@ -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",
] ]
+4 -5
View File
@@ -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
) )
+1 -1
View File
@@ -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"
+2 -3
View File
@@ -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 -3
View File
@@ -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.
+25 -33
View File
@@ -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)))
+2 -2
View File
@@ -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",
] ]
+25 -26
View File
@@ -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 -3
View File
@@ -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",
] ]
+1 -1
View File
@@ -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, [])
+4 -4
View File
@@ -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
View File
@@ -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."""
-1
View File
@@ -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."""
+3 -3
View File
@@ -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."""
+1 -1
View File
@@ -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(
+2
View File
@@ -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()
+1 -1
View File
@@ -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):
+2 -2
View File
@@ -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)
+5 -5
View File
@@ -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 (
+2 -2
View File
@@ -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,