sqlalchemy-foundation-kit 0.0.0__py3-none-any.whl

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 (49) hide show
  1. sqlalchemy_foundation_kit/__init__.py +119 -0
  2. sqlalchemy_foundation_kit/__version__.py +1 -0
  3. sqlalchemy_foundation_kit/_typing.py +28 -0
  4. sqlalchemy_foundation_kit/base/__init__.py +46 -0
  5. sqlalchemy_foundation_kit/base/_optional.py +37 -0
  6. sqlalchemy_foundation_kit/base/engine.py +256 -0
  7. sqlalchemy_foundation_kit/base/metadata.py +57 -0
  8. sqlalchemy_foundation_kit/base/models.py +101 -0
  9. sqlalchemy_foundation_kit/base/serialization.py +98 -0
  10. sqlalchemy_foundation_kit/base/types.py +72 -0
  11. sqlalchemy_foundation_kit/config/__init__.py +17 -0
  12. sqlalchemy_foundation_kit/config/postgres.py +177 -0
  13. sqlalchemy_foundation_kit/contrib/__init__.py +5 -0
  14. sqlalchemy_foundation_kit/contrib/_metrics_utils.py +19 -0
  15. sqlalchemy_foundation_kit/contrib/dependency_injector/__init__.py +27 -0
  16. sqlalchemy_foundation_kit/contrib/dependency_injector/_base.py +27 -0
  17. sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +37 -0
  18. sqlalchemy_foundation_kit/contrib/dependency_injector/database.py +196 -0
  19. sqlalchemy_foundation_kit/contrib/dependency_injector/metrics.py +85 -0
  20. sqlalchemy_foundation_kit/contrib/di/__init__.py +21 -0
  21. sqlalchemy_foundation_kit/contrib/di/_base.py +27 -0
  22. sqlalchemy_foundation_kit/contrib/di/_deps.py +32 -0
  23. sqlalchemy_foundation_kit/contrib/di/database.py +169 -0
  24. sqlalchemy_foundation_kit/contrib/di/metrics.py +71 -0
  25. sqlalchemy_foundation_kit/contrib/metrics/__init__.py +7 -0
  26. sqlalchemy_foundation_kit/contrib/metrics/postgres.py +149 -0
  27. sqlalchemy_foundation_kit/contrib/settings/__init__.py +19 -0
  28. sqlalchemy_foundation_kit/contrib/settings/postgres.py +183 -0
  29. sqlalchemy_foundation_kit/contrib/telemetry/__init__.py +17 -0
  30. sqlalchemy_foundation_kit/contrib/telemetry/instrumentations.py +101 -0
  31. sqlalchemy_foundation_kit/contrib/telemetry/uow.py +227 -0
  32. sqlalchemy_foundation_kit/protocols/__init__.py +21 -0
  33. sqlalchemy_foundation_kit/protocols/metrics.py +77 -0
  34. sqlalchemy_foundation_kit/py.typed +0 -0
  35. sqlalchemy_foundation_kit/session/__init__.py +27 -0
  36. sqlalchemy_foundation_kit/session/builder.py +293 -0
  37. sqlalchemy_foundation_kit/session/connection.py +33 -0
  38. sqlalchemy_foundation_kit/session/factories.py +104 -0
  39. sqlalchemy_foundation_kit/session/locks.py +68 -0
  40. sqlalchemy_foundation_kit/session/manager.py +252 -0
  41. sqlalchemy_foundation_kit/session/retry.py +82 -0
  42. sqlalchemy_foundation_kit/uow/__init__.py +21 -0
  43. sqlalchemy_foundation_kit/uow/enums.py +18 -0
  44. sqlalchemy_foundation_kit/uow/protocols.py +80 -0
  45. sqlalchemy_foundation_kit/uow/sqlalchemy.py +406 -0
  46. sqlalchemy_foundation_kit-0.0.0.dist-info/METADATA +624 -0
  47. sqlalchemy_foundation_kit-0.0.0.dist-info/RECORD +49 -0
  48. sqlalchemy_foundation_kit-0.0.0.dist-info/WHEEL +4 -0
  49. sqlalchemy_foundation_kit-0.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,293 @@
