fastapi-toolsets 4.0.0__tar.gz → 4.1.0__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 (46) hide show
  1. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/PKG-INFO +1 -1
  2. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/pyproject.toml +1 -1
  3. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/__init__.py +1 -1
  4. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/crud/factory.py +37 -14
  5. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/db.py +73 -0
  6. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/LICENSE +0 -0
  7. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/README.md +0 -0
  8. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/_imports.py +0 -0
  9. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/cli/__init__.py +0 -0
  10. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/cli/app.py +0 -0
  11. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
  12. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
  13. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/cli/config.py +0 -0
  14. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/cli/pyproject.py +0 -0
  15. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/cli/utils.py +0 -0
  16. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/crud/__init__.py +0 -0
  17. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/crud/search.py +0 -0
  18. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/dependencies.py +0 -0
  19. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
  20. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
  21. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/exceptions/handler.py +0 -0
  22. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
  23. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/fixtures/enum.py +0 -0
  24. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/fixtures/registry.py +0 -0
  25. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/fixtures/utils.py +0 -0
  26. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/logger.py +0 -0
  27. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/metrics/__init__.py +0 -0
  28. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/metrics/handler.py +0 -0
  29. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/metrics/registry.py +0 -0
  30. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/models/__init__.py +0 -0
  31. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/models/columns.py +0 -0
  32. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/models/watched.py +0 -0
  33. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/py.typed +0 -0
  34. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/pytest/__init__.py +0 -0
  35. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/pytest/plugin.py +0 -0
  36. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/pytest/utils.py +0 -0
  37. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/schemas.py +0 -0
  38. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/security/__init__.py +0 -0
  39. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/security/abc.py +0 -0
  40. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/security/oauth.py +0 -0
  41. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/security/sources/__init__.py +0 -0
  42. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/security/sources/bearer.py +0 -0
  43. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/security/sources/cookie.py +0 -0
  44. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/security/sources/header.py +0 -0
  45. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/security/sources/multi.py +0 -0
  46. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.0}/src/fastapi_toolsets/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-toolsets
3
- Version: 4.0.0
3
+ Version: 4.1.0
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 = "4.0.0"
3
+ version = "4.1.0"
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__ = "4.0.0"
24
+ __version__ = "4.1.0"
@@ -10,7 +10,7 @@ from collections.abc import Awaitable, Callable, Sequence
10
10
  from datetime import date, datetime
11
11
  from decimal import Decimal
12
12
  from enum import Enum
13
- from typing import Any, ClassVar, Generic, Literal, Self, cast, overload
13
+ from typing import Any, ClassVar, Generic, Literal, Self, TypeAlias, cast, overload
14
14
 
15
15
  from fastapi import Query
16
16
  from pydantic import BaseModel
@@ -52,6 +52,19 @@ from .search import (
52
52
  )
53
53
 
54
54
 
55
+ _ForUpdateMode: TypeAlias = bool | Literal["nowait", "skip_locked"]
56
+
57
+
58
+ def _apply_for_update(q: Any, mode: _ForUpdateMode) -> Any:
59
+ if not mode:
60
+ return q
61
+ if mode == "nowait":
62
+ return q.with_for_update(nowait=True)
63
+ if mode == "skip_locked":
64
+ return q.with_for_update(skip_locked=True)
65
+ return q.with_for_update()
66
+
67
+
55
68
  class _CursorDirection(str, Enum):
56
69
  NEXT = "next"
57
70
  PREV = "prev"
@@ -733,7 +746,7 @@ class AsyncCrud(Generic[ModelType]):
733
746
  *,
734
747
  joins: JoinType | None = None,
735
748
  outer_join: bool = False,
736
- with_for_update: bool = False,
749
+ with_for_update: _ForUpdateMode = False,
737
750
  load_options: Sequence[ExecutableOption] | None = None,
738
751
  schema: type[SchemaType],
739
752
  ) -> Response[SchemaType]: ...
