python3-commons 0.20.2__tar.gz → 0.20.4__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.
- {python3_commons-0.20.2/src/python3_commons.egg-info → python3_commons-0.20.4}/PKG-INFO +2 -2
- {python3_commons-0.20.2 → python3_commons-0.20.4}/pyproject.toml +1 -1
- python3_commons-0.20.4/src/python3_commons/db/__init__.py +262 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4/src/python3_commons.egg-info}/PKG-INFO +2 -2
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons.egg-info/SOURCES.txt +2 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons.egg-info/requires.txt +1 -1
- python3_commons-0.20.4/tests/unit/db/test_async_session_manager.py +75 -0
- python3_commons-0.20.4/tests/unit/log/__init__.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/uv.lock +1 -1
- python3_commons-0.20.2/src/python3_commons/db/__init__.py +0 -101
- {python3_commons-0.20.2 → python3_commons-0.20.4}/.coveragerc +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/.devcontainer/Dockerfile +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/.devcontainer/devcontainer.json +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/.devcontainer/docker-compose.yml +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/.env_template +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/.github/workflows/checks.yml +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/.github/workflows/python-publish.yaml +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/.github/workflows/release-on-tag-push.yml +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/.gitignore +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/.pre-commit-config.yaml +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/.python-version +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/AUTHORS.rst +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/CHANGELOG.rst +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/LICENSE +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/README.md +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/README.rst +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/Makefile +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/_static/.gitignore +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/authors.rst +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/changelog.rst +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/conf.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/index.rst +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/license.rst +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/setup.cfg +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/__init__.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/api_client.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/async_functools.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/audit.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/auth.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/cache.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/conf.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/db/helpers.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/db/models/__init__.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/db/models/auth.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/db/models/common.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/db/models/rbac.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/db/models/users.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/exceptions.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/fs.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/generators.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/helpers.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/log/__init__.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/log/filters.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/log/formatters.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/object_storage.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/permissions.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/serializers/__init__.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/serializers/common.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/serializers/json.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/serializers/msgpack.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/serializers/msgspec.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/soap_client.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons.egg-info/dependency_links.txt +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons.egg-info/top_level.txt +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/__init__.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/integration/__init__.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/integration/conftest.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/integration/test_auth.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/integration/test_cache.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/integration/test_osc.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/__init__.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/conftest.py +0 -0
- {python3_commons-0.20.2/tests/unit/log → python3_commons-0.20.4/tests/unit/db}/__init__.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/log/test_formatters.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/test_async_functools.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/test_audit.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/test_helpers.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/test_msgpack.py +0 -0
- {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/test_msgspec.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python3-commons
|
|
3
|
-
Version: 0.20.
|
|
3
|
+
Version: 0.20.4
|
|
4
4
|
Summary: Re-usable Python3 code
|
|
5
5
|
Author-email: Oleg Korsak <kamikaze.is.waiting.you@gmail.com>
|
|
6
6
|
License-Expression: GPL-3.0
|
|
@@ -36,7 +36,7 @@ Provides-Extra: cache
|
|
|
36
36
|
Requires-Dist: valkey[libvalkey]~=6.1.1; extra == "cache"
|
|
37
37
|
Provides-Extra: database
|
|
38
38
|
Requires-Dist: asyncpg~=0.31.0; extra == "database"
|
|
39
|
-
Requires-Dist: SQLAlchemy[asyncio]~=2.0.
|
|
39
|
+
Requires-Dist: SQLAlchemy[asyncio]~=2.0.49; extra == "database"
|
|
40
40
|
Provides-Extra: object-storage
|
|
41
41
|
Requires-Dist: aiobotocore~=3.7.0; extra == "object-storage"
|
|
42
42
|
Requires-Dist: object-storage-client==0.0.30; extra == "object-storage"
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import AsyncGenerator, Callable, Mapping
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from sqlalchemy import MetaData, text
|
|
10
|
+
from sqlalchemy.exc import OperationalError, SQLAlchemyError
|
|
11
|
+
from sqlalchemy.exc import TimeoutError as SATimeoutError
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_engine_from_config
|
|
13
|
+
from sqlalchemy.ext.asyncio.session import async_sessionmaker
|
|
14
|
+
from sqlalchemy.orm import declarative_base
|
|
15
|
+
except ImportError as e:
|
|
16
|
+
msg = 'Install python3-commons[database] to use this feature'
|
|
17
|
+
raise RuntimeError(msg) from e
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from python3_commons.conf import DBSettings
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
metadata = MetaData()
|
|
25
|
+
Base = declarative_base(metadata=metadata)
|
|
26
|
+
|
|
27
|
+
# How long (seconds) to wait for a connection from the pool before giving up.
|
|
28
|
+
# This is the main guard against silent hangs.
|
|
29
|
+
_DEFAULT_POOL_ACQUIRE_TIMEOUT = 10
|
|
30
|
+
# Timeout passed to asyncpg for the TCP connect itself.
|
|
31
|
+
_DEFAULT_CONNECT_TIMEOUT = 5
|
|
32
|
+
# Timeout for the health-check query.
|
|
33
|
+
_DEFAULT_HEALTH_CHECK_TIMEOUT = 5
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DatabaseError(Exception):
|
|
37
|
+
"""Base class for session-manager errors."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EngineCreationError(DatabaseError):
|
|
41
|
+
"""Raised when an engine cannot be created."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionAcquireError(DatabaseError):
|
|
45
|
+
"""Raised when a session cannot be acquired within the allotted time."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AsyncSessionManager:
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
db_configs: Mapping[str, DBSettings],
|
|
52
|
+
pool_acquire_timeout: float = _DEFAULT_POOL_ACQUIRE_TIMEOUT,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.db_configs: Mapping[str, DBSettings] = db_configs
|
|
55
|
+
self.pool_acquire_timeout = pool_acquire_timeout
|
|
56
|
+
self.engines: dict[str, AsyncEngine] = {}
|
|
57
|
+
self.session_makers: dict[str, async_sessionmaker] = {}
|
|
58
|
+
|
|
59
|
+
def get_db_config(self, name: str) -> DBSettings:
|
|
60
|
+
try:
|
|
61
|
+
return self.db_configs[name]
|
|
62
|
+
except KeyError:
|
|
63
|
+
logger.exception('Missing database config for key %r. Available: %s', name, list(self.db_configs))
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
def _build_engine(self, name: str) -> AsyncEngine:
|
|
67
|
+
db_config = self.get_db_config(name)
|
|
68
|
+
dsn = db_config.dsn
|
|
69
|
+
|
|
70
|
+
logger.debug('Building engine for %r (dsn=%s)', name, dsn)
|
|
71
|
+
|
|
72
|
+
configuration = {
|
|
73
|
+
'url': dsn,
|
|
74
|
+
'echo': db_config.echo,
|
|
75
|
+
'pool_size': db_config.pool_size,
|
|
76
|
+
'max_overflow': db_config.max_overflow,
|
|
77
|
+
# pool_timeout: seconds to wait for a conn from the pool.
|
|
78
|
+
# Falls back to default so the pool never blocks forever.
|
|
79
|
+
'pool_timeout': getattr(db_config, 'pool_timeout', _DEFAULT_POOL_ACQUIRE_TIMEOUT),
|
|
80
|
+
'pool_recycle': db_config.pool_recycle,
|
|
81
|
+
'pool_pre_ping': db_config.pool_pre_ping,
|
|
82
|
+
}
|
|
83
|
+
connect_args = {'timeout': _DEFAULT_CONNECT_TIMEOUT}
|
|
84
|
+
# For asyncpg, command_timeout provides a per-statement timeout.
|
|
85
|
+
if 'postgresql' in dsn:
|
|
86
|
+
connect_args['command_timeout'] = float(db_config.statement_timeout)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
engine = async_engine_from_config(
|
|
90
|
+
configuration,
|
|
91
|
+
prefix='',
|
|
92
|
+
connect_args=connect_args,
|
|
93
|
+
)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.exception('Failed to create engine for %r', name)
|
|
96
|
+
|
|
97
|
+
msg = f'Could not create engine for {name!r}'
|
|
98
|
+
raise EngineCreationError(msg) from e
|
|
99
|
+
|
|
100
|
+
logger.info(
|
|
101
|
+
'Engine created for %r (pool_size=%s, max_overflow=%s, pool_timeout=%s)',
|
|
102
|
+
name,
|
|
103
|
+
configuration['pool_size'],
|
|
104
|
+
configuration['max_overflow'],
|
|
105
|
+
configuration['pool_timeout'],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return engine
|
|
109
|
+
|
|
110
|
+
def get_engine(self, name: str) -> AsyncEngine:
|
|
111
|
+
try:
|
|
112
|
+
return self.engines[name]
|
|
113
|
+
except KeyError:
|
|
114
|
+
engine = self._build_engine(name)
|
|
115
|
+
self.engines[name] = engine
|
|
116
|
+
|
|
117
|
+
return engine
|
|
118
|
+
|
|
119
|
+
async def dispose_engine(self, name: str) -> None:
|
|
120
|
+
"""Gracefully close all pooled connections for *name*."""
|
|
121
|
+
engine = self.engines.pop(name, None)
|
|
122
|
+
self.session_makers.pop(name, None)
|
|
123
|
+
|
|
124
|
+
if engine is not None:
|
|
125
|
+
logger.info('Disposing engine for %r', name)
|
|
126
|
+
|
|
127
|
+
await engine.dispose()
|
|
128
|
+
|
|
129
|
+
async def dispose_all(self) -> None:
|
|
130
|
+
"""Gracefully close every managed engine."""
|
|
131
|
+
for name in list(self.engines):
|
|
132
|
+
await self.dispose_engine(name)
|
|
133
|
+
|
|
134
|
+
def get_session_maker(self, name: str) -> async_sessionmaker:
|
|
135
|
+
try:
|
|
136
|
+
return self.session_makers[name]
|
|
137
|
+
except KeyError:
|
|
138
|
+
logger.debug('Creating session maker for %r', name)
|
|
139
|
+
engine = self.get_engine(name)
|
|
140
|
+
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
141
|
+
self.session_makers[name] = session_maker
|
|
142
|
+
|
|
143
|
+
return session_maker
|
|
144
|
+
|
|
145
|
+
def get_async_session(self, name: str) -> Callable[[], AsyncGenerator[AsyncSession]]:
|
|
146
|
+
"""
|
|
147
|
+
Return a dependency-injection-friendly async generator that yields a
|
|
148
|
+
session, wrapped in an outer timeout so it can never hang silently.
|
|
149
|
+
|
|
150
|
+
Usage (FastAPI / any DI framework):
|
|
151
|
+
session: AsyncSession = Depends(manager.get_async_session("default"))
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
async def get_session() -> AsyncGenerator[AsyncSession]:
|
|
155
|
+
session_acquired = False
|
|
156
|
+
session_maker = self.get_session_maker(name)
|
|
157
|
+
t0 = time.monotonic()
|
|
158
|
+
logger.debug('Acquiring session for %r', name)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
async with asyncio.timeout(self.pool_acquire_timeout):
|
|
162
|
+
async with session_maker() as session:
|
|
163
|
+
session_acquired = True
|
|
164
|
+
elapsed = time.monotonic() - t0
|
|
165
|
+
logger.debug('Session acquired for %r in %.3fs', name, elapsed)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
yield session
|
|
169
|
+
except Exception:
|
|
170
|
+
logger.exception('Database communication error for %r; rolling back', name)
|
|
171
|
+
await session.rollback()
|
|
172
|
+
|
|
173
|
+
raise
|
|
174
|
+
except TimeoutError as e:
|
|
175
|
+
if session_acquired:
|
|
176
|
+
raise
|
|
177
|
+
|
|
178
|
+
elapsed = time.monotonic() - t0
|
|
179
|
+
logger.exception(
|
|
180
|
+
'Timed out waiting for a session for %r after %.3fs (limit=%.1fs)',
|
|
181
|
+
name,
|
|
182
|
+
elapsed,
|
|
183
|
+
self.pool_acquire_timeout,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
msg = f'Could not acquire a session for {name!r} within {self.pool_acquire_timeout}s'
|
|
187
|
+
raise SessionAcquireError(msg) from e
|
|
188
|
+
except (OperationalError, SATimeoutError) as e:
|
|
189
|
+
if session_acquired:
|
|
190
|
+
raise
|
|
191
|
+
|
|
192
|
+
elapsed = time.monotonic() - t0
|
|
193
|
+
|
|
194
|
+
logger.exception('DB error acquiring session for %r after %.3fs', name, elapsed)
|
|
195
|
+
|
|
196
|
+
msg = f'DB error for {name!r}: {e}'
|
|
197
|
+
raise SessionAcquireError(msg) from e
|
|
198
|
+
except SQLAlchemyError as e:
|
|
199
|
+
if session_acquired:
|
|
200
|
+
raise
|
|
201
|
+
|
|
202
|
+
logger.exception('Unexpected SQLAlchemy error for %r', name)
|
|
203
|
+
|
|
204
|
+
msg = f'SQLAlchemy error for {name!r}'
|
|
205
|
+
|
|
206
|
+
raise SessionAcquireError(msg) from e
|
|
207
|
+
|
|
208
|
+
return get_session
|
|
209
|
+
|
|
210
|
+
def get_session_context(self, name: str):
|
|
211
|
+
"""
|
|
212
|
+
Return an async context manager that yields a session.
|
|
213
|
+
|
|
214
|
+
Usage:
|
|
215
|
+
async with manager.get_session_context("default") as session:
|
|
216
|
+
...
|
|
217
|
+
"""
|
|
218
|
+
return contextlib.asynccontextmanager(self.get_async_session(name))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def is_healthy(
|
|
222
|
+
engine: AsyncEngine,
|
|
223
|
+
timeout: float = _DEFAULT_HEALTH_CHECK_TIMEOUT,
|
|
224
|
+
) -> bool:
|
|
225
|
+
"""
|
|
226
|
+
Return True only if the engine can execute a trivial query within *timeout*
|
|
227
|
+
seconds. All failures are caught and logged; never raises.
|
|
228
|
+
"""
|
|
229
|
+
t0 = time.monotonic()
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
async with asyncio.timeout(timeout):
|
|
233
|
+
async with engine.begin() as conn:
|
|
234
|
+
result = await conn.execute(text('SELECT 1'))
|
|
235
|
+
healthy = result.scalar() == 1
|
|
236
|
+
|
|
237
|
+
elapsed = time.monotonic() - t0
|
|
238
|
+
|
|
239
|
+
if healthy:
|
|
240
|
+
logger.debug('Health check passed in %.3fs', elapsed)
|
|
241
|
+
else:
|
|
242
|
+
logger.warning('Health check query returned unexpected result')
|
|
243
|
+
except TimeoutError:
|
|
244
|
+
elapsed = time.monotonic() - t0
|
|
245
|
+
logger.exception('Health check timed out after %.3fs (limit=%.1fs)', elapsed, timeout)
|
|
246
|
+
|
|
247
|
+
return False
|
|
248
|
+
except OperationalError as e:
|
|
249
|
+
msg = f'Health check failed: DB not reachable: {e}'
|
|
250
|
+
logger.exception(msg)
|
|
251
|
+
|
|
252
|
+
return False
|
|
253
|
+
except SQLAlchemyError:
|
|
254
|
+
logger.exception('Health check failed: SQLAlchemy error')
|
|
255
|
+
|
|
256
|
+
return False
|
|
257
|
+
except Exception:
|
|
258
|
+
logger.exception('Health check failed: unexpected error')
|
|
259
|
+
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
return healthy
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python3-commons
|
|
3
|
-
Version: 0.20.
|
|
3
|
+
Version: 0.20.4
|
|
4
4
|
Summary: Re-usable Python3 code
|
|
5
5
|
Author-email: Oleg Korsak <kamikaze.is.waiting.you@gmail.com>
|
|
6
6
|
License-Expression: GPL-3.0
|
|
@@ -36,7 +36,7 @@ Provides-Extra: cache
|
|
|
36
36
|
Requires-Dist: valkey[libvalkey]~=6.1.1; extra == "cache"
|
|
37
37
|
Provides-Extra: database
|
|
38
38
|
Requires-Dist: asyncpg~=0.31.0; extra == "database"
|
|
39
|
-
Requires-Dist: SQLAlchemy[asyncio]~=2.0.
|
|
39
|
+
Requires-Dist: SQLAlchemy[asyncio]~=2.0.49; extra == "database"
|
|
40
40
|
Provides-Extra: object-storage
|
|
41
41
|
Requires-Dist: aiobotocore~=3.7.0; extra == "object-storage"
|
|
42
42
|
Requires-Dist: object-storage-client==0.0.30; extra == "object-storage"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from python3_commons.conf import DBSettings
|
|
6
|
+
from python3_commons.db import AsyncSessionManager
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.asyncio
|
|
10
|
+
async def test_async_session_manager_timeout(mocker, caplog):
|
|
11
|
+
# Mock DBSettings
|
|
12
|
+
db_config = DBSettings(dsn='postgresql+asyncpg://user:pass@localhost/db', statement_timeout=1)
|
|
13
|
+
|
|
14
|
+
manager = AsyncSessionManager(db_configs={'default': db_config})
|
|
15
|
+
|
|
16
|
+
# Mock session and session maker
|
|
17
|
+
mock_session = AsyncMock()
|
|
18
|
+
mock_session_maker = MagicMock()
|
|
19
|
+
# session_maker() returns a context manager
|
|
20
|
+
mock_session_maker.return_value.__aenter__.return_value = mock_session
|
|
21
|
+
|
|
22
|
+
mocker.patch.object(manager, 'get_session_maker', return_value=mock_session_maker)
|
|
23
|
+
|
|
24
|
+
# Mock a driver-level timeout (TimeoutError)
|
|
25
|
+
mock_session.execute.side_effect = TimeoutError()
|
|
26
|
+
|
|
27
|
+
get_session_ctx_func = manager.get_session_context('default')
|
|
28
|
+
|
|
29
|
+
# We want to verify that TimeoutError from the driver is caught,
|
|
30
|
+
# logged as "Database communication error", rolled back, and re-raised.
|
|
31
|
+
with pytest.raises(TimeoutError):
|
|
32
|
+
async with get_session_ctx_func() as session:
|
|
33
|
+
await session.execute('SELECT 1')
|
|
34
|
+
|
|
35
|
+
assert 'Database communication error' in caplog.text
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.asyncio
|
|
39
|
+
async def test_async_session_manager_engine_config(mocker):
|
|
40
|
+
db_config = DBSettings(dsn='postgresql+asyncpg://user:pass@localhost/db', statement_timeout=42)
|
|
41
|
+
manager = AsyncSessionManager(db_configs={'default': db_config})
|
|
42
|
+
|
|
43
|
+
mock_engine_from_config = mocker.patch('python3_commons.db.async_engine_from_config')
|
|
44
|
+
|
|
45
|
+
manager.get_engine('default')
|
|
46
|
+
|
|
47
|
+
# Check that command_timeout was passed to connect_args for asyncpg
|
|
48
|
+
_, kwargs = mock_engine_from_config.call_args
|
|
49
|
+
assert kwargs['connect_args']['command_timeout'] == 42.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_async_session_manager_logging(mocker, caplog):
|
|
54
|
+
# Mock DBSettings
|
|
55
|
+
db_config = DBSettings(dsn='postgresql+asyncpg://user:pass@localhost/db', statement_timeout=1)
|
|
56
|
+
|
|
57
|
+
manager = AsyncSessionManager(db_configs={'default': db_config})
|
|
58
|
+
|
|
59
|
+
# Mock session and session maker
|
|
60
|
+
mock_session = AsyncMock()
|
|
61
|
+
mock_session_maker = MagicMock()
|
|
62
|
+
mock_session_maker.return_value.__aenter__.return_value = mock_session
|
|
63
|
+
|
|
64
|
+
mocker.patch.object(manager, 'get_session_maker', return_value=mock_session_maker)
|
|
65
|
+
|
|
66
|
+
# Mock a session method to fail
|
|
67
|
+
mock_session.execute.side_effect = Exception('DB Error')
|
|
68
|
+
|
|
69
|
+
get_session_ctx_func = manager.get_session_context('default')
|
|
70
|
+
|
|
71
|
+
with pytest.raises(Exception, match='DB Error'):
|
|
72
|
+
async with get_session_ctx_func() as session:
|
|
73
|
+
await session.execute('SELECT 1')
|
|
74
|
+
|
|
75
|
+
assert 'Database communication error' in caplog.text
|
|
File without changes
|
|
@@ -1171,7 +1171,7 @@ requires-dist = [
|
|
|
1171
1171
|
{ name = "python3-commons", extras = ["database"], marker = "extra == 'authz'" },
|
|
1172
1172
|
{ name = "python3-commons", extras = ["object-storage"], marker = "extra == 'audit'" },
|
|
1173
1173
|
{ name = "requests", marker = "extra == 'soap-client'", specifier = "~=2.34.2" },
|
|
1174
|
-
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = "~=2.0.
|
|
1174
|
+
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = "~=2.0.49" },
|
|
1175
1175
|
{ name = "valkey", extras = ["libvalkey"], marker = "extra == 'cache'", specifier = "~=6.1.1" },
|
|
1176
1176
|
{ name = "zeep", extras = ["async"], marker = "extra == 'audit'", specifier = "~=4.3.2" },
|
|
1177
1177
|
{ name = "zeep", extras = ["async"], marker = "extra == 'soap-client'", specifier = "~=4.3.2" },
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import contextlib
|
|
2
|
-
import logging
|
|
3
|
-
from collections.abc import AsyncGenerator, Callable, Mapping
|
|
4
|
-
from typing import TYPE_CHECKING
|
|
5
|
-
|
|
6
|
-
try:
|
|
7
|
-
from sqlalchemy import MetaData
|
|
8
|
-
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_engine_from_config
|
|
9
|
-
from sqlalchemy.ext.asyncio.session import async_sessionmaker
|
|
10
|
-
from sqlalchemy.orm import declarative_base
|
|
11
|
-
except ImportError as e:
|
|
12
|
-
msg = 'Install python3-commons[database] to use this feature'
|
|
13
|
-
raise RuntimeError(msg) from e
|
|
14
|
-
|
|
15
|
-
if TYPE_CHECKING:
|
|
16
|
-
from python3_commons.conf import DBSettings
|
|
17
|
-
|
|
18
|
-
logger = logging.getLogger(__name__)
|
|
19
|
-
metadata = MetaData()
|
|
20
|
-
Base = declarative_base(metadata=metadata)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class AsyncSessionManager:
|
|
24
|
-
def __init__(self, db_configs: Mapping[str, DBSettings]) -> None:
|
|
25
|
-
self.db_configs: Mapping[str, DBSettings] = db_configs
|
|
26
|
-
self.engines: dict[str, AsyncEngine] = {}
|
|
27
|
-
self.session_makers: dict = {}
|
|
28
|
-
|
|
29
|
-
def get_db_config(self, name: str) -> DBSettings:
|
|
30
|
-
try:
|
|
31
|
-
return self.db_configs[name]
|
|
32
|
-
except KeyError:
|
|
33
|
-
logger.exception('Missing database settings: %s', name)
|
|
34
|
-
|
|
35
|
-
raise
|
|
36
|
-
|
|
37
|
-
def async_engine_from_db_config(self, name):
|
|
38
|
-
db_config = self.get_db_config(name)
|
|
39
|
-
configuration = {
|
|
40
|
-
'url': str(db_config.dsn),
|
|
41
|
-
'echo': db_config.echo,
|
|
42
|
-
'pool_size': db_config.pool_size,
|
|
43
|
-
'max_overflow': db_config.max_overflow,
|
|
44
|
-
'pool_timeout': db_config.pool_timeout,
|
|
45
|
-
'pool_recycle': db_config.pool_recycle,
|
|
46
|
-
'pool_pre_ping': db_config.pool_pre_ping,
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return async_engine_from_config(
|
|
50
|
-
configuration,
|
|
51
|
-
prefix='',
|
|
52
|
-
connect_args={
|
|
53
|
-
'timeout': 5, # asyncpg timeout
|
|
54
|
-
},
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
def get_engine(self, name: str) -> AsyncEngine:
|
|
58
|
-
try:
|
|
59
|
-
engine = self.engines[name]
|
|
60
|
-
except KeyError:
|
|
61
|
-
logger.debug('Creating engine: %s', name)
|
|
62
|
-
engine = self.async_engine_from_db_config(name)
|
|
63
|
-
self.engines[name] = engine
|
|
64
|
-
|
|
65
|
-
return engine
|
|
66
|
-
|
|
67
|
-
def get_session_maker(self, name: str):
|
|
68
|
-
try:
|
|
69
|
-
session_maker = self.session_makers[name]
|
|
70
|
-
except KeyError:
|
|
71
|
-
logger.debug('Creating session maker: %s', name)
|
|
72
|
-
engine = self.get_engine(name)
|
|
73
|
-
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
74
|
-
self.session_makers[name] = session_maker
|
|
75
|
-
|
|
76
|
-
return session_maker
|
|
77
|
-
|
|
78
|
-
def get_async_session(self, name: str) -> Callable[[], AsyncGenerator[AsyncSession]]:
|
|
79
|
-
async def get_session() -> AsyncGenerator[AsyncSession]:
|
|
80
|
-
session_maker = self.get_session_maker(name)
|
|
81
|
-
|
|
82
|
-
async with session_maker() as session:
|
|
83
|
-
yield session
|
|
84
|
-
|
|
85
|
-
return get_session
|
|
86
|
-
|
|
87
|
-
def get_session_context(self, name: str):
|
|
88
|
-
# TODO: cache
|
|
89
|
-
return contextlib.asynccontextmanager(self.get_async_session(name))
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
async def is_healthy(engine: AsyncEngine) -> bool:
|
|
93
|
-
try:
|
|
94
|
-
async with engine.begin() as conn:
|
|
95
|
-
result = await conn.execute('SELECT 1;')
|
|
96
|
-
|
|
97
|
-
return result.scalar() == 1
|
|
98
|
-
except Exception:
|
|
99
|
-
logger.exception('Database connection is not healthy.')
|
|
100
|
-
|
|
101
|
-
return False
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/serializers/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/serializers/msgpack.py
RENAMED
|
File without changes
|
{python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/serializers/msgspec.py
RENAMED
|
File without changes
|
|
File without changes
|
{python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons.egg-info/top_level.txt
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
|