fastapi-augment 0.1.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 (50) hide show
  1. fastapi_augment/__init__.py +24 -0
  2. fastapi_augment/common/__init__.py +61 -0
  3. fastapi_augment/common/constants.py +26 -0
  4. fastapi_augment/common/exception_handlers.py +178 -0
  5. fastapi_augment/common/exceptions.py +162 -0
  6. fastapi_augment/common/utils/__init__.py +5 -0
  7. fastapi_augment/common/utils/strings.py +175 -0
  8. fastapi_augment/config/__init__.py +8 -0
  9. fastapi_augment/config/settings.py +104 -0
  10. fastapi_augment/db/__init__.py +5 -0
  11. fastapi_augment/db/sqlalchemy/__init__.py +20 -0
  12. fastapi_augment/db/sqlalchemy/alembic/__init__.py +5 -0
  13. fastapi_augment/db/sqlalchemy/alembic/env.py +141 -0
  14. fastapi_augment/db/sqlalchemy/base.py +9 -0
  15. fastapi_augment/db/sqlalchemy/crud_base.py +426 -0
  16. fastapi_augment/db/sqlalchemy/engine.py +238 -0
  17. fastapi_augment/db/sqlalchemy/migrate.py +356 -0
  18. fastapi_augment/db/sqlalchemy/mixins/__init__.py +18 -0
  19. fastapi_augment/db/sqlalchemy/mixins/audit.py +61 -0
  20. fastapi_augment/db/sqlalchemy/mixins/soft_delete.py +80 -0
  21. fastapi_augment/db/sqlalchemy/mixins/timestamp.py +48 -0
  22. fastapi_augment/db/sqlalchemy/model_base.py +47 -0
  23. fastapi_augment/db/sqlalchemy/session.py +160 -0
  24. fastapi_augment/factory.py +238 -0
  25. fastapi_augment/health/__init__.py +34 -0
  26. fastapi_augment/health/checker.py +101 -0
  27. fastapi_augment/health/checkers.py +109 -0
  28. fastapi_augment/health/router.py +87 -0
  29. fastapi_augment/lifespan.py +450 -0
  30. fastapi_augment/log/__init__.py +26 -0
  31. fastapi_augment/log/config.py +201 -0
  32. fastapi_augment/log/factory.py +32 -0
  33. fastapi_augment/log/filters.py +23 -0
  34. fastapi_augment/log/handlers.py +81 -0
  35. fastapi_augment/middlewares/__init__.py +20 -0
  36. fastapi_augment/middlewares/base.py +79 -0
  37. fastapi_augment/middlewares/request_id.py +82 -0
  38. fastapi_augment/openapi.py +110 -0
  39. fastapi_augment/py.typed +0 -0
  40. fastapi_augment/schemas/__init__.py +29 -0
  41. fastapi_augment/schemas/base.py +32 -0
  42. fastapi_augment/schemas/pagination.py +46 -0
  43. fastapi_augment/schemas/request.py +28 -0
  44. fastapi_augment/schemas/response.py +139 -0
  45. fastapi_augment/schemas/types.py +11 -0
  46. fastapi_augment-0.1.0.dist-info/METADATA +654 -0
  47. fastapi_augment-0.1.0.dist-info/RECORD +50 -0
  48. fastapi_augment-0.1.0.dist-info/WHEEL +5 -0
  49. fastapi_augment-0.1.0.dist-info/entry_points.txt +2 -0
  50. fastapi_augment-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,426 @@
