fastapi-toolsets 4.0.0__tar.gz → 4.1.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-4.0.0 → fastapi_toolsets-4.1.1}/PKG-INFO +1 -1
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/pyproject.toml +1 -1
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/__init__.py +1 -1
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/crud/factory.py +37 -14
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/db.py +105 -2
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/exceptions/__init__.py +4 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/exceptions/exceptions.py +29 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/LICENSE +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/README.md +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/_imports.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/__init__.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/app.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/config.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/pyproject.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/utils.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/crud/__init__.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/crud/search.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/dependencies.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/exceptions/handler.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/fixtures/enum.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/fixtures/registry.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/fixtures/utils.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/logger.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/metrics/__init__.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/metrics/handler.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/metrics/registry.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/models/__init__.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/models/columns.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/models/watched.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/py.typed +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/pytest/__init__.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/pytest/plugin.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/pytest/utils.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/schemas.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/__init__.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/abc.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/oauth.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/__init__.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/bearer.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/cookie.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/header.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/multi.py +0 -0
- {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/types.py +0 -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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(
|
|
@@ -6,16 +6,26 @@ from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
|
6
6
|
from enum import Enum
|
|
7
7
|
from typing import Any, TypeVar, cast
|
|
8
8
|
|
|
9
|
+
import asyncpg
|
|
9
10
|
from sqlalchemy import Table, delete, text, tuple_
|
|
11
|
+
from sqlalchemy import exc as sa_exc
|
|
10
12
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
11
13
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
12
14
|
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
|
13
15
|
from sqlalchemy.orm.relationships import RelationshipProperty
|
|
14
16
|
|
|
15
|
-
from .exceptions import NotFoundError
|
|
17
|
+
from .exceptions import LockTimeoutError, NotFoundError, PoolExhaustedError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_lock_not_available(e: sa_exc.DBAPIError) -> bool:
|
|
21
|
+
return e.orig is not None and isinstance(
|
|
22
|
+
e.orig.__cause__, asyncpg.exceptions.LockNotAvailableError
|
|
23
|
+
)
|
|
24
|
+
|
|
16
25
|
|
|
17
26
|
__all__ = [
|
|
18
27
|
"LockMode",
|
|
28
|
+
"advisory_lock",
|
|
19
29
|
"cleanup_tables",
|
|
20
30
|
"create_database",
|
|
21
31
|
"create_db_context",
|
|
@@ -64,7 +74,10 @@ def create_db_dependency(
|
|
|
64
74
|
|
|
65
75
|
async def get_db() -> AsyncGenerator[_SessionT, None]:
|
|
66
76
|
async with session_maker() as session:
|
|
67
|
-
|
|
77
|
+
try:
|
|
78
|
+
await session.connection()
|
|
79
|
+
except sa_exc.TimeoutError as e:
|
|
80
|
+
raise PoolExhaustedError() from e
|
|
68
81
|
yield session
|
|
69
82
|
if session.in_transaction():
|
|
70
83
|
await session.commit()
|
|
@@ -197,6 +210,18 @@ def lock_tables(
|
|
|
197
210
|
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
|
|
198
211
|
yield session
|
|
199
212
|
await session.commit()
|
|
213
|
+
except sa_exc.TimeoutError as e:
|
|
214
|
+
await session.rollback()
|
|
215
|
+
raise PoolExhaustedError(
|
|
216
|
+
f"Connection pool exhausted while locking '{table_names}'. "
|
|
217
|
+
) from e
|
|
218
|
+
except sa_exc.DBAPIError as e:
|
|
219
|
+
await session.rollback()
|
|
220
|
+
if _is_lock_not_available(e):
|
|
221
|
+
raise LockTimeoutError(
|
|
222
|
+
f"Lock on '{table_names}' could not be acquired within {timeout}."
|
|
223
|
+
) from e
|
|
224
|
+
raise # pragma: no cover
|
|
200
225
|
except BaseException:
|
|
201
226
|
await session.rollback()
|
|
202
227
|
raise
|
|
@@ -204,6 +229,84 @@ def lock_tables(
|
|
|
204
229
|
return _lock()
|
|
205
230
|
|
|
206
231
|
|
|
232
|
+
@asynccontextmanager
|
|
233
|
+
async def advisory_lock(
|
|
234
|
+
session: AsyncSession,
|
|
235
|
+
key: int | tuple[int, int],
|
|
236
|
+
*,
|
|
237
|
+
shared: bool = False,
|
|
238
|
+
nowait: bool = False,
|
|
239
|
+
timeout: str | None = None,
|
|
240
|
+
) -> AsyncGenerator[bool, None]:
|
|
241
|
+
"""Acquire a PostgreSQL session-level advisory lock.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
session: AsyncSession instance.
|
|
245
|
+
key: Lock key — a single ``int`` (bigint) or a ``(int, int)`` pair for namespacing.
|
|
246
|
+
shared: Acquire a shared lock (multiple holders allowed). Default is exclusive.
|
|
247
|
+
nowait: Return ``False`` immediately if the lock is unavailable instead of waiting.
|
|
248
|
+
timeout: Maximum wait time (e.g. ``"5s"``, ``"500ms"``). Raises ``DBAPIError``
|
|
249
|
+
if exceeded. Ignored when *nowait* is ``True``.
|
|
250
|
+
|
|
251
|
+
Yields:
|
|
252
|
+
``True`` if the lock was acquired, ``False`` if *nowait* is ``True`` and the lock
|
|
253
|
+
is already held.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
LockTimeoutError: If *timeout* is set and the lock cannot be acquired in time.
|
|
257
|
+
|
|
258
|
+
Example:
|
|
259
|
+
```python
|
|
260
|
+
from fastapi_toolsets.db import advisory_lock
|
|
261
|
+
|
|
262
|
+
async with advisory_lock(session, 42):
|
|
263
|
+
...
|
|
264
|
+
|
|
265
|
+
async with advisory_lock(session, 42, nowait=True) as acquired:
|
|
266
|
+
if not acquired:
|
|
267
|
+
raise HTTPException(409, "Resource is locked")
|
|
268
|
+
|
|
269
|
+
async with advisory_lock(session, 42, timeout="5s"):
|
|
270
|
+
...
|
|
271
|
+
|
|
272
|
+
async with advisory_lock(session, (1, user_id), shared=True):
|
|
273
|
+
...
|
|
274
|
+
```
|
|
275
|
+
"""
|
|
276
|
+
suffix = "_shared" if shared else ""
|
|
277
|
+
acquire_fn = f"{'pg_try_advisory_lock' if nowait else 'pg_advisory_lock'}{suffix}"
|
|
278
|
+
release_fn = f"pg_advisory_unlock{suffix}"
|
|
279
|
+
|
|
280
|
+
if isinstance(key, tuple):
|
|
281
|
+
k1, k2 = key
|
|
282
|
+
args = "CAST(:k1 AS integer), CAST(:k2 AS integer)"
|
|
283
|
+
params: dict[str, int] = {"k1": k1, "k2": k2}
|
|
284
|
+
else:
|
|
285
|
+
args = ":k"
|
|
286
|
+
params = {"k": key}
|
|
287
|
+
|
|
288
|
+
acquire_sql = text(f"SELECT {acquire_fn}({args})")
|
|
289
|
+
release_sql = text(f"SELECT {release_fn}({args})")
|
|
290
|
+
|
|
291
|
+
if timeout is not None and not nowait:
|
|
292
|
+
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
result = await session.execute(acquire_sql, params)
|
|
296
|
+
except sa_exc.DBAPIError as e:
|
|
297
|
+
if _is_lock_not_available(e):
|
|
298
|
+
raise LockTimeoutError(
|
|
299
|
+
f"Advisory lock {key!r} could not be acquired within {timeout}."
|
|
300
|
+
) from e
|
|
301
|
+
raise # pragma: no cover
|
|
302
|
+
acquired = result.scalar() if nowait else True
|
|
303
|
+
try:
|
|
304
|
+
yield acquired
|
|
305
|
+
finally:
|
|
306
|
+
if acquired:
|
|
307
|
+
await session.execute(release_sql, params)
|
|
308
|
+
|
|
309
|
+
|
|
207
310
|
async def create_database(
|
|
208
311
|
db_name: str,
|
|
209
312
|
*,
|
{fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/exceptions/__init__.py
RENAMED
|
@@ -8,8 +8,10 @@ from .exceptions import (
|
|
|
8
8
|
InvalidFacetFilterError,
|
|
9
9
|
InvalidOrderFieldError,
|
|
10
10
|
InvalidSearchColumnError,
|
|
11
|
+
LockTimeoutError,
|
|
11
12
|
NoSearchableFieldsError,
|
|
12
13
|
NotFoundError,
|
|
14
|
+
PoolExhaustedError,
|
|
13
15
|
UnauthorizedError,
|
|
14
16
|
UnsupportedFacetTypeError,
|
|
15
17
|
generate_error_responses,
|
|
@@ -26,8 +28,10 @@ __all__ = [
|
|
|
26
28
|
"InvalidFacetFilterError",
|
|
27
29
|
"InvalidOrderFieldError",
|
|
28
30
|
"InvalidSearchColumnError",
|
|
31
|
+
"LockTimeoutError",
|
|
29
32
|
"NoSearchableFieldsError",
|
|
30
33
|
"NotFoundError",
|
|
34
|
+
"PoolExhaustedError",
|
|
31
35
|
"UnauthorizedError",
|
|
32
36
|
"UnsupportedFacetTypeError",
|
|
33
37
|
]
|
{fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/exceptions/exceptions.py
RENAMED
|
@@ -223,6 +223,35 @@ class InvalidOrderFieldError(ApiException):
|
|
|
223
223
|
)
|
|
224
224
|
|
|
225
225
|
|
|
226
|
+
class PoolExhaustedError(ApiException):
|
|
227
|
+
"""HTTP 503 - Database connection pool is exhausted."""
|
|
228
|
+
|
|
229
|
+
api_error = ApiError(
|
|
230
|
+
code=503,
|
|
231
|
+
msg="Service Unavailable",
|
|
232
|
+
desc=(
|
|
233
|
+
"The database connection pool is exhausted. "
|
|
234
|
+
"Too many concurrent requests are holding connections. "
|
|
235
|
+
"Retry shortly or contact support if the issue persists."
|
|
236
|
+
),
|
|
237
|
+
err_code="DB-503-POOL",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class LockTimeoutError(ApiException):
|
|
242
|
+
"""HTTP 503 - A database lock could not be acquired within the timeout."""
|
|
243
|
+
|
|
244
|
+
api_error = ApiError(
|
|
245
|
+
code=503,
|
|
246
|
+
msg="Service Unavailable",
|
|
247
|
+
desc=(
|
|
248
|
+
"A database lock could not be acquired within the allowed timeout. "
|
|
249
|
+
"The resource is under heavy contention. Retry shortly."
|
|
250
|
+
),
|
|
251
|
+
err_code="DB-503-LOCK",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
226
255
|
def generate_error_responses(
|
|
227
256
|
*errors: type[ApiException],
|
|
228
257
|
) -> dict[int | str, dict[str, Any]]:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/commands/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.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-4.0.0 → fastapi_toolsets-4.1.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/bearer.py
RENAMED
|
File without changes
|
{fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/cookie.py
RENAMED
|
File without changes
|
{fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/header.py
RENAMED
|
File without changes
|
{fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/multi.py
RENAMED
|
File without changes
|
|
File without changes
|