@@ -747,7 +760,7 @@ class AsyncCrud(Generic[ModelType]):
747
760
  *,
748
761
  joins: JoinType | None = None,
749
762
  outer_join: bool = False,
750
- with_for_update: bool = False,
763
+ with_for_update: _ForUpdateMode = False,
751
764
  load_options: Sequence[ExecutableOption] | None = None,
752
765
  schema: None = ...,
753
766
  ) -> ModelType: ...
@@ -760,7 +773,7 @@ class AsyncCrud(Generic[ModelType]):
760
773
  *,
761
774
  joins: JoinType | None = None,
762
775
  outer_join: bool = False,
763
- with_for_update: bool = False,
776
+ with_for_update: _ForUpdateMode = False,
764
777
  load_options: Sequence[ExecutableOption] | None = None,
765
778
  schema: type[BaseModel] | None = None,
766
779
  ) -> ModelType | Response[Any]:
@@ -805,7 +818,7 @@ class AsyncCrud(Generic[ModelType]):
805
818
  *,
806
819
  joins: JoinType | None = None,
807
820
  outer_join: bool = False,
808
- with_for_update: bool = False,
821
+ with_for_update: _ForUpdateMode = False,
809
822
  load_options: Sequence[ExecutableOption] | None = None,
810
823
  schema: type[SchemaType],
811
824
  ) -> Response[SchemaType] | None: ...
@@ -819,7 +832,7 @@ class AsyncCrud(Generic[ModelType]):
819
832
  *,
820
833
  joins: JoinType | None = None,
821
834
  outer_join: bool = False,
822
- with_for_update: bool = False,
835
+ with_for_update: _ForUpdateMode = False,
823
836
  load_options: Sequence[ExecutableOption] | None = None,
824
837
  schema: None = ...,
825
838
  ) -> ModelType | None: ...
@@ -832,7 +845,7 @@ class AsyncCrud(Generic[ModelType]):
832
845
  *,
833
846
  joins: JoinType | None = None,
834
847
  outer_join: bool = False,
835
- with_for_update: bool = False,
848
+ with_for_update: _ForUpdateMode = False,
836
849
  load_options: Sequence[ExecutableOption] | None = None,
837
850
  schema: type[BaseModel] | None = None,
838
851
  ) -> ModelType | Response[Any] | None:
@@ -864,8 +877,7 @@ class AsyncCrud(Generic[ModelType]):
864
877
  q = q.where(and_(*filters))
865
878
  if resolved := cls._resolve_load_options(load_options):
866
879
  q = q.options(*resolved)
867
- if with_for_update:
868
- q = q.with_for_update()
880
+ q = _apply_for_update(q, with_for_update)
869
881
  result = await session.execute(q)
870
882
  item = result.unique().scalar_one_or_none()
871
883
  if item is None:
@@ -884,7 +896,7 @@ class AsyncCrud(Generic[ModelType]):
884
896
  *,
885
897
  joins: JoinType | None = None,
886
898
  outer_join: bool = False,
887
- with_for_update: bool = False,
899
+ with_for_update: _ForUpdateMode = False,
888
900
  load_options: Sequence[ExecutableOption] | None = None,
889
901
  schema: type[SchemaType],
890
902
  ) -> Response[SchemaType] | None: ...
@@ -898,7 +910,7 @@ class AsyncCrud(Generic[ModelType]):
898
910
  *,
899
911
  joins: JoinType | None = None,
900
912
  outer_join: bool = False,
901
- with_for_update: bool = False,
913
+ with_for_update: _ForUpdateMode = False,
902
914
  load_options: Sequence[ExecutableOption] | None = None,
903
915
  schema: None = ...,
904
916
  ) -> ModelType | None: ...
@@ -911,7 +923,7 @@ class AsyncCrud(Generic[ModelType]):
911
923
  *,
912
924
  joins: JoinType | None = None,
913
925
  outer_join: bool = False,
914
- with_for_update: bool = False,
926
+ with_for_update: _ForUpdateMode = False,
915
927
  load_options: Sequence[ExecutableOption] | None = None,
