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.
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/PKG-INFO +1 -1
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/pyproject.toml +1 -1
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/__init__.py +1 -1
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/db.py +35 -5
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/exceptions/__init__.py +4 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/exceptions/exceptions.py +29 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/pytest/utils.py +41 -10
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/LICENSE +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/README.md +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/_imports.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/__init__.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/app.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/config.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/pyproject.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/utils.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/crud/__init__.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/crud/factory.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/crud/search.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/dependencies.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/exceptions/handler.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/fixtures/enum.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/fixtures/registry.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/fixtures/utils.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/logger.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/metrics/__init__.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/metrics/handler.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/metrics/registry.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/models/__init__.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/models/columns.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/models/watched.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/py.typed +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/pytest/__init__.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/pytest/plugin.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/schemas.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/__init__.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/abc.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/oauth.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/__init__.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/bearer.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/cookie.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/header.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/multi.py +0 -0
- {fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/types.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
{fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/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.1.0 → fastapi_toolsets-4.1.2}/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]]:
|
|
@@ -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
|
|
78
|
-
|
|
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/
|
|
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
|
-
|
|
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=
|
|
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(
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/cli/commands/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/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
|
|
File without changes
|
{fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/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
|
{fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/bearer.py
RENAMED
|
File without changes
|
{fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/cookie.py
RENAMED
|
File without changes
|
{fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/header.py
RENAMED
|
File without changes
|
{fastapi_toolsets-4.1.0 → fastapi_toolsets-4.1.2}/src/fastapi_toolsets/security/sources/multi.py
RENAMED
|
File without changes
|
|
File without changes
|