1
+ """Builder pattern for AsyncSessionManager configuration.
2
+
3
+ Provides a fluent interface for constructing AsyncSessionManager instances
4
+ with many optional parameters, following the Builder pattern to simplify
5
+ complex object creation and improve code readability.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Generic
11
+
12
+ from .._typing import SessionT
13
+ from .manager import AsyncSessionManager
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Callable
17
+
18
+ from sqlalchemy.ext.asyncio import AsyncEngine
19
+
20
+ from ..config import PoolSettingsProtocol
21
+ from ..protocols import PostgresMetricsProtocol
22
+
23
+
24
+ class AsyncSessionManagerBuilder(Generic[SessionT]):
25
+ """Builder for AsyncSessionManager construction.
26
+
27
+ Provides a fluent API for configuring AsyncSessionManager with many optional
28
+ parameters. This pattern improves code readability and follows KISS principle
29
+ by avoiding constructors with 10+ parameters.
30
+
31
+ Examples:
32
+ Basic usage:
33
+ >>> manager = (
34
+ ... AsyncSessionManagerBuilder("postgresql+asyncpg://...")
35
+ ... .with_pool("queue")
36
+ ... .with_echo(True)
37
+ ... .build()
38
+ ... )
39
+
40
+ Advanced configuration:
41
+ >>> manager = (
42
+ ... AsyncSessionManagerBuilder[CustomSession]("postgresql+asyncpg://...")
43
+ ... .with_session_class(CustomSession)
44
+ ... .with_pool("queue", pool_settings=settings.pool)
45
+ ... .with_metrics(metrics)
46
+ ... .with_callbacks(on_engine_created=instrument_engine)
47
+ ... .with_json_serialization(orjson=True)
48
+ ... .with_isolation_level("READ COMMITTED")
49
+ ... .build()
50
+ ... )
51
+
52
+ Reusable configuration:
53
+ >>> builder = (
54
+ ... AsyncSessionManagerBuilder("postgresql+asyncpg://...")
55
+ ... .with_pool("queue")
56
+ ... .with_metrics(metrics)
57
+ ... )
58
+ >>> manager1 = builder.with_echo(True).build()
59
+ >>> manager2 = builder.with_echo(False).build()
60
+ """
61
+
62
+ def __init__(self, url: str) -> None:
63
+ """Initialize builder with database URL (required).
64
+
65
+ Args:
66
+ url: Database connection URL (required parameter).
67
+ """
68
+ self._url = url
69
+ self._echo: bool = False
70
+ self._poolclass: str | type = "null"
71
+ self._session_class: type[SessionT] | None = None
72
+ self._expire_on_commit: bool = False
73
+ self._connect_args: dict[str, object] | None = None
74
+ self._isolation_level: str | None = None
75
+ self._pool_settings: PoolSettingsProtocol | None = None
76
+ self._use_orjson: bool = False
77
+ self._metrics: PostgresMetricsProtocol | None = None
78
+ self._on_engine_created: Callable[[AsyncEngine], None] | None = None
79
+ self._dispose_timeout: float | None = None
80
+ self._extra_kwargs: dict[str, object] = {}
81
+
82
+ def with_echo(self, echo: bool = True) -> AsyncSessionManagerBuilder[SessionT]:
83
+ """Enable SQL statement logging.
84
+
85
+ Args:
86
+ echo: If True, SQLAlchemy logs all SQL statements.
87
+
88
+ Returns:
89
+ Self for method chaining.
90
+ """
91
+ self._echo = echo
92
+ return self
93
+
94
+ def with_pool(
95
+ self,
96
+ poolclass: str | type,
97
+ pool_settings: PoolSettingsProtocol | None = None,
98
+ ) -> AsyncSessionManagerBuilder[SessionT]:
99
+ """Configure connection pool.
100
+
101
+ Args:
102
+ poolclass: Pool class name or type (e.g., "queue", "null").
103
+ pool_settings: Optional pool configuration settings.
104
+
105
+ Returns:
106
+ Self for method chaining.
107
+ """
108
+ self._poolclass = poolclass
109
+ self._pool_settings = pool_settings
110
+ return self
111
+
112
+ def with_session_class(
113
+ self,
114
+ session_class: type[SessionT],
115
+ ) -> AsyncSessionManagerBuilder[SessionT]:
116
+ """Use custom session class.
117
+
118
+ Args:
119
+ session_class: Custom AsyncSession subclass.
120
+
121
+ Returns:
122
+ Self for method chaining.
123
+ """
124
+ self._session_class = session_class
125
+ return self
126
+
127
+ def with_expire_on_commit(
128
+ self,
129
+ expire: bool = True,
130
+ ) -> AsyncSessionManagerBuilder[SessionT]:
131
+ """Configure object expiration behavior after commit.
132
+
133
+ Args:
134
+ expire: If True, all objects expire after commit.
135
+
136
+ Returns:
137
+ Self for method chaining.
138
+ """
139
+ self._expire_on_commit = expire
140
+ return self
141
+
142
+ def with_connect_args(
143
+ self,
144
+ **connect_args: object,
145
+ ) -> AsyncSessionManagerBuilder[SessionT]:
146
+ """Add database driver connection arguments.
147
+
148
+ Args:
149
+ **connect_args: Arguments passed to the database driver.
150
+
151
+ Returns:
152
+ Self for method chaining.
153
+
154
+ Examples:
155
+ >>> builder.with_connect_args(
156
+ ... server_settings={"application_name": "myapp"},
157
+ ... command_timeout=60,
158
+ ... )
159
+ """
160
+ if self._connect_args is None:
161
+ self._connect_args = {}
162
+ self._connect_args.update(connect_args)
163
+ return self
164
+
165
+ def with_isolation_level(
166
+ self,
167
+ isolation_level: str,
168
+ ) -> AsyncSessionManagerBuilder[SessionT]:
169
+ """Set default transaction isolation level.
170
+
171
+ Args:
172
+ isolation_level: Default isolation level (e.g., "READ COMMITTED").
173
+
174
+ Returns:
175
+ Self for method chaining.
176
+ """
177
+ self._isolation_level = isolation_level
178
+ return self
179
+
180
+ def with_metrics(
181
+ self,
182
+ metrics: PostgresMetricsProtocol,
183
+ ) -> AsyncSessionManagerBuilder[SessionT]:
184
+ """Enable connection pool metrics collection.
185
+
186
+ Args:
187
+ metrics: Metrics collector implementing PostgresMetricsProtocol.
188
+
189
+ Returns:
190
+ Self for method chaining.
191
+ """
192
+ self._metrics = metrics
193
+ return self
194
+
195
+ def with_callbacks(
196
+ self,
197
+ on_engine_created: Callable[[AsyncEngine], None],
198
+ ) -> AsyncSessionManagerBuilder[SessionT]:
199
+ """Register engine creation callback.
200
+
201
+ Useful for attaching instrumentation (OpenTelemetry), custom event
202
+ listeners, or debug hooks right after engine creation.
203
+
204
+ Args:
205
+ on_engine_created: Callback invoked with AsyncEngine after creation.
206
+
207
+ Returns:
208
+ Self for method chaining.
209
+
210
+ Examples:
211
+ >>> from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
212
+ >>> def instrument(engine):
213
+ ... SQLAlchemyInstrumentor().instrument(engine=engine.sync_engine)
214
+ >>> builder.with_callbacks(on_engine_created=instrument)
215
+ """
216
+ self._on_engine_created = on_engine_created
217
+ return self
218
+
219
+ def with_json_serialization(
220
+ self,
221
+ orjson: bool = True,
222
+ ) -> AsyncSessionManagerBuilder[SessionT]:
223
+ """Configure JSON serialization backend.
224
+
225
+ Args:
226
+ orjson: If True, use orjson for faster JSON serialization.
227
+
228
+ Returns:
229
+ Self for method chaining.
230
+
231
+ Raises:
232
+ ImportError: If orjson=True but orjson is not installed.
233
+ """
234
+ self._use_orjson = orjson
235
+ return self
236
+
237
+ def with_extra_kwargs(self, **kwargs: object) -> AsyncSessionManagerBuilder[SessionT]:
238
+ """Add additional keyword arguments for create_async_engine.
239
+
240
+ Args:
241
+ **kwargs: Additional engine configuration.
242
+
243
+ Returns:
244
+ Self for method chaining.
245
+ """
246
+ self._extra_kwargs.update(kwargs)
247
+ return self
248
+
249
+ def with_dispose_timeout(self, timeout: float) -> AsyncSessionManagerBuilder[SessionT]:
250
+ """Configure how long :meth:`AsyncSessionManager.aclose` waits for engine disposal.
251
+
252
+ Args:
253
+ timeout: Maximum seconds to wait for the engine to dispose.
254
+
255
+ Returns:
256
+ Self for method chaining.
257
+ """
258
+ self._dispose_timeout = timeout
259
+ return self
260
+
261
+ def build(self) -> AsyncSessionManager[SessionT]:
262
+ """Build AsyncSessionManager instance with configured parameters.
263
+
264
+ Returns:
265
+ Configured AsyncSessionManager instance.
266
+
267
+ Raises:
268
+ ImportError: If use_orjson=True but orjson is not installed.
269
+
270
+ Examples:
271
+ >>> manager = builder.build()
272
+ >>> async with manager.get_session() as session:
273
+ ... await session.execute(...)
274
+ """
275
+ kwargs: dict[str, object] = {
276
+ "url": self._url,
277
+ "echo": self._echo,
278
+ "poolclass": self._poolclass,
279
+ "session_class": self._session_class,
280
+ "expire_on_commit": self._expire_on_commit,
281
+ "connect_args": self._connect_args,
282
+ "isolation_level": self._isolation_level,
283
+ "pool_settings": self._pool_settings,
284
+ "use_orjson": self._use_orjson,
285
+ "metrics": self._metrics,
286
+ "on_engine_created": self._on_engine_created,
287
+ }
288
+ if self._dispose_timeout is not None:
289
+ kwargs["dispose_timeout"] = self._dispose_timeout
290
+ return AsyncSessionManager(**kwargs, **self._extra_kwargs) # type: ignore[arg-type]
291
+
292
+
293
+ __all__ = ["AsyncSessionManagerBuilder"]
@@ -0,0 +1,33 @@
1
+ """Custom connection class for pgbouncer compatibility (async)."""
2
+
3
+ import uuid
4
+
5
+ import asyncpg
6
+
7
+
8
+ class AsyncCConnection(asyncpg.Connection):
9
+ """Custom async connection class for pgbouncer magic.
10
+
11
+ This subclass overrides only the private method _get_unique_id so that
12
+ prepared statement identifiers are unique per connection. That is required
13
+ when using pgbouncer in transaction mode, where the same server connection
14
+ may be reused for different logical connections.
15
+
16
+ See: https://github.com/sqlalchemy/sqlalchemy/issues/6467
17
+ """
18
+
19
+ def _get_unique_id(self, prefix: str) -> str:
20
+ """Generate unique ID for prepared statements.
21
+
22
+ Args:
23
+ prefix: Prefix for the unique ID.
24
+
25
+ Returns:
26
+ A unique string ID including the prefix and a UUID.
27
+ """
28
+ return f"__asyncpg_{prefix}_{uuid.uuid4()}__"
29
+
30
+
31
+ __all__ = [
32
+ "AsyncCConnection",
33
+ ]
@@ -0,0 +1,104 @@
1
+ """Async database session manager factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from ..config import PostgresSettingsProtocol
10
+ from .connection import AsyncCConnection
11
+ from .manager import AsyncSessionManager
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable
15
+
16
+ import asyncpg
17
+ from sqlalchemy.ext.asyncio import AsyncEngine
18
+
19
+ from ..protocols import PostgresMetricsProtocol
20
+
21
+
22
+ def create_async_session_manager(
23
+ postgres_config: PostgresSettingsProtocol,
24
+ application_name: str | None = None,
25
+ metrics: PostgresMetricsProtocol | None = None,
26
+ on_engine_created: Callable[[AsyncEngine], None] | None = None,
27
+ connection_class: type[asyncpg.Connection] | None = None,
28
+ extra_server_settings: dict[str, str] | None = None,
29
+ extra_connect_args: dict[str, object] | None = None,
30
+ **kwargs: Any,
31
+ ) -> AsyncSessionManager[AsyncSession]:
32
+ """Create async session manager with PostgreSQL-specific configuration.
33
+
34
+ Args:
35
+ postgres_config: PostgreSQL configuration implementing PostgresSettingsProtocol.
36
+ application_name: Optional custom application name. If None, uses postgres_config.application_name.
37
+ metrics: Optional metrics collector for connection pool monitoring.
38
+ on_engine_created: Optional callback invoked with the AsyncEngine right after creation.
39
+ Use it to attach OpenTelemetry instrumentation, custom listeners, etc.
40
+ connection_class: Custom asyncpg Connection subclass. Defaults to ``AsyncCConnection``
41
+ which provides pgbouncer transaction-mode compatibility.
42
+ extra_server_settings: Additional PostgreSQL ``server_settings`` to merge with defaults
43
+ (e.g., ``{"statement_timeout": "30000", "timezone": "UTC"}``). User-provided keys
44
+ override library defaults.
45
+ extra_connect_args: Additional asyncpg ``connect_args`` to merge with defaults
46
+ (e.g., ``{"command_timeout": 60}``). User-provided keys override library defaults.
47
+ **kwargs: Additional keyword arguments passed to AsyncSessionManager.
48
+
49
+ Returns:
50
+ Configured AsyncSessionManager instance.
51
+
52
+ Examples:
53
+ Basic usage:
54
+ >>> manager = create_async_session_manager(postgres_config)
55
+
56
+ With metrics:
57
+ >>> from sqlalchemy_foundation_kit.contrib.metrics import PostgresMetrics
58
+ >>> manager = create_async_session_manager(postgres_config, metrics=PostgresMetrics())
59
+
60
+ With custom server settings and command timeout:
61
+ >>> manager = create_async_session_manager(
62
+ ... postgres_config,
63
+ ... extra_server_settings={"statement_timeout": "30000", "timezone": "UTC"},
64
+ ... extra_connect_args={"command_timeout": 60},
65
+ ... )
66
+
67
+ With OpenTelemetry tracing bound to this engine:
68
+ >>> from sqlalchemy_foundation_kit.contrib.telemetry import instrument_engine
69
+ >>> manager = create_async_session_manager(
70
+ ... postgres_config,
71
+ ... on_engine_created=instrument_engine,
72
+ ... )
73
+ """
74
+ app_name = application_name or postgres_config.application_name
75
+
76
+ # Build server settings with optional overrides
77
+ server_settings: dict[str, str] = {
78
+ "application_name": app_name,
79
+ **({"jit": postgres_config.jit} if postgres_config.jit is not None else {}),
80
+ **({"search_path": postgres_config.db_schema} if postgres_config.db_schema is not None else {}),
81
+ **(extra_server_settings or {}),
82
+ }
83
+
84
+ # Build connect args with optional overrides
85
+ connect_args: dict[str, object] = {
86
+ "server_settings": server_settings,
87
+ "statement_cache_size": postgres_config.query.statement_cache_size,
88
+ "prepared_statement_cache_size": postgres_config.query.prepared_statement_cache_size,
89
+ "connection_class": connection_class or AsyncCConnection,
90
+ **(extra_connect_args or {}),
91
+ }
92
+
93
+ return AsyncSessionManager(
94
+ url=postgres_config.to_dsn(),
95
+ echo=postgres_config.query.echo,
96
+ poolclass=postgres_config.pool.kind,
97
+ connect_args=connect_args,
98
+ isolation_level=postgres_config.query.isolation_level,
99
+ pool_settings=postgres_config.pool,
100
+ use_orjson=postgres_config.use_orjson_serialization,
101
+ metrics=metrics,
102
+ on_engine_created=on_engine_created,
103
+ **kwargs,
104
+ )
@@ -0,0 +1,68 @@
1
+ """PostgreSQL advisory locks (async)."""
2
+
3
+ from sqlalchemy import text
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ # PostgreSQL bigint (signed 64-bit) range constants
7
+ _INT64_OFFSET: int = 1 << 63 # 2^63 = 9223372036854775808
8
+ _INT64_MASK: int = (1 << 64) - 1 # 2^64 - 1 = 18446744073709551615
9
+
10
+
11
+ async def try_advisory_xact_lock(session: AsyncSession, key: str | int) -> bool:
12
+ """Acquire a Postgres transaction-scoped advisory lock.
13
+
14
+ Uses ``pg_try_advisory_xact_lock``: non-blocking, released automatically
15
+ at transaction end. String keys are hashed to integers. The key is then
16
+ truncated to signed 64-bit as Postgres expects.
17
+
18
+ Args:
19
+ session: SQLAlchemy AsyncSession within an active transaction.
20
+ key: Lock identifier (string or integer). Strings are hashed to integers.
21
+
22
+ Returns:
23
+ True if lock was acquired, False if already held by another session.
24
+
25
+ Examples:
26
+ >>> async with session_maker() as session:
27
+ ... async with session.begin():
28
+ ... if await try_advisory_xact_lock(session, "my_operation"):
29
+ ... # Perform protected operation
30
+ ... await session.execute(...)
31
+ ... await session.commit()
32
+ """
33
+ # Convert string keys to integers via hashing
34
+ int_key = hash(key) if isinstance(key, str) else key
35
+
36
+ result = await session.execute(
37
+ text("SELECT pg_try_advisory_xact_lock(:k)"),
38
+ {"k": _to_signed64(int_key)},
39
+ )
40
+ return bool(result.scalar())
41
+
42
+
43
+ def _to_signed64(key: int) -> int:
44
+ """Wrap integer to PostgreSQL signed 64-bit bigint range.
45
+
46
+ PostgreSQL advisory locks use bigint (signed 64-bit integers).
47
+ This function wraps arbitrary Python ints into the range [-2^63, 2^63-1].
48
+
49
+ Args:
50
+ key: Integer of any size.
51
+
52
+ Returns:
53
+ Equivalent value in range [-9223372036854775808, 9223372036854775807].
54
+
55
+ Examples:
56
+ >>> _to_signed64(12345)
57
+ 12345
58
+ >>> _to_signed64(2**64 + 100)
59
+ 100
60
+ >>> _to_signed64(-1)
61
+ -1
62
+ """
63
+ return ((key + _INT64_OFFSET) & _INT64_MASK) - _INT64_OFFSET
64
+
65
+
66
+ __all__ = [
67
+ "try_advisory_xact_lock",
68
+ ]