1
+ """
2
+ @Author : hangu
3
+ @CreateDate : 2026/8/31
4
+ @Description : Generic async CRUD base with create / read / update / delete operations.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Mapping, Sequence
9
+ from math import ceil
10
+ from typing import Any, Generic, TypeVar, cast
11
+
12
+ from sqlalchemy import ColumnElement, delete, exists, func, select, update
13
+ from sqlalchemy.engine import CursorResult
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+ from sqlalchemy.orm import InstrumentedAttribute
16
+
17
+ from .model_base import ModelBase
18
+
19
+ ModelT = TypeVar('ModelT', bound=ModelBase)
20
+
21
+
22
+ class CrudBase(Generic[ModelT]):
23
+ """Generic CRUD repository for :class:`ModelBase` subclasses.
24
+
25
+ The session is always passed explicitly, so callers keep full control
26
+ of the transaction boundary — CRUD methods only *flush*, never *commit*::
27
+
28
+ user_crud = CrudBase(User)
29
+
30
+ async with factory.transaction() as session: # auto commit
31
+ await user_crud.create(session, User(name="alice"))
32
+
33
+ async with factory.read_session() as session: # read replica
34
+ users = await user_crud.list(session, is_active=True, limit=10)
35
+
36
+ Filters accept both keyword arguments and raw SQLAlchemy expressions::
37
+
38
+ await user_crud.list(session, role="admin", expressions=(User.age > 18,))
39
+ await user_crud.list(session, id=["01A", "02B"]) # sequence -> IN
40
+ await user_crud.list(session, name=None) # None -> IS NULL
41
+ """
42
+
43
+ def __init__(self, model: type[ModelT]):
44
+ """Initialize the CRUD repository.
45
+
46
+ Args:
47
+ model: The SQLAlchemy model class to operate on.
48
+ """
49
+ self.model = model
50
+
51
+ # ── Read ─────────────────────────────────────────────────────────────
52
+
53
+ async def get(self, session: AsyncSession, id_: str) -> ModelT | None:
54
+ """Fetch a single row by primary key.
55
+
56
+ Checks the session identity map first.
57
+
58
+ Args:
59
+ session: The async session to use.
60
+ id_: The primary key value.
61
+
62
+ Returns:
63
+ The model instance, or ``None`` if not found.
64
+ """
65
+ return await session.get(self.model, id_)
66
+
67
+ async def get_one(
68
+ self,
69
+ session: AsyncSession,
70
+ *,
71
+ expressions: Sequence[ColumnElement] | None = None,
72
+ **filters: Any,
73
+ ) -> ModelT | None:
74
+ """Fetch the first row matching the filters.
75
+
76
+ Args:
77
+ session: The async session to use.
78
+ expressions: Raw SQLAlchemy filter expressions.
79
+ **filters: Equality keyword filters.
80
+
81
+ Returns:
82
+ The first matching instance, or ``None`` if no row matches.
83
+ """
84
+ stmt = select(self.model).where(*self._conditions(expressions, filters)).limit(1)
85
+ result = await session.execute(stmt)
86
+ return result.scalars().first()
87
+
88
+ async def list(
89
+ self,
90
+ session: AsyncSession,
91
+ *,
92
+ expressions: Sequence[ColumnElement] | None = None,
93
+ order_by: Sequence[str | ColumnElement] | None = None,
94
+ offset: int = 0,
95
+ limit: int | None = 100,
96
+ **filters: Any,
97
+ ) -> Sequence[ModelT]:
98
+ """Fetch rows matching the filters with optional ordering / pagination.
99
+
100
+ Args:
101
+ session: Async session to run the query on (use a read session).
102
+ expressions: Raw SQLAlchemy filter expressions.
103
+ order_by: Column expressions or field names; prefix a name with
104
+ ``-`` for descending order (e.g. ``'-created_at'``).
105
+ offset: Number of rows to skip.
106
+ limit: Max rows to return (``None`` = no limit).
107
+ **filters: Equality keyword filters.
108
+
109
+ Returns:
110
+ A list of matching model instances.
111
+ """
112
+ stmt = select(self.model).where(*self._conditions(expressions, filters))
113
+
114
+ if order_by:
115
+ stmt = stmt.order_by(*(self._resolve_order(spec) for spec in order_by))
116
+
117
+ if offset:
118
+ stmt = stmt.offset(offset)
119
+
120
+ if limit is not None:
121
+ stmt = stmt.limit(limit)
122
+
123
+ result = await session.execute(stmt)
124
+ return result.scalars().all()
125
+
126
+ async def count(
127
+ self,
128
+ session: AsyncSession,
129
+ *,
130
+ expressions: Sequence[ColumnElement] | None = None,
131
+ **filters: Any,
132
+ ) -> int:
133
+ """Count rows matching the filters.
134
+
135
+ Args:
136
+ session: The async session to use.
137
+ expressions: Raw SQLAlchemy filter expressions.
138
+ **filters: Equality keyword filters.
139
+
140
+ Returns:
141
+ The number of matching rows.
142
+ """
143
+ stmt = select(func.count()).select_from(self.model).where(*self._conditions(expressions, filters))
144
+ result = await session.execute(stmt)
145
+ return result.scalar_one()
146
+
147
+ async def paginate(
148
+ self,
149
+ session: AsyncSession,
150
+ *,
151
+ page: int = 1,
152
+ size: int = 10,
153
+ expressions: Sequence[ColumnElement] | None = None,
154
+ order_by: Sequence[str | ColumnElement] | None = None,
155
+ **filters: Any,
156
+ ) -> dict[str, Any]:
157
+ """分页查询,自动执行 count + list 并返回分页结果字典。
158
+
159
+ 内部复用 ``_conditions`` 保证 count 与 list 使用完全相同的过滤条件,
160
+ 避免调用方手动写两遍 filter::
161
+
162
+ result = await crud.paginate(
163
+ session, page=1, size=10,
164
+ is_active=True, order_by=['-created_at'],
165
+ )
166
+ # result = {'items': [...], 'page': 1, 'size': 10, 'total': 100, 'pages': 10}
167
+
168
+ # 可配合 PageData 使用
169
+ from fastapi_augment.schemas import PageData
170
+ page_data = PageData.build(result['items'], page=result['page'],
171
+ size=result['size'], total=result['total'])
172
+
173
+ Args:
174
+ session: The async session to use (typically a read session).
175
+ page: 当前页码(从 1 开始)
176
+ size: 每页数量
177
+ expressions: Raw SQLAlchemy filter expressions.
178
+ order_by: Column expressions or field names; prefix ``-`` for descending.
179
+ **filters: Equality keyword filters.
180
+
181
+ Returns:
182
+ 包含 items / page / size / total / pages 的字典。
183
+ """
184
+
185
+ conditions = self._conditions(expressions, filters)
186
+
187
+ # count
188
+ count_stmt = select(func.count()).select_from(self.model).where(*conditions)
189
+ total = (await session.execute(count_stmt)).scalar_one()
190
+
191
+ # list
192
+ offset = (page - 1) * size
193
+ list_stmt = select(self.model).where(*conditions)
194
+
195
+ if order_by:
196
+ list_stmt = list_stmt.order_by(*(self._resolve_order(spec) for spec in order_by))
197
+
198
+ list_stmt = list_stmt.offset(offset).limit(size)
199
+ items = (await session.execute(list_stmt)).scalars().all()
200
+
201
+ pages = ceil(total / size) if size > 0 else 0
202
+ return {
203
+ 'items': items,
204
+ 'page': page,
205
+ 'size': size,
206
+ 'total': total,
207
+ 'pages': pages,
208
+ }
209
+
210
+ async def exists(
211
+ self,
212
+ session: AsyncSession,
213
+ *,
214
+ expressions: Sequence[ColumnElement] | None = None,
215
+ **filters: Any,
216
+ ) -> bool:
217
+ """Check whether at least one row matches the filters.
218
+
219
+ Args:
220
+ session: The async session to use.
221
+ expressions: Raw SQLAlchemy filter expressions.
222
+ **filters: Equality keyword filters.
223
+
224
+ Returns:
225
+ ``True`` if at least one matching row exists, ``False`` otherwise.
226
+ """
227
+ stmt = select(exists().where(*self._conditions(expressions, filters)))
228
+ result = await session.execute(stmt)
229
+ return bool(result.scalar())
230
+
231
+ # ── Update ───────────────────────────────────────────────────────────
232
+
233
+ async def update(self, session: AsyncSession, obj: ModelT, **values: Any) -> ModelT:
234
+ """Update an ORM instance in place.
235
+
236
+ Flushes; never commits.
237
+
238
+ Args:
239
+ session: The async session to use.
240
+ obj: The model instance to update.
241
+ **values: Field names and their new values.
242
+
243
+ Returns:
244
+ The updated model instance.
245
+
246
+ Raises:
247
+ AttributeError: If a key does not correspond to a mapped attribute.
248
+ """
249
+ for key in values:
250
+ self._attr(key)
251
+
252
+ for key, value in values.items():
253
+ setattr(obj, key, value)
254
+
255
+ await session.flush()
256
+ return obj
257
+
258
+ async def update_by_id(self, session: AsyncSession, id_: str, **values: Any) -> int:
259
+ """Update a row by primary key with a single UPDATE statement.
260
+
261
+ Args:
262
+ session: The async session to use.
263
+ id_: The primary key value.
264
+ **values: Field names and their new values.
265
+
266
+ Returns:
267
+ The number of affected rows (0 = not found or nothing to update).
268
+ """
269
+ if not values:
270
+ return 0
271
+
272
+ stmt = update(self.model).where(self.model.id == id_).values(**values)
273
+ result = cast(CursorResult[Any], await session.execute(stmt))
274
+ return result.rowcount or 0
275
+
276
+ # ── Delete ───────────────────────────────────────────────────────────
277
+
278
+ async def delete_by_id(self, session: AsyncSession, id_: str) -> bool:
279
+ """Delete a row by primary key.
280
+
281
+ Args:
282
+ session: The async session to use.
283
+ id_: The primary key value.
284
+
285
+ Returns:
286
+ ``True`` if a row was deleted, ``False`` otherwise.
287
+ """
288
+ stmt = delete(self.model).where(self.model.id == id_)
289
+ result = cast(CursorResult[Any], await session.execute(stmt))
290
+ return bool(result.rowcount)
291
+
292
+ async def delete_where(
293
+ self,
294
+ session: AsyncSession,
295
+ *,
296
+ expressions: Sequence[ColumnElement] | None = None,
297
+ **filters: Any,
298
+ ) -> int:
299
+ """Delete all rows matching the filters.
300
+
301
+ Args:
302
+ session: The async session to use.
303
+ expressions: Raw SQLAlchemy filter expressions.
304
+ **filters: Equality keyword filters.
305
+
306
+ Returns:
307
+ The number of deleted rows.
308
+ """
309
+ stmt = delete(self.model).where(*self._conditions(expressions, filters))
310
+ result = cast(CursorResult[Any], await session.execute(stmt))
311
+ return result.rowcount or 0
312
+
313
+ # ── Helpers ──────────────────────────────────────────────────────────
314
+
315
+ def _attr(self, name: str) -> InstrumentedAttribute[Any]:
316
+ """Resolve a field name to a mapped attribute.
317
+
318
+ Args:
319
+ name: The field name to resolve.
320
+
321
+ Returns:
322
+ The corresponding :class:`~sqlalchemy.orm.InstrumentedAttribute`.
323
+
324
+ Raises:
325
+ AttributeError: If the name does not correspond to a mapped attribute.
326
+ """
327
+ attr = getattr(self.model, name, None)
328
+
329
+ if not isinstance(attr, InstrumentedAttribute):
330
+ raise AttributeError(f'{self.model.__name__} has no mapped attribute {name!r}')
331
+
332
+ return attr
333
+
334
+ def _conditions(
335
+ self,
336
+ expressions: Sequence[ColumnElement] | None,
337
+ filters: Mapping[str, Any],
338
+ ) -> Sequence[ColumnElement]:
339
+ """Combine raw expressions and keyword filters into WHERE conditions.
340
+
341
+ Args:
342
+ expressions: Raw SQLAlchemy filter expressions.
343
+ filters: Equality keyword filters. Sequences (list, set, tuple,
344
+ frozenset) are converted to ``IN`` clauses; ``None`` values
345
+ become ``IS NULL`` checks.
346
+
347
+ Returns:
348
+ A list of column expressions suitable for ``.where()``.
349
+ """
350
+ conditions: list[ColumnElement] = list(expressions or [])
351
+
352
+ for key, value in filters.items():
353
+ column = self._attr(key)
354
+
355
+ if isinstance(value, (list, set, tuple, frozenset)):
356
+ conditions.append(column.in_(value))
357
+ else:
358
+ conditions.append(column == value)
359
+
360
+ return conditions
361
+
362
+ def _resolve_order(self, spec: str | ColumnElement) -> ColumnElement:
363
+ """Convert an order-by spec into a column expression.
364
+
365
+ Args:
366
+ spec: A column expression, or a field name. Prefix with ``-``
367
+ for descending order (e.g. ``'-created_at'``).
368
+
369
+ Returns:
370
+ A column expression with the appropriate asc/desc direction.
371
+ """
372
+ if isinstance(spec, str):
373
+ descending = spec.startswith('-')
374
+ column = self._attr(spec.lstrip('+-'))
375
+ return column.desc() if descending else column.asc()
376
+
377
+ return spec
378
+
379
+ # ── Static Methods ───────────────────────────────────────────────────
380
+
381
+ @staticmethod
382
+ async def create(session: AsyncSession, obj: ModelT) -> ModelT:
383
+ """Persist a new object.
384
+
385
+ Flushes so PKs / defaults are populated; never commits.
386
+
387
+ Args:
388
+ session: The async session to use.
389
+ obj: The model instance to persist.
390
+
391
+ Returns:
392
+ The same instance with populated defaults.
393
+ """
394
+ session.add(obj)
395
+ await session.flush()
396
+ return obj
397
+
398
+ @staticmethod
399
+ async def create_many(session: AsyncSession, objs: Sequence[ModelT]) -> Sequence[ModelT]:
400
+ """Persist multiple objects in one batch.
401
+
402
+ Flushes; never commits.
403
+
404
+ Args:
405
+ session: The async session to use.
406
+ objs: The model instances to persist.
407
+
408
+ Returns:
409
+ A list of the persisted instances.
410
+ """
411
+ session.add_all(objs)
412
+ await session.flush()
413
+ return objs
414
+
415
+ @staticmethod
416
+ async def delete(session: AsyncSession, obj: ModelT) -> None:
417
+ """Delete an ORM instance.
418
+
419
+ Flushes; never commits.
420
+
421
+ Args:
422
+ session: The async session to use.
423
+ obj: The model instance to delete.
424
+ """
425
+ await session.delete(obj)
426
+ await session.flush()
@@ -0,0 +1,238 @@
1
+ """
2
+ @Author : hangu
3
+ @CreateDate : 2026/8/31
4
+ @Description : SQLAlchemy async engine factory, supporting single / master-replica / cluster topologies.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from itertools import cycle
10
+ from typing import Any
11
+
12
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
13
+
14
+
15
+ # ── Configuration Models ─────────────────────────────────────────────────────
16
+
17
+
18
+ @dataclass
19
+ class NodeConfig:
20
+ """Single database node configuration.
21
+
22
+ Args:
23
+ url: Async SQLAlchemy connection URL, e.g. ``postgresql+asyncpg://user:pass@host/db``.
24
+ pool_size: Connection pool size (0 = unlimited).
25
+ max_overflow: Max connections allowed beyond *pool_size*.
26
+ pool_timeout: Seconds to wait for a connection from the pool.
27
+ pool_recycle: Recycle connections after N seconds (-1 = disable).
28
+ pool_pre_ping: Emit a test statement on checkout to verify liveness.
29
+ echo: Log all SQL statements.
30
+ connect_args: Extra arguments passed to the DBAPI ``connect()``.
31
+ """
32
+
33
+ url: str
34
+ pool_size: int = 5
35
+ max_overflow: int = 10
36
+ pool_timeout: float = 30.0
37
+ pool_recycle: int = 3600
38
+ pool_pre_ping: bool = True
39
+ echo: bool = False
40
+ connect_args: dict[str, Any] = field(default_factory=dict)
41
+
42
+ def __post_init__(self) -> None:
43
+ if not self.url:
44
+ raise ValueError('url must not be empty')
45
+ if self.pool_size < 0:
46
+ raise ValueError(f'pool_size must be >= 0, got {self.pool_size}')
47
+ if self.max_overflow < 0:
48
+ raise ValueError(f'max_overflow must be >= 0, got {self.max_overflow}')
49
+ if self.pool_timeout < 0:
50
+ raise ValueError(f'pool_timeout must be >= 0, got {self.pool_timeout}')
51
+ if self.pool_recycle < -1:
52
+ raise ValueError(f'pool_recycle must be >= -1, got {self.pool_recycle}')
53
+
54
+
55
+ @dataclass
56
+ class ClusterTopology:
57
+ """
58
+ Database cluster topology.
59
+
60
+ Supports three deployment patterns::
61
+
62
+ Single : only ``primary`` is set
63
+ Master-Replica: ``primary`` + ``replicas``
64
+ Cluster : ``primary`` + ``replicas`` + ``readonly``
65
+
66
+ Read routing priority: replicas -> readonly -> primary (fallback).
67
+ """
68
+
69
+ primary: NodeConfig
70
+ replicas: list[NodeConfig] = field(default_factory=list)
71
+ readonly: list[NodeConfig] = field(default_factory=list)
72
+
73
+ @property
74
+ def is_single(self) -> bool:
75
+ """Indicates whether the topology consists of a single primary node (no replicas or readonly nodes).
76
+
77
+ Returns:
78
+ True if the topology is single, False otherwise.
79
+ """
80
+ return not self.replicas and not self.readonly
81
+
82
+ @property
83
+ def is_master_replica(self) -> bool:
84
+ """Indicates whether the topology consists of a single primary node and one or more replicas (no readonly nodes).
85
+
86
+ Returns:
87
+ True if the topology is master-replica, False otherwise.
88
+ """
89
+ return bool(self.replicas) and not self.readonly
90
+
91
+ @property
92
+ def is_cluster(self) -> bool:
93
+ """Indicates whether the topology consists of a single primary node, one or more replicas, and one or more readonly nodes.
94
+
95
+ Returns:
96
+ True if the topology is a cluster, False otherwise.
97
+ """
98
+ return bool(self.replicas) or bool(self.readonly)
99
+
100
+ def get_all_read_sources(self) -> list[NodeConfig]:
101
+ """Return all nodes eligible for read traffic (replicas first, then readonly).
102
+
103
+ Returns:
104
+ A list of node configurations.
105
+ """
106
+ return [*self.replicas, *self.readonly]
107
+
108
+
109
+ # ── Engine Manager ───────────────────────────────────────────────────────────
110
+
111
+
112
+ class EngineManager:
113
+ """
114
+ Manages the full lifecycle of async SQLAlchemy engines.
115
+
116
+ Usage::
117
+
118
+ topology = ClusterTopology(
119
+ primary=NodeConfig(url='postgresql+asyncpg://...'),
120
+ replicas=[NodeConfig(url='postgresql+asyncpg://replica-1/...')],
121
+ )
122
+ manager = EngineManager(topology)
123
+ manager.start()
124
+
125
+ write_engine = manager.write_engine
126
+ read_engine = manager.next_read_engine() # round-robin
127
+
128
+ await manager.dispose()
129
+ """
130
+
131
+ def __init__(self, topology: ClusterTopology) -> None:
132
+ self._topology = topology
133
+ self._engines: dict[str, AsyncEngine] = {}
134
+ self._write_key: str = 'primary'
135
+ self._read_keys: list[str] = []
136
+ self._read_cycle: cycle[str] | None = None
137
+
138
+ # ── Lifecycle ────────────────────────────────────────────────────────
139
+
140
+ def start(self) -> EngineManager:
141
+ """Create all engines based on the topology. Returns *self* for chaining.
142
+
143
+ Returns:
144
+ The engine manager instance itself, for method chaining.
145
+ """
146
+ self._engines[self._write_key] = self._create_engine(self._topology.primary)
147
+
148
+ read_sources = self._topology.get_all_read_sources()
149
+ for idx, node in enumerate(read_sources):
150
+ key = f'read_{idx}'
151
+ self._engines[key] = self._create_engine(node)
152
+ self._read_keys.append(key)
153
+
154
+ if self._read_keys:
155
+ self._read_cycle = cycle(self._read_keys)
156
+
157
+ return self
158
+
159
+ async def dispose(self) -> None:
160
+ """Dispose all engine connection pools gracefully."""
161
+ for engine in self._engines.values():
162
+ await engine.dispose()
163
+ self._engines.clear()
164
+ self._read_keys.clear()
165
+ self._read_cycle = None
166
+
167
+ # ── Engine Access ────────────────────────────────────────────────────
168
+
169
+ @property
170
+ def write_engine(self) -> AsyncEngine:
171
+ """The primary engine used for all write operations.
172
+
173
+ Returns:
174
+ The primary async SQLAlchemy engine.
175
+ """
176
+ return self._engines[self._write_key]
177
+
178
+ def next_read_engine(self) -> AsyncEngine:
179
+ """Return the next read engine via round-robin; falls back to the write engine.
180
+
181
+ Returns:
182
+ An async SQLAlchemy engine.
183
+ """
184
+ if self._read_cycle is not None:
185
+ return self._engines[next(self._read_cycle)]
186
+ return self.write_engine
187
+
188
+ def get_engine(self, name: str) -> AsyncEngine:
189
+ """Get a specific engine by its key (e.g. ``'primary'``, ``'read_0'``).
190
+
191
+ Args:
192
+ name: Engine key.
193
+
194
+ Returns:
195
+ An async SQLAlchemy engine.
196
+ """
197
+ return self._engines[name]
198
+
199
+ @property
200
+ def engines(self) -> dict[str, AsyncEngine]:
201
+ """All engines managed by the engine manager.
202
+
203
+ Returns:
204
+ A dictionary of engine key -> async SQLAlchemy engine.
205
+ """
206
+ return dict(self._engines)
207
+
208
+ @property
209
+ def topology(self) -> ClusterTopology:
210
+ """The database topology configuration.
211
+
212
+ Returns:
213
+ A ClusterTopology instance.
214
+ """
215
+ return self._topology
216
+
217
+ # ── Internal ─────────────────────────────────────────────────────────
218
+
219
+ @staticmethod
220
+ def _create_engine(node: NodeConfig) -> AsyncEngine:
221
+ """Create an async SQLAlchemy engine based on the given node configuration.
222
+
223
+ Args:
224
+ node: Database node configuration.
225
+
226
+ Returns:
227
+ An async SQLAlchemy engine.
228
+ """
229
+ return create_async_engine(
230
+ node.url,
231
+ pool_size=node.pool_size,
232
+ max_overflow=node.max_overflow,
233
+ pool_timeout=node.pool_timeout,
234
+ pool_recycle=node.pool_recycle,
235
+ pool_pre_ping=node.pool_pre_ping,
236
+ echo=node.echo,
237
+ connect_args=node.connect_args,
238
+ )