fastapi-toolsets 4.1.0__tar.gz → 4.1.2__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.1.0 → fastapi_toolsets-4.1.2}/PKG-INFO +1 -1
  2. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/pyproject.toml +1 -1
  3. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/__init__.py +1 -1
  4. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/db.py +35 -5
  5. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/exceptions/__init__.py +4 -0
  6. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/exceptions/exceptions.py +29 -0
  7. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/pytest/utils.py +41 -10
  8. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/LICENSE +0 -0
  9. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/README.md +0 -0
  10. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/_imports.py +0 -0
  11. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/__init__.py +0 -0
  12. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/app.py +0 -0
  13. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
  14. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
  15. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/config.py +0 -0
  16. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/pyproject.py +0 -0
  17. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/utils.py +0 -0
  18. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/crud/__init__.py +0 -0
  19. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/crud/factory.py +0 -0
  20. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/crud/search.py +0 -0
  21. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/dependencies.py +0 -0
  22. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/exceptions/handler.py +0 -0
  23. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
  24. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/fixtures/enum.py +0 -0
  25. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/fixtures/registry.py +0 -0
  26. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/fixtures/utils.py +0 -0
  27. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/logger.py +0 -0
  28. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/metrics/__init__.py +0 -0
  29. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/metrics/handler.py +0 -0
  30. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/metrics/registry.py +0 -0
  31. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/models/__init__.py +0 -0
  32. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/models/columns.py +0 -0
  33. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/models/watched.py +0 -0
  34. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/py.typed +0 -0
  35. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/pytest/__init__.py +0 -0
  36. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/pytest/plugin.py +0 -0
  37. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/schemas.py +0 -0
  38. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/__init__.py +0 -0
  39. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/abc.py +0 -0
  40. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/oauth.py +0 -0
  41. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/__init__.py +0 -0
  42. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/bearer.py +0 -0
  43. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/cookie.py +0 -0
  44. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/header.py +0 -0
  45. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/multi.py +0 -0
  46. {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/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.1.0
3
+ Version: 4.1.2
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.1.0"
3
+ version = "4.1.2"
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.1.0"
24
+ __version__ = "4.1.2"
@@ -6,13 +6,22 @@ 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",
@@ -65,7 +74,10 @@ def create_db_dependency(
65
74
 
66
75
  async def get_db() -> AsyncGenerator[_SessionT, None]:
67
76
  async with session_maker() as session:
68
- await session.connection()
77
+ try:
78
+ await session.connection()
79
+ except sa_exc.TimeoutError as e:
80
+ raise PoolExhaustedError() from e
69
81
  yield session
70
82
  if session.in_transaction():
71
83
  await session.commit()
@@ -198,6 +210,18 @@ def lock_tables(
198
210
  await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
199
211
  yield session
200
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
201
225
  except BaseException:
202
226
  await session.rollback()
203
227
  raise
@@ -229,8 +253,7 @@ async def advisory_lock(
229
253
  is already held.
230
254
 
231
255
  Raises:
232
- sqlalchemy.exc.DBAPIError: If *timeout* is set and the lock cannot be acquired
233
- in time.
256
+ LockTimeoutError: If *timeout* is set and the lock cannot be acquired in time.
234
257
 
235
258
  Example:
236
259
  ```python
@@ -268,7 +291,14 @@ async def advisory_lock(
268
291
  if timeout is not None and not nowait:
269
292
  await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
270
293
 
271
- result = await session.execute(acquire_sql, params)
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
272
302
  acquired = result.scalar() if nowait else True
273
303
  try:
274
304
  yield acquired
@@ -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
  ]
@@ -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]]:
@@ -7,7 +7,7 @@ from typing import Any
7
7
 
8
8
  from httpx import ASGITransport, AsyncClient
9
9
  from sqlalchemy import text
10
- from sqlalchemy.engine import make_url
10
+ from sqlalchemy.engine import URL, make_url
11
11
  from sqlalchemy.ext.asyncio import (
12
12
  AsyncSession,
13
13
  async_sessionmaker,
@@ -63,6 +63,8 @@ def worker_database_url(database_url: str, default_test_db: str) -> str:
63
63
  async def create_worker_database(
64
64
  database_url: str,
65
65
  default_test_db: str = "test_db",
66
+ *,
67
+ server_url: str | None = None,
66
68
  ) -> AsyncGenerator[str, None]:
67
69
  """Create and drop a per-worker database for pytest-xdist isolation.
68
70
 
@@ -74,10 +76,13 @@ async def create_worker_database(
74
76
  name (e.g. ``_gw0``). Otherwise it is suffixed with *default_test_db*.
75
77
 
76
78
  Args:
77
- database_url: Original database connection URL (used as the server
78
- connection and as the base for the worker database name).
79
+ database_url: Original database connection URL (used as the base for
80
+ the worker database name).
79
81
  default_test_db: Suffix appended to the database name when
80
82
  ``PYTEST_XDIST_WORKER`` is not set. Defaults to ``"test_db"``.
83
+ server_url: URL used for server-level DDL (must point to an existing
84
+ database on the same server). Defaults to *database_url* with the
85
+ database omitted, letting asyncpg fall back to the username.
81
86
 
82
87
  Yields:
83
88
  The worker-specific database URL.
@@ -86,7 +91,7 @@ async def create_worker_database(
86
91
  ```python
87
92
  from fastapi_toolsets.pytest import create_worker_database, create_db_session
88
93
 
89
- DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost/test_db"
94
+ DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost/myapp"
90
95
 
91
96
  @pytest.fixture(scope="session")
92
97
  async def worker_db_url():
@@ -107,11 +112,21 @@ async def create_worker_database(
107
112
  worker_db_name = make_url(worker_url).database
108
113
  assert worker_db_name is not None
109
114
 
110
- engine = create_async_engine(database_url, isolation_level="AUTOCOMMIT")
115
+ _parsed = make_url(database_url)
116
+ _server_url = server_url or URL.create(
117
+ drivername=_parsed.drivername,
118
+ username=_parsed.username,
119
+ password=_parsed.password,
120
+ host=_parsed.host,
121
+ port=_parsed.port,
122
+ query=_parsed.query,
123
+ ).render_as_string(hide_password=False)
124
+
125
+ engine = create_async_engine(_server_url, isolation_level="AUTOCOMMIT")
111
126
  try:
112
127
  async with engine.connect() as conn:
113
128
  await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
114
- await create_database(db_name=worker_db_name, server_url=database_url)
129
+ await create_database(db_name=worker_db_name, server_url=_server_url)
115
130
 
116
131
  yield worker_url
117
132
 
@@ -126,6 +141,7 @@ async def create_async_client(
126
141
  app: Any,
127
142
  base_url: str = "http://test",
128
143
  dependency_overrides: dict[Callable[..., Any], Callable[..., Any]] | None = None,
144
+ **kwargs: Any,
129
145
  ) -> AsyncGenerator[AsyncClient, None]:
130
146
  """Create an async httpx client for testing FastAPI applications.
131
147
 
@@ -135,6 +151,9 @@ async def create_async_client(
135
151
  dependency_overrides: Optional mapping of original dependencies to
136
152
  their test replacements. Applied via ``app.dependency_overrides``
137
153
  before yielding and cleaned up after.
154
+ **kwargs: Additional keyword arguments forwarded to
155
+ :class:`httpx.AsyncClient` (e.g. ``headers``, ``cookies``,
156
+ ``auth``, ``timeout``).
138
157
 
139
158
  Yields:
140
159
  An AsyncClient configured for the app.
@@ -182,7 +201,9 @@ async def create_async_client(
182
201
 
183
202
  transport = ASGITransport(app=app)
184
203
  try:
185
- async with AsyncClient(transport=transport, base_url=base_url) as client:
204
+ async with AsyncClient(
205
+ transport=transport, base_url=base_url, **kwargs
206
+ ) as client:
186
207
  yield client
187
208
  finally:
188
209
  if dependency_overrides:
@@ -199,6 +220,8 @@ async def create_db_session(
199
220
  expire_on_commit: bool = False,
200
221
  drop_tables: bool = True,
201
222
  cleanup: bool = False,
223
+ engine_kwargs: dict[str, Any] | None = None,
224
+ session_kwargs: dict[str, Any] | None = None,
202
225
  ) -> AsyncGenerator[AsyncSession, None]:
203
226
  """Create a database session for testing.
204
227
 
@@ -213,6 +236,12 @@ async def create_db_session(
213
236
  drop_tables: Drop tables after test. Defaults to True.
214
237
  cleanup: Truncate all tables after test using
215
238
  :func:`cleanup_tables`. Defaults to False.
239
+ engine_kwargs: Additional keyword arguments forwarded to
240
+ :func:`sqlalchemy.ext.asyncio.create_async_engine`
241
+ (e.g. ``pool_size``, ``connect_args``).
242
+ session_kwargs: Additional keyword arguments forwarded to
243
+ :class:`sqlalchemy.ext.asyncio.async_sessionmaker`
244
+ (e.g. ``autoflush``, ``class_``).
216
245
 
217
246
  Yields:
218
247
  An AsyncSession ready for database operations.
@@ -237,15 +266,17 @@ async def create_db_session(
237
266
  await db_session.commit()
238
267
  ```
239
268
  """
240
- engine = create_async_engine(database_url, echo=echo)
269
+ engine = create_async_engine(database_url, echo=echo, **(engine_kwargs or {}))
241
270
 
242
271
  try:
243
- # Create tables
244
272
  async with engine.begin() as conn:
245
273
  await conn.run_sync(base.metadata.create_all)
246
274
 
247
275
  session_maker = async_sessionmaker(
248
- engine, expire_on_commit=expire_on_commit, class_=EventSession
276
+ engine,
277
+ expire_on_commit=expire_on_commit,
278
+ class_=EventSession,
279
+ **(session_kwargs or {}),
249
280
  )
250
281
  async with session_maker() as session:
251
282
  yield session