916
928
  schema: type[BaseModel] | None = None,
917
929
  ) -> ModelType | Response[Any] | None:
@@ -937,8 +949,7 @@ class AsyncCrud(Generic[ModelType]):
937
949
  q = q.where(and_(*filters))
938
950
  if resolved := cls._resolve_load_options(load_options):
939
951
  q = q.options(*resolved)
940
- if with_for_update:
941
- q = q.with_for_update()
952
+ q = _apply_for_update(q, with_for_update)
942
953
  result = await session.execute(q)
943
954
  item = result.unique().scalars().first()
944
955
  if item is None:
@@ -956,6 +967,7 @@ class AsyncCrud(Generic[ModelType]):
956
967
  filters: list[Any] | None = None,
957
968
  joins: JoinType | None = None,
958
969
  outer_join: bool = False,
970
+ with_for_update: _ForUpdateMode = False,
959
971
  load_options: Sequence[ExecutableOption] | None = None,
960
972
  order_by: OrderByClause | None = None,
961
973
  limit: int | None = None,
@@ -968,6 +980,9 @@ class AsyncCrud(Generic[ModelType]):
968
980
  filters: List of SQLAlchemy filter conditions
969
981
  joins: List of (model, condition) tuples for joining related tables
970
982
  outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
983
+ with_for_update: Lock rows for update. ``True`` for plain ``FOR UPDATE``,
984
+ ``"nowait"`` for ``FOR UPDATE NOWAIT``, ``"skip_locked"`` for
985
+ ``FOR UPDATE SKIP LOCKED``.
971
986
  load_options: SQLAlchemy loader options
972
987
  order_by: Column or list of columns to order by
973
988
  limit: Max number of rows to return
@@ -982,6 +997,7 @@ class AsyncCrud(Generic[ModelType]):
982
997
  q = q.where(and_(*filters))
983
998
  if resolved := cls._resolve_load_options(load_options):
984
999
  q = q.options(*resolved)
1000
+ q = _apply_for_update(q, with_for_update)
985
1001
  if order_by is not None:
986
1002
  q = q.order_by(order_by)
987
1003
  if offset is not None:
@@ -1001,6 +1017,7 @@ class AsyncCrud(Generic[ModelType]):
1001
1017
  *,
1002
1018
  exclude_unset: bool = True,
1003
1019
  exclude_none: bool = False,
1020
+ with_for_update: _ForUpdateMode = False,
1004
1021
  schema: type[SchemaType],
1005
1022
  ) -> Response[SchemaType]: ...
1006
1023
 
@@ -1014,6 +1031,7 @@ class AsyncCrud(Generic[ModelType]):
1014
1031
  *,
1015
1032
  exclude_unset: bool = True,
1016
1033
  exclude_none: bool = False,
1034
+ with_for_update: _ForUpdateMode = False,
1017
1035
  schema: None = ...,
1018
1036
  ) -> ModelType: ...
1019
1037
 
@@ -1026,6 +1044,7 @@ class AsyncCrud(Generic[ModelType]):
1026
1044
  *,
1027
1045
  exclude_unset: bool = True,
1028
1046
  exclude_none: bool = False,
1047
+ with_for_update: _ForUpdateMode = False,
1029
1048
  schema: type[BaseModel] | None = None,
1030
1049
  ) -> ModelType | Response[Any]:
