fastapi-toolsets 2.3.0__tar.gz → 2.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/PKG-INFO +2 -2
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/README.md +1 -1
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/pyproject.toml +1 -1
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/__init__.py +1 -1
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/crud/factory.py +174 -23
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/dependencies.py +17 -4
- fastapi_toolsets-2.4.1/src/fastapi_toolsets/models/__init__.py +21 -0
- fastapi_toolsets-2.3.0/src/fastapi_toolsets/models.py → fastapi_toolsets-2.4.1/src/fastapi_toolsets/models/columns.py +1 -1
- fastapi_toolsets-2.4.1/src/fastapi_toolsets/models/watched.py +231 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/schemas.py +16 -3
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/types.py +1 -1
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/LICENSE +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/_imports.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/__init__.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/app.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/config.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/pyproject.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/utils.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/crud/__init__.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/crud/search.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/db.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/exceptions/handler.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/fixtures/enum.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/fixtures/registry.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/fixtures/utils.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/logger.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/metrics/__init__.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/metrics/handler.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/metrics/registry.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/py.typed +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/pytest/__init__.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/pytest/plugin.py +0 -0
- {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/pytest/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-toolsets
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.1
|
|
4
4
|
Summary: Production-ready utilities for FastAPI applications
|
|
5
5
|
Keywords: fastapi,sqlalchemy,postgresql
|
|
6
6
|
Author: d3vyce
|
|
@@ -96,7 +96,7 @@ uv add "fastapi-toolsets[all]"
|
|
|
96
96
|
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
|
97
97
|
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
|
98
98
|
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
|
99
|
-
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
|
|
99
|
+
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`) and lifecycle callbacks (`WatchedFieldsMixin`, `@watch`) that fire after commit for insert, update, and delete events
|
|
100
100
|
- **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
|
|
101
101
|
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
|
102
102
|
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
|
@@ -48,7 +48,7 @@ uv add "fastapi-toolsets[all]"
|
|
|
48
48
|
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
|
49
49
|
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
|
50
50
|
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
|
51
|
-
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
|
|
51
|
+
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`) and lifecycle callbacks (`WatchedFieldsMixin`, `@watch`) that fire after commit for insert, update, and delete events
|
|
52
52
|
- **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
|
|
53
53
|
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
|
54
54
|
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
|
@@ -58,18 +58,33 @@ class _CursorDirection(str, Enum):
|
|
|
58
58
|
def _encode_cursor(
|
|
59
59
|
value: Any, *, direction: _CursorDirection = _CursorDirection.NEXT
|
|
60
60
|
) -> str:
|
|
61
|
-
"""Encode a cursor column value and navigation direction as a base64 string."""
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
"""Encode a cursor column value and navigation direction as a URL-safe base64 string."""
|
|
62
|
+
return (
|
|
63
|
+
base64.urlsafe_b64encode(
|
|
64
|
+
json.dumps({"val": str(value), "dir": direction}).encode()
|
|
65
|
+
)
|
|
66
|
+
.decode()
|
|
67
|
+
.rstrip("=")
|
|
68
|
+
)
|
|
65
69
|
|
|
66
70
|
|
|
67
71
|
def _decode_cursor(cursor: str) -> tuple[str, _CursorDirection]:
|
|
68
|
-
"""Decode a
|
|
69
|
-
|
|
72
|
+
"""Decode a URL-safe base64 cursor string into ``(raw_value, direction)``."""
|
|
73
|
+
padded = cursor + "=" * (-len(cursor) % 4)
|
|
74
|
+
payload = json.loads(base64.urlsafe_b64decode(padded).decode())
|
|
70
75
|
return payload["val"], _CursorDirection(payload["dir"])
|
|
71
76
|
|
|
72
77
|
|
|
78
|
+
def _page_size_query(default: int, max_size: int) -> int:
|
|
79
|
+
"""Return a FastAPI ``Query`` for the ``items_per_page`` parameter."""
|
|
80
|
+
return Query(
|
|
81
|
+
default,
|
|
82
|
+
ge=1,
|
|
83
|
+
le=max_size,
|
|
84
|
+
description=f"Number of items per page (max {max_size})",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
73
88
|
def _parse_cursor_value(raw_val: str, col_type: Any) -> Any:
|
|
74
89
|
"""Parse a raw cursor string value back into the appropriate Python type."""
|
|
75
90
|
if isinstance(col_type, Integer):
|
|
@@ -254,6 +269,7 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
254
269
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
|
255
270
|
) -> Callable[..., Awaitable[dict[str, list[str]]]]:
|
|
256
271
|
"""Return a FastAPI dependency that collects facet filter values from query parameters.
|
|
272
|
+
|
|
257
273
|
Args:
|
|
258
274
|
facet_fields: Override the facet fields for this dependency. Falls back to the
|
|
259
275
|
class-level ``facet_fields`` if not provided.
|
|
@@ -293,6 +309,121 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
293
309
|
|
|
294
310
|
return dependency
|
|
295
311
|
|
|
312
|
+
@classmethod
|
|
313
|
+
def offset_params(
|
|
314
|
+
cls: type[Self],
|
|
315
|
+
*,
|
|
316
|
+
default_page_size: int = 20,
|
|
317
|
+
max_page_size: int = 100,
|
|
318
|
+
include_total: bool = True,
|
|
319
|
+
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
|
320
|
+
"""Return a FastAPI dependency that collects offset pagination params from query params.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
default_page_size: Default value for the ``items_per_page`` query parameter.
|
|
324
|
+
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
|
325
|
+
``le`` on the ``Query``).
|
|
326
|
+
include_total: Server-side flag forwarded as-is to ``include_total`` in
|
|
327
|
+
:meth:`offset_paginate`. Not exposed as a query parameter.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
An async dependency that resolves to a dict with ``page``,
|
|
331
|
+
``items_per_page``, and ``include_total`` keys, ready to be
|
|
332
|
+
unpacked into :meth:`offset_paginate`.
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
async def dependency(
|
|
336
|
+
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
|
337
|
+
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
|
338
|
+
) -> dict[str, Any]:
|
|
339
|
+
return {
|
|
340
|
+
"page": page,
|
|
341
|
+
"items_per_page": items_per_page,
|
|
342
|
+
"include_total": include_total,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
dependency.__name__ = f"{cls.model.__name__}OffsetParams"
|
|
346
|
+
return dependency
|
|
347
|
+
|
|
348
|
+
@classmethod
|
|
349
|
+
def cursor_params(
|
|
350
|
+
cls: type[Self],
|
|
351
|
+
*,
|
|
352
|
+
default_page_size: int = 20,
|
|
353
|
+
max_page_size: int = 100,
|
|
354
|
+
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
|
355
|
+
"""Return a FastAPI dependency that collects cursor pagination params from query params.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
default_page_size: Default value for the ``items_per_page`` query parameter.
|
|
359
|
+
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
|
360
|
+
``le`` on the ``Query``).
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
An async dependency that resolves to a dict with ``cursor`` and
|
|
364
|
+
``items_per_page`` keys, ready to be unpacked into
|
|
365
|
+
:meth:`cursor_paginate`.
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
async def dependency(
|
|
369
|
+
cursor: str | None = Query(
|
|
370
|
+
None, description="Cursor token from a previous response"
|
|
371
|
+
),
|
|
372
|
+
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
|
373
|
+
) -> dict[str, Any]:
|
|
374
|
+
return {"cursor": cursor, "items_per_page": items_per_page}
|
|
375
|
+
|
|
376
|
+
dependency.__name__ = f"{cls.model.__name__}CursorParams"
|
|
377
|
+
return dependency
|
|
378
|
+
|
|
379
|
+
@classmethod
|
|
380
|
+
def paginate_params(
|
|
381
|
+
cls: type[Self],
|
|
382
|
+
*,
|
|
383
|
+
default_page_size: int = 20,
|
|
384
|
+
max_page_size: int = 100,
|
|
385
|
+
default_pagination_type: PaginationType = PaginationType.OFFSET,
|
|
386
|
+
include_total: bool = True,
|
|
387
|
+
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
|
388
|
+
"""Return a FastAPI dependency that collects all pagination params from query params.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
default_page_size: Default value for the ``items_per_page`` query parameter.
|
|
392
|
+
max_page_size: Maximum allowed value for ``items_per_page`` (enforced via
|
|
393
|
+
``le`` on the ``Query``).
|
|
394
|
+
default_pagination_type: Default pagination strategy.
|
|
395
|
+
include_total: Server-side flag forwarded as-is to ``include_total`` in
|
|
396
|
+
:meth:`paginate`. Not exposed as a query parameter.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
An async dependency that resolves to a dict with ``pagination_type``,
|
|
400
|
+
``page``, ``cursor``, ``items_per_page``, and ``include_total`` keys,
|
|
401
|
+
ready to be unpacked into :meth:`paginate`.
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
async def dependency(
|
|
405
|
+
pagination_type: PaginationType = Query(
|
|
406
|
+
default_pagination_type, description="Pagination strategy"
|
|
407
|
+
),
|
|
408
|
+
page: int = Query(
|
|
409
|
+
1, ge=1, description="Page number (1-indexed, offset only)"
|
|
410
|
+
),
|
|
411
|
+
cursor: str | None = Query(
|
|
412
|
+
None, description="Cursor token from a previous response (cursor only)"
|
|
413
|
+
),
|
|
414
|
+
items_per_page: int = _page_size_query(default_page_size, max_page_size),
|
|
415
|
+
) -> dict[str, Any]:
|
|
416
|
+
return {
|
|
417
|
+
"pagination_type": pagination_type,
|
|
418
|
+
"page": page,
|
|
419
|
+
"cursor": cursor,
|
|
420
|
+
"items_per_page": items_per_page,
|
|
421
|
+
"include_total": include_total,
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
dependency.__name__ = f"{cls.model.__name__}PaginateParams"
|
|
425
|
+
return dependency
|
|
426
|
+
|
|
296
427
|
@classmethod
|
|
297
428
|
def order_params(
|
|
298
429
|
cls: type[Self],
|
|
@@ -922,6 +1053,7 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
922
1053
|
order_by: OrderByClause | None = None,
|
|
923
1054
|
page: int = 1,
|
|
924
1055
|
items_per_page: int = 20,
|
|
1056
|
+
include_total: bool = True,
|
|
925
1057
|
search: str | SearchConfig | None = None,
|
|
926
1058
|
search_fields: Sequence[SearchFieldType] | None = None,
|
|
927
1059
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
|
@@ -939,6 +1071,8 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
939
1071
|
order_by: Column or list of columns to order by
|
|
940
1072
|
page: Page number (1-indexed)
|
|
941
1073
|
items_per_page: Number of items per page
|
|
1074
|
+
include_total: When ``False``, skip the ``COUNT`` query;
|
|
1075
|
+
``pagination.total_count`` will be ``None``.
|
|
942
1076
|
search: Search query string or SearchConfig object
|
|
943
1077
|
search_fields: Fields to search in (overrides class default)
|
|
944
1078
|
facet_fields: Columns to compute distinct values for (overrides class default)
|
|
@@ -983,27 +1117,38 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
983
1117
|
if order_by is not None:
|
|
984
1118
|
q = q.order_by(order_by)
|
|
985
1119
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1120
|
+
if include_total:
|
|
1121
|
+
q = q.offset(offset).limit(items_per_page)
|
|
1122
|
+
result = await session.execute(q)
|
|
1123
|
+
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
|
990
1124
|
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1125
|
+
# Count query (with same joins and filters)
|
|
1126
|
+
pk_col = cls.model.__mapper__.primary_key[0]
|
|
1127
|
+
count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
|
|
1128
|
+
count_q = count_q.select_from(cls.model)
|
|
995
1129
|
|
|
996
|
-
|
|
997
|
-
|
|
1130
|
+
# Apply explicit joins to count query
|
|
1131
|
+
count_q = _apply_joins(count_q, joins, outer_join)
|
|
998
1132
|
|
|
999
|
-
|
|
1000
|
-
|
|
1133
|
+
# Apply search joins to count query
|
|
1134
|
+
count_q = _apply_search_joins(count_q, search_joins)
|
|
1001
1135
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1136
|
+
if filters:
|
|
1137
|
+
count_q = count_q.where(and_(*filters))
|
|
1004
1138
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1139
|
+
count_result = await session.execute(count_q)
|
|
1140
|
+
total_count: int | None = count_result.scalar_one()
|
|
1141
|
+
has_more = page * items_per_page < total_count
|
|
1142
|
+
else:
|
|
1143
|
+
# Fetch one extra row to detect if a next page exists without COUNT
|
|
1144
|
+
q = q.offset(offset).limit(items_per_page + 1)
|
|
1145
|
+
result = await session.execute(q)
|
|
1146
|
+
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
|
1147
|
+
has_more = len(raw_items) > items_per_page
|
|
1148
|
+
raw_items = raw_items[:items_per_page]
|
|
1149
|
+
total_count = None
|
|
1150
|
+
|
|
1151
|
+
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
|
1007
1152
|
|
|
1008
1153
|
filter_attributes = await cls._build_filter_attributes(
|
|
1009
1154
|
session, facet_fields, filters, search_joins
|
|
@@ -1015,7 +1160,7 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
1015
1160
|
total_count=total_count,
|
|
1016
1161
|
items_per_page=items_per_page,
|
|
1017
1162
|
page=page,
|
|
1018
|
-
has_more=
|
|
1163
|
+
has_more=has_more,
|
|
1019
1164
|
),
|
|
1020
1165
|
filter_attributes=filter_attributes,
|
|
1021
1166
|
)
|
|
@@ -1190,6 +1335,7 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
1190
1335
|
page: int = ...,
|
|
1191
1336
|
cursor: str | None = ...,
|
|
1192
1337
|
items_per_page: int = ...,
|
|
1338
|
+
include_total: bool = ...,
|
|
1193
1339
|
search: str | SearchConfig | None = ...,
|
|
1194
1340
|
search_fields: Sequence[SearchFieldType] | None = ...,
|
|
1195
1341
|
facet_fields: Sequence[FacetFieldType] | None = ...,
|
|
@@ -1212,6 +1358,7 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
1212
1358
|
page: int = ...,
|
|
1213
1359
|
cursor: str | None = ...,
|
|
1214
1360
|
items_per_page: int = ...,
|
|
1361
|
+
include_total: bool = ...,
|
|
1215
1362
|
search: str | SearchConfig | None = ...,
|
|
1216
1363
|
search_fields: Sequence[SearchFieldType] | None = ...,
|
|
1217
1364
|
facet_fields: Sequence[FacetFieldType] | None = ...,
|
|
@@ -1233,6 +1380,7 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
1233
1380
|
page: int = 1,
|
|
1234
1381
|
cursor: str | None = None,
|
|
1235
1382
|
items_per_page: int = 20,
|
|
1383
|
+
include_total: bool = True,
|
|
1236
1384
|
search: str | SearchConfig | None = None,
|
|
1237
1385
|
search_fields: Sequence[SearchFieldType] | None = None,
|
|
1238
1386
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
|
@@ -1258,6 +1406,8 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
1258
1406
|
:class:`.CursorPaginatedResponse`. Only used when
|
|
1259
1407
|
``pagination_type`` is ``CURSOR``.
|
|
1260
1408
|
items_per_page: Number of items per page (default 20).
|
|
1409
|
+
include_total: When ``False``, skip the ``COUNT`` query;
|
|
1410
|
+
only applies when ``pagination_type`` is ``OFFSET``.
|
|
1261
1411
|
search: Search query string or :class:`.SearchConfig` object.
|
|
1262
1412
|
search_fields: Fields to search in (overrides class default).
|
|
1263
1413
|
facet_fields: Columns to compute distinct values for (overrides
|
|
@@ -1304,6 +1454,7 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
1304
1454
|
order_by=order_by,
|
|
1305
1455
|
page=page,
|
|
1306
1456
|
items_per_page=items_per_page,
|
|
1457
|
+
include_total=include_total,
|
|
1307
1458
|
search=search,
|
|
1308
1459
|
search_fields=search_fields,
|
|
1309
1460
|
facet_fields=facet_fields,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"""Dependency factories for FastAPI routes."""
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
|
+
import typing
|
|
4
5
|
from collections.abc import Callable
|
|
5
6
|
from typing import Any, cast
|
|
6
7
|
|
|
7
8
|
from fastapi import Depends
|
|
9
|
+
from fastapi.params import Depends as DependsClass
|
|
8
10
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
11
|
|
|
10
12
|
from .crud import CrudFactory
|
|
@@ -13,6 +15,15 @@ from .types import ModelType, SessionDependency
|
|
|
13
15
|
__all__ = ["BodyDependency", "PathDependency"]
|
|
14
16
|
|
|
15
17
|
|
|
18
|
+
def _unwrap_session_dep(session_dep: SessionDependency) -> Callable[..., Any]:
|
|
19
|
+
"""Extract the plain callable from ``Annotated[AsyncSession, Depends(fn)]`` if needed."""
|
|
20
|
+
if typing.get_origin(session_dep) is typing.Annotated:
|
|
21
|
+
for arg in typing.get_args(session_dep)[1:]:
|
|
22
|
+
if isinstance(arg, DependsClass):
|
|
23
|
+
return arg.dependency
|
|
24
|
+
return session_dep
|
|
25
|
+
|
|
26
|
+
|
|
16
27
|
def PathDependency(
|
|
17
28
|
model: type[ModelType],
|
|
18
29
|
field: Any,
|
|
@@ -44,6 +55,7 @@ def PathDependency(
|
|
|
44
55
|
): ...
|
|
45
56
|
```
|
|
46
57
|
"""
|
|
58
|
+
session_callable = _unwrap_session_dep(session_dep)
|
|
47
59
|
crud = CrudFactory(model)
|
|
48
60
|
name = (
|
|
49
61
|
param_name
|
|
@@ -53,7 +65,7 @@ def PathDependency(
|
|
|
53
65
|
python_type = field.type.python_type
|
|
54
66
|
|
|
55
67
|
async def dependency(
|
|
56
|
-
session: AsyncSession = Depends(
|
|
68
|
+
session: AsyncSession = Depends(session_callable), **kwargs: Any
|
|
57
69
|
) -> ModelType:
|
|
58
70
|
value = kwargs[name]
|
|
59
71
|
return await crud.get(session, filters=[field == value])
|
|
@@ -70,7 +82,7 @@ def PathDependency(
|
|
|
70
82
|
"session",
|
|
71
83
|
inspect.Parameter.KEYWORD_ONLY,
|
|
72
84
|
annotation=AsyncSession,
|
|
73
|
-
default=Depends(
|
|
85
|
+
default=Depends(session_callable),
|
|
74
86
|
),
|
|
75
87
|
]
|
|
76
88
|
),
|
|
@@ -112,11 +124,12 @@ def BodyDependency(
|
|
|
112
124
|
): ...
|
|
113
125
|
```
|
|
114
126
|
"""
|
|
127
|
+
session_callable = _unwrap_session_dep(session_dep)
|
|
115
128
|
crud = CrudFactory(model)
|
|
116
129
|
python_type = field.type.python_type
|
|
117
130
|
|
|
118
131
|
async def dependency(
|
|
119
|
-
session: AsyncSession = Depends(
|
|
132
|
+
session: AsyncSession = Depends(session_callable), **kwargs: Any
|
|
120
133
|
) -> ModelType:
|
|
121
134
|
value = kwargs[body_field]
|
|
122
135
|
return await crud.get(session, filters=[field == value])
|
|
@@ -133,7 +146,7 @@ def BodyDependency(
|
|
|
133
146
|
"session",
|
|
134
147
|
inspect.Parameter.KEYWORD_ONLY,
|
|
135
148
|
annotation=AsyncSession,
|
|
136
|
-
default=Depends(
|
|
149
|
+
default=Depends(session_callable),
|
|
137
150
|
),
|
|
138
151
|
]
|
|
139
152
|
),
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""SQLAlchemy model mixins for common column patterns."""
|
|
2
|
+
|
|
3
|
+
from .columns import (
|
|
4
|
+
CreatedAtMixin,
|
|
5
|
+
TimestampMixin,
|
|
6
|
+
UUIDMixin,
|
|
7
|
+
UUIDv7Mixin,
|
|
8
|
+
UpdatedAtMixin,
|
|
9
|
+
)
|
|
10
|
+
from .watched import ModelEvent, WatchedFieldsMixin, watch
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ModelEvent",
|
|
14
|
+
"UUIDMixin",
|
|
15
|
+
"UUIDv7Mixin",
|
|
16
|
+
"CreatedAtMixin",
|
|
17
|
+
"UpdatedAtMixin",
|
|
18
|
+
"TimestampMixin",
|
|
19
|
+
"WatchedFieldsMixin",
|
|
20
|
+
"watch",
|
|
21
|
+
]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Field-change monitoring via SQLAlchemy session events."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import weakref
|
|
5
|
+
from collections.abc import Awaitable
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import event
|
|
10
|
+
from sqlalchemy import inspect as sa_inspect
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value
|
|
13
|
+
|
|
14
|
+
from ..logger import get_logger
|
|
15
|
+
|
|
16
|
+
__all__ = ["ModelEvent", "WatchedFieldsMixin", "watch"]
|
|
17
|
+
|
|
18
|
+
_logger = get_logger()
|
|
19
|
+
_T = TypeVar("_T")
|
|
20
|
+
_CALLBACK_ERROR_MSG = "WatchedFieldsMixin callback raised an unhandled exception"
|
|
21
|
+
_WATCHED_FIELDS: weakref.WeakKeyDictionary[type, list[str]] = (
|
|
22
|
+
weakref.WeakKeyDictionary()
|
|
23
|
+
)
|
|
24
|
+
_SESSION_PENDING_NEW = "_ft_pending_new"
|
|
25
|
+
_SESSION_CREATES = "_ft_creates"
|
|
26
|
+
_SESSION_DELETES = "_ft_deletes"
|
|
27
|
+
_SESSION_UPDATES = "_ft_updates"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ModelEvent(str, Enum):
|
|
31
|
+
"""Event types emitted by :class:`WatchedFieldsMixin`."""
|
|
32
|
+
|
|
33
|
+
CREATE = "create"
|
|
34
|
+
DELETE = "delete"
|
|
35
|
+
UPDATE = "update"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def watch(*fields: str) -> Any:
|
|
39
|
+
"""Class decorator to filter which fields trigger ``on_update``.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
*fields: One or more field names to watch. At least one name is required.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValueError: If called with no field names.
|
|
46
|
+
"""
|
|
47
|
+
if not fields:
|
|
48
|
+
raise ValueError("@watch requires at least one field name.")
|
|
49
|
+
|
|
50
|
+
def decorator(cls: type[_T]) -> type[_T]:
|
|
51
|
+
_WATCHED_FIELDS[cls] = list(fields)
|
|
52
|
+
return cls
|
|
53
|
+
|
|
54
|
+
return decorator
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _snapshot_column_attrs(obj: Any) -> dict[str, Any]:
|
|
58
|
+
"""Read currently-loaded column values into a plain dict."""
|
|
59
|
+
state = sa_inspect(obj) # InstanceState
|
|
60
|
+
state_dict = state.dict
|
|
61
|
+
return {
|
|
62
|
+
prop.key: state_dict[prop.key]
|
|
63
|
+
for prop in state.mapper.column_attrs
|
|
64
|
+
if prop.key in state_dict
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _upsert_changes(
|
|
69
|
+
pending: dict[int, tuple[Any, dict[str, dict[str, Any]]]],
|
|
70
|
+
obj: Any,
|
|
71
|
+
changes: dict[str, dict[str, Any]],
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Insert or merge *changes* into *pending* for *obj*."""
|
|
74
|
+
key = id(obj)
|
|
75
|
+
if key in pending:
|
|
76
|
+
existing = pending[key][1]
|
|
77
|
+
for field, change in changes.items():
|
|
78
|
+
if field in existing:
|
|
79
|
+
existing[field]["new"] = change["new"]
|
|
80
|
+
else:
|
|
81
|
+
existing[field] = change
|
|
82
|
+
else:
|
|
83
|
+
pending[key] = (obj, changes)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@event.listens_for(AsyncSession.sync_session_class, "after_flush")
|
|
87
|
+
def _after_flush(session: Any, flush_context: Any) -> None:
|
|
88
|
+
# New objects: capture references while session.new is still populated.
|
|
89
|
+
# Values are read in _after_flush_postexec once RETURNING has been processed.
|
|
90
|
+
for obj in session.new:
|
|
91
|
+
if isinstance(obj, WatchedFieldsMixin):
|
|
92
|
+
session.info.setdefault(_SESSION_PENDING_NEW, []).append(obj)
|
|
93
|
+
|
|
94
|
+
# Deleted objects: capture before they leave the identity map.
|
|
95
|
+
for obj in session.deleted:
|
|
96
|
+
if isinstance(obj, WatchedFieldsMixin):
|
|
97
|
+
session.info.setdefault(_SESSION_DELETES, []).append(obj)
|
|
98
|
+
|
|
99
|
+
# Dirty objects: read old/new from SQLAlchemy attribute history.
|
|
100
|
+
for obj in session.dirty:
|
|
101
|
+
if not isinstance(obj, WatchedFieldsMixin):
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
# None = not in dict = watch all fields; list = specific fields only
|
|
105
|
+
watched = _WATCHED_FIELDS.get(type(obj))
|
|
106
|
+
changes: dict[str, dict[str, Any]] = {}
|
|
107
|
+
|
|
108
|
+
attrs = (
|
|
109
|
+
# Specific fields
|
|
110
|
+
((field, sa_inspect(obj).attrs[field]) for field in watched)
|
|
111
|
+
if watched is not None
|
|
112
|
+
# All mapped fields
|
|
113
|
+
else ((s.key, s) for s in sa_inspect(obj).attrs)
|
|
114
|
+
)
|
|
115
|
+
for field, attr_state in attrs:
|
|
116
|
+
history = attr_state.history
|
|
117
|
+
if history.has_changes() and history.deleted:
|
|
118
|
+
changes[field] = {
|
|
119
|
+
"old": history.deleted[0],
|
|
120
|
+
"new": history.added[0] if history.added else None,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if changes:
|
|
124
|
+
_upsert_changes(
|
|
125
|
+
session.info.setdefault(_SESSION_UPDATES, {}),
|
|
126
|
+
obj,
|
|
127
|
+
changes,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@event.listens_for(AsyncSession.sync_session_class, "after_flush_postexec")
|
|
132
|
+
def _after_flush_postexec(session: Any, flush_context: Any) -> None:
|
|
133
|
+
# New objects are now persistent and RETURNING values have been applied,
|
|
134
|
+
# so server defaults (id, created_at, …) are available via getattr.
|
|
135
|
+
pending_new: list[Any] = session.info.pop(_SESSION_PENDING_NEW, [])
|
|
136
|
+
if not pending_new:
|
|
137
|
+
return
|
|
138
|
+
session.info.setdefault(_SESSION_CREATES, []).extend(pending_new)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@event.listens_for(AsyncSession.sync_session_class, "after_rollback")
|
|
142
|
+
def _after_rollback(session: Any) -> None:
|
|
143
|
+
session.info.pop(_SESSION_PENDING_NEW, None)
|
|
144
|
+
session.info.pop(_SESSION_CREATES, None)
|
|
145
|
+
session.info.pop(_SESSION_DELETES, None)
|
|
146
|
+
session.info.pop(_SESSION_UPDATES, None)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _task_error_handler(task: asyncio.Task[Any]) -> None:
|
|
150
|
+
if not task.cancelled() and (exc := task.exception()):
|
|
151
|
+
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _schedule_with_snapshot(
|
|
155
|
+
loop: asyncio.AbstractEventLoop, obj: Any, fn: Any, *args: Any
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Snapshot *obj*'s column attrs now (before expire_on_commit wipes them),
|
|
158
|
+
then schedule a coroutine that restores the snapshot and calls *fn*.
|
|
159
|
+
"""
|
|
160
|
+
snapshot = _snapshot_column_attrs(obj)
|
|
161
|
+
|
|
162
|
+
async def _run(
|
|
163
|
+
obj: Any = obj,
|
|
164
|
+
fn: Any = fn,
|
|
165
|
+
snapshot: dict[str, Any] = snapshot,
|
|
166
|
+
args: tuple = args,
|
|
167
|
+
) -> None:
|
|
168
|
+
for key, value in snapshot.items():
|
|
169
|
+
_sa_set_committed_value(obj, key, value)
|
|
170
|
+
try:
|
|
171
|
+
result = fn(*args)
|
|
172
|
+
if asyncio.iscoroutine(result):
|
|
173
|
+
await result
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
|
176
|
+
|
|
177
|
+
task = loop.create_task(_run())
|
|
178
|
+
task.add_done_callback(_task_error_handler)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@event.listens_for(AsyncSession.sync_session_class, "after_commit")
|
|
182
|
+
def _after_commit(session: Any) -> None:
|
|
183
|
+
creates: list[Any] = session.info.pop(_SESSION_CREATES, [])
|
|
184
|
+
deletes: list[Any] = session.info.pop(_SESSION_DELETES, [])
|
|
185
|
+
field_changes: dict[int, tuple[Any, dict[str, dict[str, Any]]]] = session.info.pop(
|
|
186
|
+
_SESSION_UPDATES, {}
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if not creates and not deletes and not field_changes:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
loop = asyncio.get_running_loop()
|
|
194
|
+
except RuntimeError:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
for obj in creates:
|
|
198
|
+
_schedule_with_snapshot(loop, obj, obj.on_create)
|
|
199
|
+
|
|
200
|
+
for obj in deletes:
|
|
201
|
+
_schedule_with_snapshot(loop, obj, obj.on_delete)
|
|
202
|
+
|
|
203
|
+
for obj, changes in field_changes.values():
|
|
204
|
+
_schedule_with_snapshot(loop, obj, obj.on_update, changes)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class WatchedFieldsMixin:
|
|
208
|
+
"""Mixin that enables lifecycle callbacks for SQLAlchemy models."""
|
|
209
|
+
|
|
210
|
+
def on_event(
|
|
211
|
+
self, event: ModelEvent, changes: dict[str, dict[str, Any]] | None = None
|
|
212
|
+
) -> Awaitable[None] | None:
|
|
213
|
+
"""Catch-all callback fired for every lifecycle event.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
event: The event type (:attr:`ModelEvent.CREATE`, :attr:`ModelEvent.DELETE`,
|
|
217
|
+
or :attr:`ModelEvent.UPDATE`).
|
|
218
|
+
changes: Field changes for :attr:`ModelEvent.UPDATE`, ``None`` otherwise.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def on_create(self) -> Awaitable[None] | None:
|
|
222
|
+
"""Called after INSERT commit."""
|
|
223
|
+
return self.on_event(ModelEvent.CREATE)
|
|
224
|
+
|
|
225
|
+
def on_delete(self) -> Awaitable[None] | None:
|
|
226
|
+
"""Called after DELETE commit."""
|
|
227
|
+
return self.on_event(ModelEvent.DELETE)
|
|
228
|
+
|
|
229
|
+
def on_update(self, changes: dict[str, dict[str, Any]]) -> Awaitable[None] | None:
|
|
230
|
+
"""Called after UPDATE commit when watched fields change."""
|
|
231
|
+
return self.on_event(ModelEvent.UPDATE, changes=changes)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Base Pydantic schemas for API responses."""
|
|
2
2
|
|
|
3
|
+
import math
|
|
3
4
|
from enum import Enum
|
|
4
5
|
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, Union
|
|
5
6
|
|
|
6
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
7
8
|
|
|
8
9
|
from .types import DataT
|
|
9
10
|
|
|
@@ -98,17 +99,29 @@ class OffsetPagination(PydanticBase):
|
|
|
98
99
|
"""Pagination metadata for offset-based list responses.
|
|
99
100
|
|
|
100
101
|
Attributes:
|
|
101
|
-
total_count: Total number of items across all pages
|
|
102
|
+
total_count: Total number of items across all pages.
|
|
103
|
+
``None`` when ``include_total=False``.
|
|
102
104
|
items_per_page: Number of items per page
|
|
103
105
|
page: Current page number (1-indexed)
|
|
104
106
|
has_more: Whether there are more pages
|
|
107
|
+
pages: Total number of pages
|
|
105
108
|
"""
|
|
106
109
|
|
|
107
|
-
total_count: int
|
|
110
|
+
total_count: int | None
|
|
108
111
|
items_per_page: int
|
|
109
112
|
page: int
|
|
110
113
|
has_more: bool
|
|
111
114
|
|
|
115
|
+
@computed_field
|
|
116
|
+
@property
|
|
117
|
+
def pages(self) -> int | None:
|
|
118
|
+
"""Total number of pages, or ``None`` when ``total_count`` is unknown."""
|
|
119
|
+
if self.total_count is None:
|
|
120
|
+
return None
|
|
121
|
+
if self.items_per_page == 0:
|
|
122
|
+
return 0
|
|
123
|
+
return math.ceil(self.total_count / self.items_per_page)
|
|
124
|
+
|
|
112
125
|
|
|
113
126
|
class CursorPagination(PydanticBase):
|
|
114
127
|
"""Pagination metadata for cursor-based list responses.
|
|
@@ -24,4 +24,4 @@ SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any],
|
|
|
24
24
|
FacetFieldType = SearchFieldType
|
|
25
25
|
|
|
26
26
|
# Dependency type aliases
|
|
27
|
-
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]]
|
|
27
|
+
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] | Any
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/commands/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/commands/fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/exceptions/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/exceptions/exceptions.py
RENAMED
|
File without changes
|
{fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/exceptions/handler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|