fastapi-toolsets 2.4.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.4.0 → fastapi_toolsets-2.4.1}/PKG-INFO +1 -1
  2. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/pyproject.toml +1 -1
  3. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/__init__.py +1 -1
  4. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/crud/factory.py +174 -23
  5. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/schemas.py +16 -3
  6. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/LICENSE +0 -0
  7. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/README.md +0 -0
  8. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/_imports.py +0 -0
  9. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/__init__.py +0 -0
  10. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/app.py +0 -0
  11. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
  12. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
  13. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/config.py +0 -0
  14. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/pyproject.py +0 -0
  15. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/cli/utils.py +0 -0
  16. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/crud/__init__.py +0 -0
  17. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/crud/search.py +0 -0
  18. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/db.py +0 -0
  19. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/dependencies.py +0 -0
  20. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
  21. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
  22. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/exceptions/handler.py +0 -0
  23. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
  24. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/fixtures/enum.py +0 -0
  25. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/fixtures/registry.py +0 -0
  26. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/fixtures/utils.py +0 -0
  27. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/logger.py +0 -0
  28. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/metrics/__init__.py +0 -0
  29. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/metrics/handler.py +0 -0
  30. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/metrics/registry.py +0 -0
  31. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/models/__init__.py +0 -0
  32. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/models/columns.py +0 -0
  33. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/models/watched.py +0 -0
  34. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/py.typed +0 -0
  35. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/pytest/__init__.py +0 -0
  36. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/pytest/plugin.py +0 -0
  37. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/pytest/utils.py +0 -0
  38. {fastapi_toolsets-2.4.0 → fastapi_toolsets-2.4.1}/src/fastapi_toolsets/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-toolsets
3
- Version: 2.4.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-toolsets"
3
- version = "2.4.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.4.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,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.