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.
Files changed (79) hide show
  1. {python3_commons-0.20.2/src/python3_commons.egg-info → python3_commons-0.20.4}/PKG-INFO +2 -2
  2. {python3_commons-0.20.2 → python3_commons-0.20.4}/pyproject.toml +1 -1
  3. python3_commons-0.20.4/src/python3_commons/db/__init__.py +262 -0
  4. {python3_commons-0.20.2 → python3_commons-0.20.4/src/python3_commons.egg-info}/PKG-INFO +2 -2
  5. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons.egg-info/SOURCES.txt +2 -0
  6. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons.egg-info/requires.txt +1 -1
  7. python3_commons-0.20.4/tests/unit/db/test_async_session_manager.py +75 -0
  8. python3_commons-0.20.4/tests/unit/log/__init__.py +0 -0
  9. {python3_commons-0.20.2 → python3_commons-0.20.4}/uv.lock +1 -1
  10. python3_commons-0.20.2/src/python3_commons/db/__init__.py +0 -101
  11. {python3_commons-0.20.2 → python3_commons-0.20.4}/.coveragerc +0 -0
  12. {python3_commons-0.20.2 → python3_commons-0.20.4}/.devcontainer/Dockerfile +0 -0
  13. {python3_commons-0.20.2 → python3_commons-0.20.4}/.devcontainer/devcontainer.json +0 -0
  14. {python3_commons-0.20.2 → python3_commons-0.20.4}/.devcontainer/docker-compose.yml +0 -0
  15. {python3_commons-0.20.2 → python3_commons-0.20.4}/.env_template +0 -0
  16. {python3_commons-0.20.2 → python3_commons-0.20.4}/.github/workflows/checks.yml +0 -0
  17. {python3_commons-0.20.2 → python3_commons-0.20.4}/.github/workflows/python-publish.yaml +0 -0
  18. {python3_commons-0.20.2 → python3_commons-0.20.4}/.github/workflows/release-on-tag-push.yml +0 -0
  19. {python3_commons-0.20.2 → python3_commons-0.20.4}/.gitignore +0 -0
  20. {python3_commons-0.20.2 → python3_commons-0.20.4}/.pre-commit-config.yaml +0 -0
  21. {python3_commons-0.20.2 → python3_commons-0.20.4}/.python-version +0 -0
  22. {python3_commons-0.20.2 → python3_commons-0.20.4}/AUTHORS.rst +0 -0
  23. {python3_commons-0.20.2 → python3_commons-0.20.4}/CHANGELOG.rst +0 -0
  24. {python3_commons-0.20.2 → python3_commons-0.20.4}/LICENSE +0 -0
  25. {python3_commons-0.20.2 → python3_commons-0.20.4}/README.md +0 -0
  26. {python3_commons-0.20.2 → python3_commons-0.20.4}/README.rst +0 -0
  27. {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/Makefile +0 -0
  28. {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/_static/.gitignore +0 -0
  29. {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/authors.rst +0 -0
  30. {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/changelog.rst +0 -0
  31. {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/conf.py +0 -0
  32. {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/index.rst +0 -0
  33. {python3_commons-0.20.2 → python3_commons-0.20.4}/docs/license.rst +0 -0
  34. {python3_commons-0.20.2 → python3_commons-0.20.4}/setup.cfg +0 -0
  35. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/__init__.py +0 -0
  36. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/api_client.py +0 -0
  37. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/async_functools.py +0 -0
  38. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/audit.py +0 -0
  39. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/auth.py +0 -0
  40. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/cache.py +0 -0
  41. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/conf.py +0 -0
  42. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/db/helpers.py +0 -0
  43. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/db/models/__init__.py +0 -0
  44. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/db/models/auth.py +0 -0
  45. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/db/models/common.py +0 -0
  46. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/db/models/rbac.py +0 -0
  47. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/db/models/users.py +0 -0
  48. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/exceptions.py +0 -0
  49. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/fs.py +0 -0
  50. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/generators.py +0 -0
  51. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/helpers.py +0 -0
  52. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/log/__init__.py +0 -0
  53. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/log/filters.py +0 -0
  54. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/log/formatters.py +0 -0
  55. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/object_storage.py +0 -0
  56. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/permissions.py +0 -0
  57. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/serializers/__init__.py +0 -0
  58. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/serializers/common.py +0 -0
  59. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/serializers/json.py +0 -0
  60. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/serializers/msgpack.py +0 -0
  61. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/serializers/msgspec.py +0 -0
  62. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons/soap_client.py +0 -0
  63. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons.egg-info/dependency_links.txt +0 -0
  64. {python3_commons-0.20.2 → python3_commons-0.20.4}/src/python3_commons.egg-info/top_level.txt +0 -0
  65. {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/__init__.py +0 -0
  66. {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/integration/__init__.py +0 -0
  67. {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/integration/conftest.py +0 -0
  68. {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/integration/test_auth.py +0 -0
  69. {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/integration/test_cache.py +0 -0
  70. {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/integration/test_osc.py +0 -0
  71. {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/__init__.py +0 -0
  72. {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/conftest.py +0 -0
  73. {python3_commons-0.20.2/tests/unit/log → python3_commons-0.20.4/tests/unit/db}/__init__.py +0 -0
  74. {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/log/test_formatters.py +0 -0
  75. {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/test_async_functools.py +0 -0
  76. {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/test_audit.py +0 -0
  77. {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/test_helpers.py +0 -0
  78. {python3_commons-0.20.2 → python3_commons-0.20.4}/tests/unit/test_msgpack.py +0 -0
  79. {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.2
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.50; extra == "database"
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"
@@ -51,7 +51,7 @@ cache = [
51
51
  ]
52
52
  database = [
53
53
  "asyncpg~=0.31.0",
54
- "SQLAlchemy[asyncio]~=2.0.50"
54
+ "SQLAlchemy[asyncio]~=2.0.49"
55
55
  ]
56
56
  object-storage = [
57
57
  "aiobotocore~=3.7.0",
@@ -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.2
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.50; extra == "database"
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"
@@ -70,5 +70,7 @@ tests/unit/test_audit.py
70
70
  tests/unit/test_helpers.py
71
71
  tests/unit/test_msgpack.py
72
72
  tests/unit/test_msgspec.py
73
+ tests/unit/db/__init__.py
74
+ tests/unit/db/test_async_session_manager.py
73
75
  tests/unit/log/__init__.py
74
76
  tests/unit/log/test_formatters.py
@@ -29,7 +29,7 @@ valkey[libvalkey]~=6.1.1
29
29
 
30
30
  [database]
31
31
  asyncpg~=0.31.0
32
- SQLAlchemy[asyncio]~=2.0.50
32
+ SQLAlchemy[asyncio]~=2.0.49
33
33
 
34
34
  [object-storage]
35
35
  aiobotocore~=3.7.0
@@ -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.50" },
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