1031
1050
  """Update a record in the database.
@@ -1036,6 +1055,9 @@ class AsyncCrud(Generic[ModelType]):
1036
1055
  filters: List of SQLAlchemy filter conditions
1037
1056
  exclude_unset: Exclude fields not explicitly set in the schema
1038
1057
  exclude_none: Exclude fields with None value
1058
+ with_for_update: Lock the row before updating. ``True`` for plain
1059
+ ``FOR UPDATE``, ``"nowait"`` for ``FOR UPDATE NOWAIT``,
1060
+ ``"skip_locked"`` for ``FOR UPDATE SKIP LOCKED``.
1039
1061
  schema: Pydantic schema to serialize the result into. When provided,
1040
1062
  the result is automatically wrapped in a ``Response[schema]``.
1041
1063
 
@@ -1059,6 +1081,7 @@ class AsyncCrud(Generic[ModelType]):
1059
1081
  db_model = await cls.get(
1060
1082
  session=session,
1061
1083
  filters=filters,
1084
+ with_for_update=with_for_update,
1062
1085
  load_options=m2m_load_options or None,
1063
1086
  )
1064
1087
  values = obj.model_dump(
@@ -16,6 +16,7 @@ from .exceptions import NotFoundError
16
16
 
17
17
  __all__ = [
18
18
  "LockMode",
19
+ "advisory_lock",
19
20
  "cleanup_tables",
20
21
  "create_database",
21
22
  "create_db_context",
@@ -204,6 +205,78 @@ def lock_tables(
204
205
  return _lock()
205
206
 
206
207
 
208
+ @asynccontextmanager
209
+ async def advisory_lock(
210
+ session: AsyncSession,
211
+ key: int | tuple[int, int],
212
+ *,
213
+ shared: bool = False,
214
+ nowait: bool = False,
215
+ timeout: str | None = None,
216
+ ) -> AsyncGenerator[bool, None]:
217
+ """Acquire a PostgreSQL session-level advisory lock.
218
+
219
+ Args:
220
+ session: AsyncSession instance.
221
+ key: Lock key — a single ``int`` (bigint) or a ``(int, int)`` pair for namespacing.
222
+ shared: Acquire a shared lock (multiple holders allowed). Default is exclusive.
223
+ nowait: Return ``False`` immediately if the lock is unavailable instead of waiting.
224
+ timeout: Maximum wait time (e.g. ``"5s"``, ``"500ms"``). Raises ``DBAPIError``
225
+ if exceeded. Ignored when *nowait* is ``True``.
226
+
227
+ Yields:
228
+ ``True`` if the lock was acquired, ``False`` if *nowait* is ``True`` and the lock
229
+ is already held.
230
+
231
+ Raises:
232
+ sqlalchemy.exc.DBAPIError: If *timeout* is set and the lock cannot be acquired
233
+ in time.
234
+
235
+ Example:
236
+ ```python
237
+ from fastapi_toolsets.db import advisory_lock
238
+
239
+ async with advisory_lock(session, 42):
240
+ ...
241
+
242
+ async with advisory_lock(session, 42, nowait=True) as acquired:
243
+ if not acquired:
244
+ raise HTTPException(409, "Resource is locked")
245
+
246
+ async with advisory_lock(session, 42, timeout="5s"):
247
+ ...
248
+
249
+ async with advisory_lock(session, (1, user_id), shared=True):
250
+ ...
251
+ ```
252
+ """
253
+ suffix = "_shared" if shared else ""
254
+ acquire_fn = f"{'pg_try_advisory_lock' if nowait else 'pg_advisory_lock'}{suffix}"
255
+ release_fn = f"pg_advisory_unlock{suffix}"
256
+
257
+ if isinstance(key, tuple):
258
+ k1, k2 = key
259
+ args = "CAST(:k1 AS integer), CAST(:k2 AS integer)"
260
+ params: dict[str, int] = {"k1": k1, "k2": k2}
261
+ else:
262
+ args = ":k"
263
+ params = {"k": key}
264
+
265
+ acquire_sql = text(f"SELECT {acquire_fn}({args})")
266
+ release_sql = text(f"SELECT {release_fn}({args})")
267
+
268
+ if timeout is not None and not nowait:
269
+ await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
270
+
271
+ result = await session.execute(acquire_sql, params)
272
+ acquired = result.scalar() if nowait else True
273
+ try:
274
+ yield acquired
275
+ finally:
276
+ if acquired:
277
+ await session.execute(release_sql, params)
278
+
279
+
207
280
  async def create_database(
208
281
  db_name: str,
209
282
  *,