mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 06:36:26 +02:00
refactor: remove deprecated parameter and function/cleanup code (#101)
* refactor: remove deprecated parameter and function * refactor: centralize type aliases in types.py and simplify crud layer * test: add missing tests for fixtures/utils.py * refactor: simplify and deduplicate across crud, metrics, cli, and exceptions * docs: fix old Paginate references * docs: add migration + fix icon * docs: update README/migration to v2
This commit is contained in:
@@ -72,7 +72,7 @@ async def load(
|
||||
registry = get_fixtures_registry()
|
||||
db_context = get_db_context()
|
||||
|
||||
context_list = [c.value for c in contexts] if contexts else [Context.BASE]
|
||||
context_list = list(contexts) if contexts else [Context.BASE]
|
||||
|
||||
ordered = registry.resolve_context_dependencies(*context_list)
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""Generic async CRUD operations for SQLAlchemy models."""
|
||||
|
||||
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
||||
from .factory import CrudFactory, JoinType, M2MFieldType, OrderByClause
|
||||
from .search import (
|
||||
from ..types import (
|
||||
FacetFieldType,
|
||||
SearchConfig,
|
||||
get_searchable_fields,
|
||||
JoinType,
|
||||
M2MFieldType,
|
||||
OrderByClause,
|
||||
SearchFieldType,
|
||||
)
|
||||
from .factory import CrudFactory
|
||||
from .search import SearchConfig, get_searchable_fields
|
||||
|
||||
__all__ = [
|
||||
"CrudFactory",
|
||||
@@ -18,4 +21,5 @@ __all__ = [
|
||||
"NoSearchableFieldsError",
|
||||
"OrderByClause",
|
||||
"SearchConfig",
|
||||
"SearchFieldType",
|
||||
]
|
||||
|
||||
@@ -6,11 +6,10 @@ import base64
|
||||
import inspect
|
||||
import json
|
||||
import uuid as uuid_module
|
||||
import warnings
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, ClassVar, Generic, Literal, Self, TypeVar, cast, overload
|
||||
from typing import Any, ClassVar, Generic, Literal, Self, cast, overload
|
||||
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel
|
||||
@@ -21,28 +20,28 @@ from sqlalchemy.exc import NoResultFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
||||
from sqlalchemy.sql.base import ExecutableOption
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.roles import WhereHavingRole
|
||||
|
||||
from ..db import get_transaction
|
||||
from ..exceptions import InvalidOrderFieldError, NotFoundError
|
||||
from ..schemas import CursorPagination, OffsetPagination, PaginatedResponse, Response
|
||||
from .search import (
|
||||
from ..types import (
|
||||
FacetFieldType,
|
||||
SearchConfig,
|
||||
JoinType,
|
||||
M2MFieldType,
|
||||
ModelType,
|
||||
OrderByClause,
|
||||
SchemaType,
|
||||
SearchFieldType,
|
||||
)
|
||||
from .search import (
|
||||
SearchConfig,
|
||||
build_facets,
|
||||
build_filter_by,
|
||||
build_search_filters,
|
||||
facet_keys,
|
||||
)
|
||||
|
||||
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
||||
SchemaType = TypeVar("SchemaType", bound=BaseModel)
|
||||
JoinType = list[tuple[type[DeclarativeBase], Any]]
|
||||
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
||||
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
||||
|
||||
|
||||
def _encode_cursor(value: Any) -> str:
|
||||
"""Encode cursor column value as an base64 string."""
|
||||
@@ -54,6 +53,22 @@ def _decode_cursor(cursor: str) -> str:
|
||||
return json.loads(base64.b64decode(cursor.encode()).decode())
|
||||
|
||||
|
||||
def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
|
||||
"""Apply a list of (model, condition) joins to a SQLAlchemy select query."""
|
||||
if not joins:
|
||||
return q
|
||||
for model, condition in joins:
|
||||
q = q.outerjoin(model, condition) if outer_join else q.join(model, condition)
|
||||
return q
|
||||
|
||||
|
||||
def _apply_search_joins(q: Any, search_joins: list[Any]) -> Any:
|
||||
"""Apply relationship-based outer joins (from search/filter_by) to a query."""
|
||||
for join_rel in search_joins:
|
||||
q = q.outerjoin(join_rel)
|
||||
return q
|
||||
|
||||
|
||||
class AsyncCrud(Generic[ModelType]):
|
||||
"""Generic async CRUD operations for SQLAlchemy models.
|
||||
|
||||
@@ -133,6 +148,48 @@ class AsyncCrud(Generic[ModelType]):
|
||||
return set()
|
||||
return set(cls.m2m_fields.keys())
|
||||
|
||||
@classmethod
|
||||
def _resolve_facet_fields(
|
||||
cls: type[Self],
|
||||
facet_fields: Sequence[FacetFieldType] | None,
|
||||
) -> Sequence[FacetFieldType] | None:
|
||||
"""Return facet_fields if given, otherwise fall back to the class-level default."""
|
||||
return facet_fields if facet_fields is not None else cls.facet_fields
|
||||
|
||||
@classmethod
|
||||
def _prepare_filter_by(
|
||||
cls: type[Self],
|
||||
filter_by: dict[str, Any] | BaseModel | None,
|
||||
facet_fields: Sequence[FacetFieldType] | None,
|
||||
) -> tuple[list[Any], list[Any]]:
|
||||
"""Normalize filter_by and return (filters, joins) to apply to the query."""
|
||||
if isinstance(filter_by, BaseModel):
|
||||
filter_by = filter_by.model_dump(exclude_none=True)
|
||||
if not filter_by:
|
||||
return [], []
|
||||
resolved = cls._resolve_facet_fields(facet_fields)
|
||||
return build_filter_by(filter_by, resolved or [])
|
||||
|
||||
@classmethod
|
||||
async def _build_filter_attributes(
|
||||
cls: type[Self],
|
||||
session: AsyncSession,
|
||||
facet_fields: Sequence[FacetFieldType] | None,
|
||||
filters: list[Any],
|
||||
search_joins: list[Any],
|
||||
) -> dict[str, list[Any]] | None:
|
||||
"""Build facet filter_attributes, or return None if no facet fields configured."""
|
||||
resolved = cls._resolve_facet_fields(facet_fields)
|
||||
if not resolved:
|
||||
return None
|
||||
return await build_facets(
|
||||
session,
|
||||
cls.model,
|
||||
resolved,
|
||||
base_filters=filters,
|
||||
base_joins=search_joins,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def filter_params(
|
||||
cls: type[Self],
|
||||
@@ -153,7 +210,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
ValueError: If no facet fields are configured on this CRUD class and none are
|
||||
provided via ``facet_fields``.
|
||||
"""
|
||||
fields = facet_fields if facet_fields is not None else cls.facet_fields
|
||||
fields = cls._resolve_facet_fields(facet_fields)
|
||||
if not fields:
|
||||
raise ValueError(
|
||||
f"{cls.__name__} has no facet_fields configured. "
|
||||
@@ -244,10 +301,8 @@ class AsyncCrud(Generic[ModelType]):
|
||||
obj: BaseModel,
|
||||
*,
|
||||
schema: type[SchemaType],
|
||||
as_response: bool = ...,
|
||||
) -> Response[SchemaType]: ...
|
||||
|
||||
# Backward-compatible - will be removed in v2.0
|
||||
@overload
|
||||
@classmethod
|
||||
async def create( # pragma: no cover
|
||||
@@ -255,18 +310,6 @@ class AsyncCrud(Generic[ModelType]):
|
||||
session: AsyncSession,
|
||||
obj: BaseModel,
|
||||
*,
|
||||
as_response: Literal[True],
|
||||
schema: None = ...,
|
||||
) -> Response[ModelType]: ...
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
async def create( # pragma: no cover
|
||||
cls: type[Self],
|
||||
session: AsyncSession,
|
||||
obj: BaseModel,
|
||||
*,
|
||||
as_response: Literal[False] = ...,
|
||||
schema: None = ...,
|
||||
) -> ModelType: ...
|
||||
|
||||
@@ -276,29 +319,19 @@ class AsyncCrud(Generic[ModelType]):
|
||||
session: AsyncSession,
|
||||
obj: BaseModel,
|
||||
*,
|
||||
as_response: bool = False,
|
||||
schema: type[BaseModel] | None = None,
|
||||
) -> ModelType | Response[ModelType] | Response[Any]:
|
||||
) -> ModelType | Response[Any]:
|
||||
"""Create a new record in the database.
|
||||
|
||||
Args:
|
||||
session: DB async session
|
||||
obj: Pydantic model with data to create
|
||||
as_response: Deprecated. Use ``schema`` instead. Will be removed in v2.0.
|
||||
schema: Pydantic schema to serialize the result into. When provided,
|
||||
the result is automatically wrapped in a ``Response[schema]``.
|
||||
|
||||
Returns:
|
||||
Created model instance, or ``Response[schema]`` when ``schema`` is given,
|
||||
or ``Response[ModelType]`` when ``as_response=True`` (deprecated).
|
||||
Created model instance, or ``Response[schema]`` when ``schema`` is given.
|
||||
"""
|
||||
if as_response and schema is None:
|
||||
warnings.warn(
|
||||
"as_response is deprecated and will be removed in v2.0. "
|
||||
"Use schema=YourSchema instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
async with get_transaction(session):
|
||||
m2m_exclude = cls._m2m_schema_fields()
|
||||
data = (
|
||||
@@ -314,9 +347,8 @@ class AsyncCrud(Generic[ModelType]):
|
||||
session.add(db_model)
|
||||
await session.refresh(db_model)
|
||||
result = cast(ModelType, db_model)
|
||||
if as_response or schema:
|
||||
data_out = schema.model_validate(result) if schema else result
|
||||
return Response(data=data_out)
|
||||
if schema:
|
||||
return Response(data=schema.model_validate(result))
|
||||
return result
|
||||
|
||||
@overload
|
||||
@@ -331,10 +363,8 @@ class AsyncCrud(Generic[ModelType]):
|
||||
with_for_update: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
schema: type[SchemaType],
|
||||
as_response: bool = ...,
|
||||
) -> Response[SchemaType]: ...
|
||||
|
||||
# Backward-compatible - will be removed in v2.0
|
||||
@overload
|
||||
@classmethod
|
||||
async def get( # pragma: no cover
|
||||
@@ -346,22 +376,6 @@ class AsyncCrud(Generic[ModelType]):
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
as_response: Literal[True],
|
||||
schema: None = ...,
|
||||
) -> Response[ModelType]: ...
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
async def get( # pragma: no cover
|
||||
cls: type[Self],
|
||||
session: AsyncSession,
|
||||
filters: list[Any],
|
||||
*,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
as_response: Literal[False] = ...,
|
||||
schema: None = ...,
|
||||
) -> ModelType: ...
|
||||
|
||||
@@ -375,9 +389,8 @@ class AsyncCrud(Generic[ModelType]):
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
as_response: bool = False,
|
||||
schema: type[BaseModel] | None = None,
|
||||
) -> ModelType | Response[ModelType] | Response[Any]:
|
||||
) -> ModelType | Response[Any]:
|
||||
"""Get exactly one record. Raises NotFoundError if not found.
|
||||
|
||||
Args:
|
||||
@@ -387,33 +400,18 @@ class AsyncCrud(Generic[ModelType]):
|
||||
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
|
||||
with_for_update: Lock the row for update
|
||||
load_options: SQLAlchemy loader options (e.g., selectinload)
|
||||
as_response: Deprecated. Use ``schema`` instead. Will be removed in v2.0.
|
||||
schema: Pydantic schema to serialize the result into. When provided,
|
||||
the result is automatically wrapped in a ``Response[schema]``.
|
||||
|
||||
Returns:
|
||||
Model instance, or ``Response[schema]`` when ``schema`` is given,
|
||||
or ``Response[ModelType]`` when ``as_response=True`` (deprecated).
|
||||
Model instance, or ``Response[schema]`` when ``schema`` is given.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If no record found
|
||||
MultipleResultsFound: If more than one record found
|
||||
"""
|
||||
if as_response and schema is None:
|
||||
warnings.warn(
|
||||
"as_response is deprecated and will be removed in v2.0. "
|
||||
"Use schema=YourSchema instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
q = select(cls.model)
|
||||
if joins:
|
||||
for model, condition in joins:
|
||||
q = (
|
||||
q.outerjoin(model, condition)
|
||||
if outer_join
|
||||
else q.join(model, condition)
|
||||
)
|
||||
q = _apply_joins(q, joins, outer_join)
|
||||
q = q.where(and_(*filters))
|
||||
if resolved := cls._resolve_load_options(load_options):
|
||||
q = q.options(*resolved)
|
||||
@@ -424,9 +422,8 @@ class AsyncCrud(Generic[ModelType]):
|
||||
if not item:
|
||||
raise NotFoundError()
|
||||
result = cast(ModelType, item)
|
||||
if as_response or schema:
|
||||
data_out = schema.model_validate(result) if schema else result
|
||||
return Response(data=data_out)
|
||||
if schema:
|
||||
return Response(data=schema.model_validate(result))
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
@@ -452,13 +449,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
Model instance or None
|
||||
"""
|
||||
q = select(cls.model)
|
||||
if joins:
|
||||
for model, condition in joins:
|
||||
q = (
|
||||
q.outerjoin(model, condition)
|
||||
if outer_join
|
||||
else q.join(model, condition)
|
||||
)
|
||||
q = _apply_joins(q, joins, outer_join)
|
||||
if filters:
|
||||
q = q.where(and_(*filters))
|
||||
if resolved := cls._resolve_load_options(load_options):
|
||||
@@ -495,13 +486,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
List of model instances
|
||||
"""
|
||||
q = select(cls.model)
|
||||
if joins:
|
||||
for model, condition in joins:
|
||||
q = (
|
||||
q.outerjoin(model, condition)
|
||||
if outer_join
|
||||
else q.join(model, condition)
|
||||
)
|
||||
q = _apply_joins(q, joins, outer_join)
|
||||
if filters:
|
||||
q = q.where(and_(*filters))
|
||||
if resolved := cls._resolve_load_options(load_options):
|
||||
@@ -526,10 +511,8 @@ class AsyncCrud(Generic[ModelType]):
|
||||
exclude_unset: bool = True,
|
||||
exclude_none: bool = False,
|
||||
schema: type[SchemaType],
|
||||
as_response: bool = ...,
|
||||
) -> Response[SchemaType]: ...
|
||||
|
||||
# Backward-compatible - will be removed in v2.0
|
||||
@overload
|
||||
@classmethod
|
||||
async def update( # pragma: no cover
|
||||
@@ -540,21 +523,6 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
exclude_unset: bool = True,
|
||||
exclude_none: bool = False,
|
||||
as_response: Literal[True],
|
||||
schema: None = ...,
|
||||
) -> Response[ModelType]: ...
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
async def update( # pragma: no cover
|
||||
cls: type[Self],
|
||||
session: AsyncSession,
|
||||
obj: BaseModel,
|
||||
filters: list[Any],
|
||||
*,
|
||||
exclude_unset: bool = True,
|
||||
exclude_none: bool = False,
|
||||
as_response: Literal[False] = ...,
|
||||
schema: None = ...,
|
||||
) -> ModelType: ...
|
||||
|
||||
@@ -567,9 +535,8 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
exclude_unset: bool = True,
|
||||
exclude_none: bool = False,
|
||||
as_response: bool = False,
|
||||
schema: type[BaseModel] | None = None,
|
||||
) -> ModelType | Response[ModelType] | Response[Any]:
|
||||
) -> ModelType | Response[Any]:
|
||||
"""Update a record in the database.
|
||||
|
||||
Args:
|
||||
@@ -578,24 +545,15 @@ class AsyncCrud(Generic[ModelType]):
|
||||
filters: List of SQLAlchemy filter conditions
|
||||
exclude_unset: Exclude fields not explicitly set in the schema
|
||||
exclude_none: Exclude fields with None value
|
||||
as_response: Deprecated. Use ``schema`` instead. Will be removed in v2.0.
|
||||
schema: Pydantic schema to serialize the result into. When provided,
|
||||
the result is automatically wrapped in a ``Response[schema]``.
|
||||
|
||||
Returns:
|
||||
Updated model instance, or ``Response[schema]`` when ``schema`` is given,
|
||||
or ``Response[ModelType]`` when ``as_response=True`` (deprecated).
|
||||
Updated model instance, or ``Response[schema]`` when ``schema`` is given.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If no record found
|
||||
"""
|
||||
if as_response and schema is None:
|
||||
warnings.warn(
|
||||
"as_response is deprecated and will be removed in v2.0. "
|
||||
"Use schema=YourSchema instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
async with get_transaction(session):
|
||||
m2m_exclude = cls._m2m_schema_fields()
|
||||
|
||||
@@ -625,9 +583,8 @@ class AsyncCrud(Generic[ModelType]):
|
||||
for rel_attr, related_instances in m2m_resolved.items():
|
||||
setattr(db_model, rel_attr, related_instances)
|
||||
await session.refresh(db_model)
|
||||
if as_response or schema:
|
||||
data_out = schema.model_validate(db_model) if schema else db_model
|
||||
return Response(data=data_out)
|
||||
if schema:
|
||||
return Response(data=schema.model_validate(db_model))
|
||||
return db_model
|
||||
|
||||
@classmethod
|
||||
@@ -683,7 +640,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
session: AsyncSession,
|
||||
filters: list[Any],
|
||||
*,
|
||||
as_response: Literal[True],
|
||||
return_response: Literal[True],
|
||||
) -> Response[None]: ...
|
||||
|
||||
@overload
|
||||
@@ -693,8 +650,8 @@ class AsyncCrud(Generic[ModelType]):
|
||||
session: AsyncSession,
|
||||
filters: list[Any],
|
||||
*,
|
||||
as_response: Literal[False] = ...,
|
||||
) -> bool: ...
|
||||
return_response: Literal[False] = ...,
|
||||
) -> None: ...
|
||||
|
||||
@classmethod
|
||||
async def delete(
|
||||
@@ -702,33 +659,26 @@ class AsyncCrud(Generic[ModelType]):
|
||||
session: AsyncSession,
|
||||
filters: list[Any],
|
||||
*,
|
||||
as_response: bool = False,
|
||||
) -> bool | Response[None]:
|
||||
return_response: bool = False,
|
||||
) -> None | Response[None]:
|
||||
"""Delete records from the database.
|
||||
|
||||
Args:
|
||||
session: DB async session
|
||||
filters: List of SQLAlchemy filter conditions
|
||||
as_response: Deprecated. Will be removed in v2.0. When ``True``,
|
||||
returns ``Response[None]`` instead of ``bool``.
|
||||
return_response: When ``True``, returns ``Response[None]`` instead
|
||||
of ``None``. Useful for API endpoints that expect a consistent
|
||||
response envelope.
|
||||
|
||||
Returns:
|
||||
``True`` if deletion was executed, or ``Response[None]`` when
|
||||
``as_response=True`` (deprecated).
|
||||
``None``, or ``Response[None]`` when ``return_response=True``.
|
||||
"""
|
||||
if as_response:
|
||||
warnings.warn(
|
||||
"as_response is deprecated and will be removed in v2.0. "
|
||||
"Use schema=YourSchema instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
async with get_transaction(session):
|
||||
q = sql_delete(cls.model).where(and_(*filters))
|
||||
await session.execute(q)
|
||||
if as_response:
|
||||
if return_response:
|
||||
return Response(data=None)
|
||||
return True
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def count(
|
||||
@@ -751,13 +701,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
Number of matching records
|
||||
"""
|
||||
q = select(func.count()).select_from(cls.model)
|
||||
if joins:
|
||||
for model, condition in joins:
|
||||
q = (
|
||||
q.outerjoin(model, condition)
|
||||
if outer_join
|
||||
else q.join(model, condition)
|
||||
)
|
||||
q = _apply_joins(q, joins, outer_join)
|
||||
if filters:
|
||||
q = q.where(and_(*filters))
|
||||
result = await session.execute(q)
|
||||
@@ -784,58 +728,11 @@ class AsyncCrud(Generic[ModelType]):
|
||||
True if at least one record matches
|
||||
"""
|
||||
q = select(cls.model)
|
||||
if joins:
|
||||
for model, condition in joins:
|
||||
q = (
|
||||
q.outerjoin(model, condition)
|
||||
if outer_join
|
||||
else q.join(model, condition)
|
||||
)
|
||||
q = _apply_joins(q, joins, outer_join)
|
||||
q = q.where(and_(*filters)).exists().select()
|
||||
result = await session.execute(q)
|
||||
return bool(result.scalar())
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
async def offset_paginate( # pragma: no cover
|
||||
cls: type[Self],
|
||||
session: AsyncSession,
|
||||
*,
|
||||
filters: list[Any] | None = None,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
page: int = 1,
|
||||
items_per_page: int = 20,
|
||||
search: str | SearchConfig | None = None,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||
schema: type[SchemaType],
|
||||
) -> PaginatedResponse[SchemaType]: ...
|
||||
|
||||
# Backward-compatible - will be removed in v2.0
|
||||
@overload
|
||||
@classmethod
|
||||
async def offset_paginate( # pragma: no cover
|
||||
cls: type[Self],
|
||||
session: AsyncSession,
|
||||
*,
|
||||
filters: list[Any] | None = None,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
page: int = 1,
|
||||
items_per_page: int = 20,
|
||||
search: str | SearchConfig | None = None,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||
schema: None = ...,
|
||||
) -> PaginatedResponse[ModelType]: ...
|
||||
|
||||
@classmethod
|
||||
async def offset_paginate(
|
||||
cls: type[Self],
|
||||
@@ -852,8 +749,8 @@ class AsyncCrud(Generic[ModelType]):
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||
schema: type[BaseModel] | None = None,
|
||||
) -> PaginatedResponse[ModelType] | PaginatedResponse[Any]:
|
||||
schema: type[BaseModel],
|
||||
) -> PaginatedResponse[Any]:
|
||||
"""Get paginated results using offset-based pagination.
|
||||
|
||||
Args:
|
||||
@@ -871,54 +768,36 @@ class AsyncCrud(Generic[ModelType]):
|
||||
filter_by: Dict of {column_key: value} to filter by declared facet fields.
|
||||
Keys must match the column.key of a facet field. Scalar → equality,
|
||||
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
||||
schema: Optional Pydantic schema to serialize each item into.
|
||||
schema: Pydantic schema to serialize each item into.
|
||||
|
||||
Returns:
|
||||
PaginatedResponse with OffsetPagination metadata
|
||||
"""
|
||||
filters = list(filters) if filters else []
|
||||
offset = (page - 1) * items_per_page
|
||||
search_joins: list[Any] = []
|
||||
|
||||
if isinstance(filter_by, BaseModel):
|
||||
filter_by = filter_by.model_dump(exclude_none=True) or None
|
||||
|
||||
# Build filter_by conditions from declared facet fields
|
||||
if filter_by:
|
||||
resolved_facets_for_filter = (
|
||||
facet_fields if facet_fields is not None else cls.facet_fields
|
||||
)
|
||||
fb_filters, fb_joins = build_filter_by(
|
||||
filter_by, resolved_facets_for_filter or []
|
||||
)
|
||||
filters.extend(fb_filters)
|
||||
search_joins.extend(fb_joins)
|
||||
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
||||
filters.extend(fb_filters)
|
||||
|
||||
# Build search filters
|
||||
if search:
|
||||
search_filters, search_joins = build_search_filters(
|
||||
search_filters, new_search_joins = build_search_filters(
|
||||
cls.model,
|
||||
search,
|
||||
search_fields=search_fields,
|
||||
default_fields=cls.searchable_fields,
|
||||
)
|
||||
filters.extend(search_filters)
|
||||
search_joins.extend(new_search_joins)
|
||||
|
||||
# Build query with joins
|
||||
q = select(cls.model)
|
||||
|
||||
# Apply explicit joins
|
||||
if joins:
|
||||
for model, condition in joins:
|
||||
q = (
|
||||
q.outerjoin(model, condition)
|
||||
if outer_join
|
||||
else q.join(model, condition)
|
||||
)
|
||||
q = _apply_joins(q, joins, outer_join)
|
||||
|
||||
# Apply search joins (always outer joins for search)
|
||||
for join_rel in search_joins:
|
||||
q = q.outerjoin(join_rel)
|
||||
q = _apply_search_joins(q, search_joins)
|
||||
|
||||
if filters:
|
||||
q = q.where(and_(*filters))
|
||||
@@ -930,9 +809,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
q = q.offset(offset).limit(items_per_page)
|
||||
result = await session.execute(q)
|
||||
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
||||
items: list[Any] = (
|
||||
[schema.model_validate(item) for item in raw_items] if schema else raw_items
|
||||
)
|
||||
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
||||
|
||||
# Count query (with same joins and filters)
|
||||
pk_col = cls.model.__mapper__.primary_key[0]
|
||||
@@ -940,17 +817,10 @@ class AsyncCrud(Generic[ModelType]):
|
||||
count_q = count_q.select_from(cls.model)
|
||||
|
||||
# Apply explicit joins to count query
|
||||
if joins:
|
||||
for model, condition in joins:
|
||||
count_q = (
|
||||
count_q.outerjoin(model, condition)
|
||||
if outer_join
|
||||
else count_q.join(model, condition)
|
||||
)
|
||||
count_q = _apply_joins(count_q, joins, outer_join)
|
||||
|
||||
# Apply search joins to count query
|
||||
for join_rel in search_joins:
|
||||
count_q = count_q.outerjoin(join_rel)
|
||||
count_q = _apply_search_joins(count_q, search_joins)
|
||||
|
||||
if filters:
|
||||
count_q = count_q.where(and_(*filters))
|
||||
@@ -958,19 +828,9 @@ class AsyncCrud(Generic[ModelType]):
|
||||
count_result = await session.execute(count_q)
|
||||
total_count = count_result.scalar_one()
|
||||
|
||||
# Build facets
|
||||
resolved_facet_fields = (
|
||||
facet_fields if facet_fields is not None else cls.facet_fields
|
||||
filter_attributes = await cls._build_filter_attributes(
|
||||
session, facet_fields, filters, search_joins
|
||||
)
|
||||
filter_attributes: dict[str, list[Any]] | None = None
|
||||
if resolved_facet_fields:
|
||||
filter_attributes = await build_facets(
|
||||
session,
|
||||
cls.model,
|
||||
resolved_facet_fields,
|
||||
base_filters=filters or None,
|
||||
base_joins=search_joins or None,
|
||||
)
|
||||
|
||||
return PaginatedResponse(
|
||||
data=items,
|
||||
@@ -983,50 +843,6 @@ class AsyncCrud(Generic[ModelType]):
|
||||
filter_attributes=filter_attributes,
|
||||
)
|
||||
|
||||
# Backward-compatible - will be removed in v2.0
|
||||
paginate = offset_paginate
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
async def cursor_paginate( # pragma: no cover
|
||||
cls: type[Self],
|
||||
session: AsyncSession,
|
||||
*,
|
||||
cursor: str | None = None,
|
||||
filters: list[Any] | None = None,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
items_per_page: int = 20,
|
||||
search: str | SearchConfig | None = None,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||
schema: type[SchemaType],
|
||||
) -> PaginatedResponse[SchemaType]: ...
|
||||
|
||||
# Backward-compatible - will be removed in v2.0
|
||||
@overload
|
||||
@classmethod
|
||||
async def cursor_paginate( # pragma: no cover
|
||||
cls: type[Self],
|
||||
session: AsyncSession,
|
||||
*,
|
||||
cursor: str | None = None,
|
||||
filters: list[Any] | None = None,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
items_per_page: int = 20,
|
||||
search: str | SearchConfig | None = None,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||
schema: None = ...,
|
||||
) -> PaginatedResponse[ModelType]: ...
|
||||
|
||||
@classmethod
|
||||
async def cursor_paginate(
|
||||
cls: type[Self],
|
||||
@@ -1043,8 +859,8 @@ class AsyncCrud(Generic[ModelType]):
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||
schema: type[BaseModel] | None = None,
|
||||
) -> PaginatedResponse[ModelType] | PaginatedResponse[Any]:
|
||||
schema: type[BaseModel],
|
||||
) -> PaginatedResponse[Any]:
|
||||
"""Get paginated results using cursor-based pagination.
|
||||
|
||||
Args:
|
||||
@@ -1071,21 +887,9 @@ class AsyncCrud(Generic[ModelType]):
|
||||
PaginatedResponse with CursorPagination metadata
|
||||
"""
|
||||
filters = list(filters) if filters else []
|
||||
search_joins: list[Any] = []
|
||||
|
||||
if isinstance(filter_by, BaseModel):
|
||||
filter_by = filter_by.model_dump(exclude_none=True) or None
|
||||
|
||||
# Build filter_by conditions from declared facet fields
|
||||
if filter_by:
|
||||
resolved_facets_for_filter = (
|
||||
facet_fields if facet_fields is not None else cls.facet_fields
|
||||
)
|
||||
fb_filters, fb_joins = build_filter_by(
|
||||
filter_by, resolved_facets_for_filter or []
|
||||
)
|
||||
filters.extend(fb_filters)
|
||||
search_joins.extend(fb_joins)
|
||||
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
||||
filters.extend(fb_filters)
|
||||
|
||||
if cls.cursor_column is None:
|
||||
raise ValueError(
|
||||
@@ -1118,29 +922,23 @@ class AsyncCrud(Generic[ModelType]):
|
||||
|
||||
# Build search filters
|
||||
if search:
|
||||
search_filters, search_joins = build_search_filters(
|
||||
search_filters, new_search_joins = build_search_filters(
|
||||
cls.model,
|
||||
search,
|
||||
search_fields=search_fields,
|
||||
default_fields=cls.searchable_fields,
|
||||
)
|
||||
filters.extend(search_filters)
|
||||
search_joins.extend(new_search_joins)
|
||||
|
||||
# Build query
|
||||
q = select(cls.model)
|
||||
|
||||
# Apply explicit joins
|
||||
if joins:
|
||||
for model, condition in joins:
|
||||
q = (
|
||||
q.outerjoin(model, condition)
|
||||
if outer_join
|
||||
else q.join(model, condition)
|
||||
)
|
||||
q = _apply_joins(q, joins, outer_join)
|
||||
|
||||
# Apply search joins (always outer joins)
|
||||
for join_rel in search_joins:
|
||||
q = q.outerjoin(join_rel)
|
||||
q = _apply_search_joins(q, search_joins)
|
||||
|
||||
if filters:
|
||||
q = q.where(and_(*filters))
|
||||
@@ -1170,25 +968,11 @@ class AsyncCrud(Generic[ModelType]):
|
||||
if cursor is not None and items_page:
|
||||
prev_cursor = _encode_cursor(getattr(items_page[0], cursor_col_name))
|
||||
|
||||
items: list[Any] = (
|
||||
[schema.model_validate(item) for item in items_page]
|
||||
if schema
|
||||
else items_page
|
||||
)
|
||||
items: list[Any] = [schema.model_validate(item) for item in items_page]
|
||||
|
||||
# Build facets
|
||||
resolved_facet_fields = (
|
||||
facet_fields if facet_fields is not None else cls.facet_fields
|
||||
filter_attributes = await cls._build_filter_attributes(
|
||||
session, facet_fields, filters, search_joins
|
||||
)
|
||||
filter_attributes: dict[str, list[Any]] | None = None
|
||||
if resolved_facet_fields:
|
||||
filter_attributes = await build_facets(
|
||||
session,
|
||||
cls.model,
|
||||
resolved_facet_fields,
|
||||
base_filters=filters or None,
|
||||
base_joins=search_joins or None,
|
||||
)
|
||||
|
||||
return PaginatedResponse(
|
||||
data=items,
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
"""Search utilities for AsyncCrud."""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
from collections import Counter
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from sqlalchemy import String, or_, select
|
||||
from sqlalchemy import String, and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
||||
from ..types import FacetFieldType, SearchFieldType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
||||
FacetFieldType = SearchFieldType
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchConfig:
|
||||
@@ -37,6 +36,7 @@ class SearchConfig:
|
||||
match_mode: Literal["any", "all"] = "any"
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=128)
|
||||
def get_searchable_fields(
|
||||
model: type[DeclarativeBase],
|
||||
*,
|
||||
@@ -101,14 +101,11 @@ def build_search_filters(
|
||||
if isinstance(search, str):
|
||||
config = SearchConfig(query=search, fields=search_fields)
|
||||
else:
|
||||
config = search
|
||||
if search_fields is not None:
|
||||
config = SearchConfig(
|
||||
query=config.query,
|
||||
fields=search_fields,
|
||||
case_sensitive=config.case_sensitive,
|
||||
match_mode=config.match_mode,
|
||||
)
|
||||
config = (
|
||||
replace(search, fields=search_fields)
|
||||
if search_fields is not None
|
||||
else search
|
||||
)
|
||||
|
||||
if not config.query or not config.query.strip():
|
||||
return [], []
|
||||
@@ -227,8 +224,6 @@ async def build_facets(
|
||||
q = q.outerjoin(rel)
|
||||
|
||||
if base_filters:
|
||||
from sqlalchemy import and_
|
||||
|
||||
q = q.where(and_(*base_filters))
|
||||
|
||||
q = q.order_by(column)
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
"""Dependency factories for FastAPI routes."""
|
||||
|
||||
import inspect
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from typing import Any, TypeVar, cast
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from .crud import CrudFactory
|
||||
from .types import ModelType, SessionDependency
|
||||
|
||||
__all__ = ["BodyDependency", "PathDependency"]
|
||||
|
||||
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
||||
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]]
|
||||
|
||||
|
||||
def PathDependency(
|
||||
model: type[ModelType],
|
||||
|
||||
@@ -14,6 +14,10 @@ from fastapi.responses import JSONResponse
|
||||
from ..schemas import ErrorResponse, ResponseStatus
|
||||
from .exceptions import ApiException
|
||||
|
||||
_VALIDATION_LOCATION_PARAMS: frozenset[str] = frozenset(
|
||||
{"body", "query", "path", "header", "cookie"}
|
||||
)
|
||||
|
||||
|
||||
def init_exceptions_handlers(app: FastAPI) -> FastAPI:
|
||||
"""Register exception handlers and custom OpenAPI schema on a FastAPI app.
|
||||
@@ -99,7 +103,7 @@ def _format_validation_error(
|
||||
|
||||
for error in errors:
|
||||
locs = error["loc"]
|
||||
if locs and locs[0] in ("body", "query", "path", "header", "cookie"):
|
||||
if locs and locs[0] in _VALIDATION_LOCATION_PARAMS:
|
||||
locs = locs[1:]
|
||||
field_path = ".".join(str(loc) for loc in locs)
|
||||
formatted_errors.append(
|
||||
|
||||
@@ -1,24 +1,84 @@
|
||||
"""Fixture loading utilities for database seeding."""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, TypeVar
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from ..db import get_transaction
|
||||
from ..logger import get_logger
|
||||
from ..types import ModelType
|
||||
from .enum import LoadStrategy
|
||||
from .registry import Context, FixtureRegistry
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
T = TypeVar("T", bound=DeclarativeBase)
|
||||
|
||||
async def _load_ordered(
|
||||
session: AsyncSession,
|
||||
registry: FixtureRegistry,
|
||||
ordered_names: list[str],
|
||||
strategy: LoadStrategy,
|
||||
) -> dict[str, list[DeclarativeBase]]:
|
||||
"""Load fixtures in order."""
|
||||
results: dict[str, list[DeclarativeBase]] = {}
|
||||
|
||||
for name in ordered_names:
|
||||
fixture = registry.get(name)
|
||||
instances = list(fixture.func())
|
||||
|
||||
if not instances:
|
||||
results[name] = []
|
||||
continue
|
||||
|
||||
model_name = type(instances[0]).__name__
|
||||
loaded: list[DeclarativeBase] = []
|
||||
|
||||
async with get_transaction(session):
|
||||
for instance in instances:
|
||||
if strategy == LoadStrategy.INSERT:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
|
||||
elif strategy == LoadStrategy.MERGE:
|
||||
merged = await session.merge(instance)
|
||||
loaded.append(merged)
|
||||
|
||||
else: # LoadStrategy.SKIP_EXISTING
|
||||
pk = _get_primary_key(instance)
|
||||
if pk is not None:
|
||||
existing = await session.get(type(instance), pk)
|
||||
if existing is None:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
else:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
|
||||
results[name] = loaded
|
||||
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
||||
"""Get the primary key value of a model instance."""
|
||||
mapper = instance.__class__.__mapper__
|
||||
pk_cols = mapper.primary_key
|
||||
|
||||
if len(pk_cols) == 1:
|
||||
return getattr(instance, pk_cols[0].name, None)
|
||||
|
||||
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
|
||||
if all(v is not None for v in pk_values):
|
||||
return pk_values
|
||||
return None
|
||||
|
||||
|
||||
def get_obj_by_attr(
|
||||
fixtures: Callable[[], Sequence[T]], attr_name: str, value: Any
|
||||
) -> T:
|
||||
fixtures: Callable[[], Sequence[ModelType]], attr_name: str, value: Any
|
||||
) -> ModelType:
|
||||
"""Get a SQLAlchemy model instance by matching an attribute value.
|
||||
|
||||
Args:
|
||||
@@ -57,13 +117,6 @@ async def load_fixtures(
|
||||
|
||||
Returns:
|
||||
Dict mapping fixture names to loaded instances
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Loads 'roles' first (dependency), then 'users'
|
||||
result = await load_fixtures(session, fixtures, "users")
|
||||
print(result["users"]) # [User(...), ...]
|
||||
```
|
||||
"""
|
||||
ordered = registry.resolve_dependencies(*names)
|
||||
return await _load_ordered(session, registry, ordered, strategy)
|
||||
@@ -85,76 +138,6 @@ async def load_fixtures_by_context(
|
||||
|
||||
Returns:
|
||||
Dict mapping fixture names to loaded instances
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Load base + testing fixtures
|
||||
await load_fixtures_by_context(
|
||||
session, fixtures,
|
||||
Context.BASE, Context.TESTING
|
||||
)
|
||||
```
|
||||
"""
|
||||
ordered = registry.resolve_context_dependencies(*contexts)
|
||||
return await _load_ordered(session, registry, ordered, strategy)
|
||||
|
||||
|
||||
async def _load_ordered(
|
||||
session: AsyncSession,
|
||||
registry: FixtureRegistry,
|
||||
ordered_names: list[str],
|
||||
strategy: LoadStrategy,
|
||||
) -> dict[str, list[DeclarativeBase]]:
|
||||
"""Load fixtures in order."""
|
||||
results: dict[str, list[DeclarativeBase]] = {}
|
||||
|
||||
for name in ordered_names:
|
||||
fixture = registry.get(name)
|
||||
instances = list(fixture.func())
|
||||
|
||||
if not instances:
|
||||
results[name] = []
|
||||
continue
|
||||
|
||||
model_name = type(instances[0]).__name__
|
||||
loaded: list[DeclarativeBase] = []
|
||||
|
||||
async with get_transaction(session):
|
||||
for instance in instances:
|
||||
if strategy == LoadStrategy.INSERT:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
|
||||
elif strategy == LoadStrategy.MERGE:
|
||||
merged = await session.merge(instance)
|
||||
loaded.append(merged)
|
||||
|
||||
elif strategy == LoadStrategy.SKIP_EXISTING:
|
||||
pk = _get_primary_key(instance)
|
||||
if pk is not None:
|
||||
existing = await session.get(type(instance), pk)
|
||||
if existing is None:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
else:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
|
||||
results[name] = loaded
|
||||
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
||||
"""Get the primary key value of a model instance."""
|
||||
mapper = instance.__class__.__mapper__
|
||||
pk_cols = mapper.primary_key
|
||||
|
||||
if len(pk_cols) == 1:
|
||||
return getattr(instance, pk_cols[0].name, None)
|
||||
|
||||
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
|
||||
if all(v is not None for v in pk_values):
|
||||
return pk_values
|
||||
return None
|
||||
|
||||
@@ -53,17 +53,23 @@ def init_metrics(
|
||||
logger.debug("Initialising metric provider '%s'", provider.name)
|
||||
provider.func()
|
||||
|
||||
collectors = registry.get_collectors()
|
||||
# Partition collectors and cache env check at startup — both are stable for the app lifetime.
|
||||
async_collectors = [
|
||||
c for c in registry.get_collectors() if asyncio.iscoroutinefunction(c.func)
|
||||
]
|
||||
sync_collectors = [
|
||||
c for c in registry.get_collectors() if not asyncio.iscoroutinefunction(c.func)
|
||||
]
|
||||
multiprocess_mode = _is_multiprocess()
|
||||
|
||||
@app.get(path, include_in_schema=False)
|
||||
async def metrics_endpoint() -> Response:
|
||||
for collector in collectors:
|
||||
if asyncio.iscoroutinefunction(collector.func):
|
||||
await collector.func()
|
||||
else:
|
||||
collector.func()
|
||||
for collector in sync_collectors:
|
||||
collector.func()
|
||||
for collector in async_collectors:
|
||||
await collector.func()
|
||||
|
||||
if _is_multiprocess():
|
||||
if multiprocess_mode:
|
||||
prom_registry = CollectorRegistry()
|
||||
multiprocess.MultiProcessCollector(prom_registry)
|
||||
output = generate_latest(prom_registry)
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
"""Base Pydantic schemas for API responses."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, ClassVar, Generic, TypeVar
|
||||
from typing import Any, ClassVar, Generic
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from .types import DataT
|
||||
|
||||
__all__ = [
|
||||
"ApiError",
|
||||
"CursorPagination",
|
||||
"ErrorResponse",
|
||||
"OffsetPagination",
|
||||
"Pagination",
|
||||
"PaginatedResponse",
|
||||
"PydanticBase",
|
||||
"Response",
|
||||
"ResponseStatus",
|
||||
]
|
||||
|
||||
DataT = TypeVar("DataT")
|
||||
|
||||
|
||||
class PydanticBase(BaseModel):
|
||||
"""Base class for all Pydantic models with common configuration."""
|
||||
@@ -108,10 +107,6 @@ class OffsetPagination(PydanticBase):
|
||||
has_more: bool
|
||||
|
||||
|
||||
# Backward-compatible - will be removed in v2.0
|
||||
Pagination = OffsetPagination
|
||||
|
||||
|
||||
class CursorPagination(PydanticBase):
|
||||
"""Pagination metadata for cursor-based list responses.
|
||||
|
||||
|
||||
27
src/fastapi_toolsets/types.py
Normal file
27
src/fastapi_toolsets/types.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Shared type aliases for the fastapi-toolsets package."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable, Mapping
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
# Generic TypeVars
|
||||
DataT = TypeVar("DataT")
|
||||
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
||||
SchemaType = TypeVar("SchemaType", bound=BaseModel)
|
||||
|
||||
# CRUD type aliases
|
||||
JoinType = list[tuple[type[DeclarativeBase], Any]]
|
||||
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
||||
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
||||
|
||||
# Search / facet type aliases
|
||||
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
||||
FacetFieldType = SearchFieldType
|
||||
|
||||
# Dependency type aliases
|
||||
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]]
|
||||
Reference in New Issue
Block a user