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.
Files changed (38) hide show
  1. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/PKG-INFO +2 -2
  2. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/README.md +1 -1
  3. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/pyproject.toml +1 -1
  4. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/__init__.py +1 -1
  5. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/crud/factory.py +174 -23
  6. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/dependencies.py +17 -4
  7. fastapi_toolsets-2.4.1/src/fastapi_toolsets/models/__init__.py +21 -0
  8. fastapi_toolsets-2.3.0/src/fastapi_toolsets/models.py → fastapi_toolsets-2.4.1/src/fastapi_toolsets/models/columns.py +1 -1
  9. fastapi_toolsets-2.4.1/src/fastapi_toolsets/models/watched.py +231 -0
  10. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/schemas.py +16 -3
  11. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/types.py +1 -1
  12. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/LICENSE +0 -0
  13. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/_imports.py +0 -0
  14. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/__init__.py +0 -0
  15. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/app.py +0 -0
  16. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
  17. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
  18. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/config.py +0 -0
  19. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/pyproject.py +0 -0
  20. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/utils.py +0 -0
  21. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/crud/__init__.py +0 -0
  22. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/crud/search.py +0 -0
  23. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/db.py +0 -0
  24. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
  25. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
  26. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/exceptions/handler.py +0 -0
  27. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
  28. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/fixtures/enum.py +0 -0
  29. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/fixtures/registry.py +0 -0
  30. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/fixtures/utils.py +0 -0
  31. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/logger.py +0 -0
  32. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/metrics/__init__.py +0 -0
  33. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/metrics/handler.py +0 -0
  34. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/metrics/registry.py +0 -0
  35. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/py.typed +0 -0
  36. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/pytest/__init__.py +0 -0
  37. {fastapi_toolsets-2.3.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/pytest/plugin.py +0 -0
  38. {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.0
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`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-toolsets"
3
- version = "2.3.0"
3
+ version = "2.4.1"
4
4
  description = "Production-ready utilities for FastAPI applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,4 +21,4 @@ Example usage:
21
21
  return Response(data={"user": user.username}, message="Success")
22
22
  """
23
23
 
24
- __version__ = "2.3.0"
24
+ __version__ = "2.4.1"
@@ -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 base64.b64encode(
63
- json.dumps({"val": str(value), "dir": direction}).encode()
64
- ).decode()
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 cursor base64 string into ``(raw_value, direction)``."""
69
- payload = json.loads(base64.b64decode(cursor.encode()).decode())
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
- q = q.offset(offset).limit(items_per_page)
987
- result = await session.execute(q)
988
- raw_items = cast(list[ModelType], result.unique().scalars().all())
989
- items: list[Any] = [schema.model_validate(item) for item in raw_items]
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
- # Count query (with same joins and filters)
992
- pk_col = cls.model.__mapper__.primary_key[0]
993
- count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
994
- count_q = count_q.select_from(cls.model)
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
- # Apply explicit joins to count query
997
- count_q = _apply_joins(count_q, joins, outer_join)
1130
+ # Apply explicit joins to count query
1131
+ count_q = _apply_joins(count_q, joins, outer_join)
998
1132
 
999
- # Apply search joins to count query
1000
- count_q = _apply_search_joins(count_q, search_joins)
1133
+ # Apply search joins to count query
1134
+ count_q = _apply_search_joins(count_q, search_joins)
1001
1135
 
1002
- if filters:
1003
- count_q = count_q.where(and_(*filters))
1136
+ if filters:
1137
+ count_q = count_q.where(and_(*filters))
1004
1138
 
1005
- count_result = await session.execute(count_q)
1006
- total_count = count_result.scalar_one()
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=page * items_per_page < total_count,
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(session_dep), **kwargs: Any
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(session_dep),
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(session_dep), **kwargs: Any
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(session_dep),
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
+ ]
@@ -1,4 +1,4 @@
1
- """SQLAlchemy model mixins for common column patterns."""
1
+ """SQLAlchemy column mixins for common column patterns."""
2
2
 
3
3
  import uuid
4
4
  from datetime import datetime
@@ -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