diff --git a/AGENTS.md b/AGENTS.md index 6f23426..c92ce77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,16 +21,42 @@ Python is 3.14, dependencies managed by `uv`. The API is exposed on `:8000`. **Request flow**: `app/__main__.py` → `granian` → `app.application:build_app` (factory) → `LitestarBootstrapper` from `lite-bootstrap` wraps a `litestar.Litestar` with OpenTelemetry (SQLAlchemy instrumentor), Sentry, CORS, Swagger, etc., based on `Settings.api_bootstrapper_config`. -**Dependency injection** (`app/ioc.py`): `modern_di.Container` is created in `build_app` with the `Dependencies` group and attached via `modern_di_litestar.ModernDIPlugin`. Route handlers receive repositories as parameters; `application.py` declares them with `modern_di_litestar.FromDI(...)` so Litestar resolves them per-request. Provider scopes: +**Dependency injection** (`app/ioc.py`): `modern_di.Container` is created in `build_app` with `ioc.ALL_GROUPS` and attached via `modern_di_litestar.ModernDIPlugin(di_container, autowired_groups=[ioc.UseCases])`. The plugin registers every `UseCases` provider as a Litestar dependency under its attribute name, so a handler asks for `list_decks_use_case: NamedDependency[ListDecksUseCase]` and nothing is declared in `application.py`. Handlers call the use case and convert the result to a schema; they never touch repositories or the session. + +`Database` group: - `database_engine` — application-scoped primary engine, finalizer disposes the engine -- `database_read_engine` — application-scoped read engine built from `DB_REPLICA_DSN`, or from `DB_DSN` when it is unset. It runs in `AUTOCOMMIT` (no BEGIN/ROLLBACK per request, and the pre-ping is a bare `;`) and sets `default_transaction_read_only=on`, so Postgres rejects writes on it. Each statement sees its own snapshot, so a multi-query read (e.g. `fetch_with_cards`) can see a write that commits in between. Without a replica it is a second pool on the primary, so a process can hold up to `2 × db_pool_size` connections +- `database_read_engine` — application-scoped read engine built from `DB_REPLICA_DSN`, or from `DB_DSN` when it is unset. It runs in `AUTOCOMMIT` (no BEGIN/ROLLBACK per request) and sets `default_transaction_read_only=on`, so Postgres rejects writes on it. Each statement sees its own snapshot, so a multi-query read (e.g. `fetch_with_cards`) can see a write that commits in between. Without a replica it is a second pool on the primary, so a process can hold up to `2 × db_pool_size` connections - `dynamic_engine` — request-scoped; `choose_sa_engine` returns the read engine for `GET`/`HEAD` requests, the primary otherwise (including when there is no request). GET handlers cannot write, and a read right after a write may see replica lag - `session` — request-scoped, bound to `dynamic_engine`, finalizer closes the session -- `*_repository` — request-scoped, depend on `session`, configured with `auto_commit=True` (advanced-alchemy commits on success / rolls back on exception) +- `transaction` — request-scoped `db_retry.Transaction` over `session`. Leaving its block rolls back anything uncommitted and closes the session + +`Repositories` group (request-scoped): `decks_repository` and `cards_repository`, on `Database.session` with `auto_commit=False`. The use case commits through `transaction.commit()`. + +`UseCases` group (request-scoped): one provider per use case in `app/use_cases/`. modern-di fills each dataclass field by its type, so a use case needs no `kwargs`. + +**Use cases and lost connections**: `pool_pre_ping` is off by default (`DB_POOL_PRE_PING`). A pooled connection that died while idle is handled by `db_retry.postgres_retry` instead, which saves a round trip on every checkout. That only works if the retried function owns its whole transaction, so every use case has this shape: + +```python +@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +class ListDecksUseCase: + transaction: Transaction + decks_repository: DecksRepository + + @postgres_retry + async def __call__(self) -> Sequence[models.Deck]: + async with self.transaction: + decks: typing.Final = await self.decks_repository.get_many() + await self.transaction.commit() + return decks +``` + +- Every query runs inside `async with self.transaction:`. A query before the block leaves the session in a failed transaction on a lost connection, and the retry then fails with `PendingRollbackError`. +- Commit before leaving the block, reads included, and before any early return. Otherwise `__aexit__` rolls back, which expires the loaded rows. +- A retry runs the whole use case again. If the connection drops during `COMMIT`, the write may already have landed, so a non-idempotent write such as `CreateDeckUseCase` can be applied twice. **Persistence**: Models inherit `advanced_alchemy.base.BigIntAuditBase` (gives `id: BigInt`, `created_at`, `updated_at`). Metadata is shared with `orm.DeclarativeBase.metadata` in `app/models.py` so Alembic autogen sees everything. Repositories are `SQLAlchemyAsyncRepositoryService[Model]` with a nested `BaseRepository(SQLAlchemyAsyncRepository[Model])`. `create_session` in `app/resources/db.py` builds a plain `AsyncSession` with `join_transaction_mode="create_savepoint"`. This is inert in production (the session binds to an engine) but enables the per-test rollback fixture: when a test binds the session to a connection already in a transaction, the session owns its own savepoint so the outer transaction survives commits. Do not "fix" it. -**Test isolation** (`tests/conftest.py`): `db_session` fixture opens a connection, starts a transaction, then **overrides** `Dependencies.dynamic_engine` in the DI container to return that connection. All requests in the test reuse this connection. Each session built against it uses `join_transaction_mode="create_savepoint"`, so `auto_commit` releases the session's own savepoint while the outer transaction is rolled back in teardown. DB state is clean between tests with no truncation needed. `app` and `client` fixtures build the real app and run it through `httpx.ASGITransport` + `asgi_lifespan.LifespanManager`. Polyfactory `SQLAlchemyFactory` is wired up via `set_async_session_in_base_sqlalchemy_factory`. +**Test isolation** (`tests/conftest.py`): `db_session` fixture opens a connection, starts a transaction, then **overrides** `Database.dynamic_engine` in the DI container to return that connection. All requests in the test reuse this connection. Each session built against it uses `join_transaction_mode="create_savepoint"`, so `transaction.commit()` releases the session's own savepoint while the outer transaction is rolled back in teardown. DB state is clean between tests with no truncation needed. `app` and `client` fixtures build the real app and run it through `httpx.ASGITransport` + `asgi_lifespan.LifespanManager`. Polyfactory `SQLAlchemyFactory` is wired up via `set_async_session_in_base_sqlalchemy_factory`. **Migrations**: `migrations/env.py` reads `app.models.METADATA` and rewrites the DSN driver from `postgresql+asyncpg` → `postgresql+psycopg` (Alembic runs on sync psycopg 3). Always run autogen against an upgraded DB — `just migration` enforces this. @@ -38,7 +64,7 @@ Python is 3.14, dependencies managed by `uv`. The API is exposed on `:8000`. ## Conventions -- Routes live in `app/api/`, one module per resource (`decks.py`, `cards.py`), each exposing its own `ROUTER` (`litestar.Router`, prefix `/api`). `application.build_app` registers them all via `route_handlers=[decks.ROUTER, cards.ROUTER]`. Add a new resource by creating `app/api/.py`, defining handlers + a `ROUTER`, and adding it to that list. +- Routes live in `app/api/`, one module per resource (`decks.py`, `cards.py`), each exposing its own `ROUTER` (`litestar.Router`, prefix `/api`). `application.build_app` registers them all via `route_handlers=[decks.ROUTER, cards.ROUTER]`. Add a new resource by creating `app/api/.py`, defining handlers + a `ROUTER`, and adding it to that list. Its database work goes in `app/use_cases/` with a provider in `ioc.UseCases`. - Pydantic schemas in `app/schemas.py` use `from_attributes=True` (via `Base`) so they validate directly from ORM instances (`schemas.X.model_validate(orm_instance)`). Collection responses go through `Collection[T].from_models(...)` (e.g. `schemas.Decks`, `schemas.Cards`). - Deck responses are deliberately two-shaped: `list_decks`/`create_deck`/`update_deck` return the light `schemas.Deck` (no `cards`), while `get_deck` returns `schemas.DeckWithCards`. The split mirrors loading — lists use `noload` (no cards query), detail uses `selectinload` via `fetch_with_cards` — so the type states exactly what each endpoint loads. - Domain exceptions: register handlers in `application.build_app`'s `exception_handlers` dict (`DuplicateKeyError` → `exceptions.duplicate_key_error_handler` returns 400, `NotFoundError` → `exceptions.not_found_error_handler` returns 404). Handlers don't raise HTTP errors themselves: a missing row surfaces as advanced-alchemy's `NotFoundError`. diff --git a/app/api/cards.py b/app/api/cards.py index 0880e27..7aa0f64 100644 --- a/app/api/cards.py +++ b/app/api/cards.py @@ -4,36 +4,39 @@ from litestar.di import NamedDependency from litestar.params import FromPath -from app import models, schemas -from app.repositories import CardsRepository +from app import schemas +from app.use_cases.create_cards import CreateCardsUseCase +from app.use_cases.get_card import GetCardUseCase +from app.use_cases.list_cards import ListCardsUseCase +from app.use_cases.update_cards import UpdateCardsUseCase @litestar.get("/decks/{deck_id:int}/cards/") -async def list_cards(deck_id: FromPath[int], cards_repository: NamedDependency[CardsRepository]) -> schemas.Cards: - objects = await cards_repository.list_for_deck(deck_id) - return schemas.Cards.from_models(objects) +async def list_cards(deck_id: FromPath[int], list_cards_use_case: NamedDependency[ListCardsUseCase]) -> schemas.Cards: + return schemas.Cards.from_models(await list_cards_use_case(deck_id=deck_id)) @litestar.get("/cards/{card_id:int}/") -async def get_card(card_id: FromPath[int], cards_repository: NamedDependency[CardsRepository]) -> schemas.Card: - instance = await cards_repository.get_one(models.Card.id == card_id) - return schemas.Card.model_validate(instance) +async def get_card(card_id: FromPath[int], get_card_use_case: NamedDependency[GetCardUseCase]) -> schemas.Card: + return schemas.Card.model_validate(await get_card_use_case(card_id=card_id)) @litestar.post("/decks/{deck_id:int}/cards/") async def create_cards( - deck_id: FromPath[int], data: list[schemas.CardCreate], cards_repository: NamedDependency[CardsRepository] + deck_id: FromPath[int], + data: list[schemas.CardCreate], + create_cards_use_case: NamedDependency[CreateCardsUseCase], ) -> schemas.Cards: - objects = await cards_repository.add_cards(deck_id, data) - return schemas.Cards.from_models(objects) + return schemas.Cards.from_models(await create_cards_use_case(deck_id=deck_id, data=data)) @litestar.put("/decks/{deck_id:int}/cards/") async def update_cards( - deck_id: FromPath[int], data: list[schemas.Card], cards_repository: NamedDependency[CardsRepository] + deck_id: FromPath[int], + data: list[schemas.Card], + update_cards_use_case: NamedDependency[UpdateCardsUseCase], ) -> schemas.Cards: - objects = await cards_repository.upsert_cards(deck_id, data) - return schemas.Cards.from_models(objects) + return schemas.Cards.from_models(await update_cards_use_case(deck_id=deck_id, data=data)) ROUTER: typing.Final = litestar.Router( diff --git a/app/api/decks.py b/app/api/decks.py index f29901a..407d065 100644 --- a/app/api/decks.py +++ b/app/api/decks.py @@ -5,35 +5,36 @@ from litestar.params import FromPath from app import schemas -from app.repositories import DecksRepository +from app.use_cases.create_deck import CreateDeckUseCase +from app.use_cases.get_deck import GetDeckUseCase +from app.use_cases.list_decks import ListDecksUseCase +from app.use_cases.update_deck import UpdateDeckUseCase @litestar.get("/decks/") -async def list_decks(decks_repository: NamedDependency[DecksRepository]) -> schemas.Decks: - objects = await decks_repository.get_many() - return schemas.Decks.from_models(objects) +async def list_decks(list_decks_use_case: NamedDependency[ListDecksUseCase]) -> schemas.Decks: + return schemas.Decks.from_models(await list_decks_use_case()) @litestar.get("/decks/{deck_id:int}/") -async def get_deck(deck_id: FromPath[int], decks_repository: NamedDependency[DecksRepository]) -> schemas.DeckWithCards: - instance = await decks_repository.fetch_with_cards(deck_id) - return schemas.DeckWithCards.model_validate(instance) +async def get_deck(deck_id: FromPath[int], get_deck_use_case: NamedDependency[GetDeckUseCase]) -> schemas.DeckWithCards: + return schemas.DeckWithCards.model_validate(await get_deck_use_case(deck_id=deck_id)) @litestar.put("/decks/{deck_id:int}/") async def update_deck( deck_id: FromPath[int], data: schemas.DeckCreate, - decks_repository: NamedDependency[DecksRepository], + update_deck_use_case: NamedDependency[UpdateDeckUseCase], ) -> schemas.Deck: - instance = await decks_repository.update(data=data.model_dump(), item_id=deck_id) - return schemas.Deck.model_validate(instance) + return schemas.Deck.model_validate(await update_deck_use_case(deck_id=deck_id, data=data)) @litestar.post("/decks/") -async def create_deck(data: schemas.DeckCreate, decks_repository: NamedDependency[DecksRepository]) -> schemas.Deck: - instance = await decks_repository.create(data) - return schemas.Deck.model_validate(instance) +async def create_deck( + data: schemas.DeckCreate, create_deck_use_case: NamedDependency[CreateDeckUseCase] +) -> schemas.Deck: + return schemas.Deck.model_validate(await create_deck_use_case(data=data)) ROUTER: typing.Final = litestar.Router( diff --git a/app/application.py b/app/application.py index a216a9e..fcd8385 100644 --- a/app/application.py +++ b/app/application.py @@ -8,7 +8,7 @@ from litestar.config.app import AppConfig from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor -from app import exceptions, ioc, repositories +from app import exceptions, ioc from app.api import cards, decks from app.settings import settings @@ -18,7 +18,7 @@ def build_app() -> litestar.Litestar: - di_container = modern_di.Container(groups=[ioc.Dependencies]) + di_container = modern_di.Container(groups=ioc.ALL_GROUPS) bootstrap_config = dataclasses.replace( settings.api_bootstrapper_config, application_config=AppConfig( @@ -27,11 +27,7 @@ def build_app() -> litestar.Litestar: NotFoundError: exceptions.not_found_error_handler, }, route_handlers=[decks.ROUTER, cards.ROUTER], - plugins=[modern_di_litestar.ModernDIPlugin(di_container)], - dependencies={ - "decks_repository": modern_di_litestar.FromDI(repositories.DecksRepository), - "cards_repository": modern_di_litestar.FromDI(repositories.CardsRepository), - }, + plugins=[modern_di_litestar.ModernDIPlugin(di_container, autowired_groups=[ioc.UseCases])], request_max_body_size=settings.request_max_body_size, ), opentelemetry_instrumentors=[SQLAlchemyInstrumentor()], diff --git a/app/ioc.py b/app/ioc.py index 5cc4224..290e5a7 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -1,3 +1,6 @@ +import typing + +from db_retry import Transaction from modern_di import Group, Scope, providers from app.repositories import CardsRepository, DecksRepository @@ -9,9 +12,17 @@ create_read_sa_engine, create_session, ) +from app.use_cases.create_cards import CreateCardsUseCase +from app.use_cases.create_deck import CreateDeckUseCase +from app.use_cases.get_card import GetCardUseCase +from app.use_cases.get_deck import GetDeckUseCase +from app.use_cases.list_cards import ListCardsUseCase +from app.use_cases.list_decks import ListDecksUseCase +from app.use_cases.update_cards import UpdateCardsUseCase +from app.use_cases.update_deck import UpdateDeckUseCase -class Dependencies(Group): +class Database(Group): database_engine = providers.Factory( creator=create_primary_sa_engine, cache=providers.CacheSettings(finalizer=close_sa_engine), @@ -33,14 +44,33 @@ class Dependencies(Group): cache=providers.CacheSettings(finalizer=close_session), kwargs={"engine": dynamic_engine}, ) + transaction = providers.Factory( + scope=Scope.REQUEST, + creator=Transaction, + kwargs={"session": session}, + ) + +class Repositories(Group, scope=Scope.REQUEST): decks_repository = providers.Factory( - scope=Scope.REQUEST, creator=DecksRepository, - kwargs={"auto_commit": True, "session": session}, + kwargs={"session": Database.session, "auto_commit": False}, ) cards_repository = providers.Factory( - scope=Scope.REQUEST, creator=CardsRepository, - kwargs={"auto_commit": True, "session": session}, + kwargs={"session": Database.session, "auto_commit": False}, ) + + +class UseCases(Group, scope=Scope.REQUEST): + list_decks_use_case = providers.Factory(creator=ListDecksUseCase) + get_deck_use_case = providers.Factory(creator=GetDeckUseCase) + update_deck_use_case = providers.Factory(creator=UpdateDeckUseCase) + create_deck_use_case = providers.Factory(creator=CreateDeckUseCase) + list_cards_use_case = providers.Factory(creator=ListCardsUseCase) + get_card_use_case = providers.Factory(creator=GetCardUseCase) + create_cards_use_case = providers.Factory(creator=CreateCardsUseCase) + update_cards_use_case = providers.Factory(creator=UpdateCardsUseCase) + + +ALL_GROUPS: typing.Final[list[type[Group]]] = [Database, Repositories, UseCases] diff --git a/app/settings.py b/app/settings.py index 7c290e4..6cbab52 100644 --- a/app/settings.py +++ b/app/settings.py @@ -14,7 +14,7 @@ class Settings(pydantic_settings.BaseSettings): db_replica_dsn: str = "" db_pool_size: int = 5 db_max_overflow: int = 0 - db_pool_pre_ping: bool = True + db_pool_pre_ping: bool = False app_host: str = "0.0.0.0" # noqa: S104 app_port: int = 8000 diff --git a/app/use_cases/__init__.py b/app/use_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/use_cases/create_cards.py b/app/use_cases/create_cards.py new file mode 100644 index 0000000..cd491f6 --- /dev/null +++ b/app/use_cases/create_cards.py @@ -0,0 +1,21 @@ +import dataclasses +import typing +from collections.abc import Sequence + +from db_retry import Transaction, postgres_retry + +from app import models, schemas +from app.repositories import CardsRepository + + +@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +class CreateCardsUseCase: + transaction: Transaction + cards_repository: CardsRepository + + @postgres_retry + async def __call__(self, *, deck_id: int, data: list[schemas.CardCreate]) -> Sequence[models.Card]: + async with self.transaction: + cards: typing.Final = await self.cards_repository.add_cards(deck_id, data) + await self.transaction.commit() + return cards diff --git a/app/use_cases/create_deck.py b/app/use_cases/create_deck.py new file mode 100644 index 0000000..80713d3 --- /dev/null +++ b/app/use_cases/create_deck.py @@ -0,0 +1,20 @@ +import dataclasses +import typing + +from db_retry import Transaction, postgres_retry + +from app import models, schemas +from app.repositories import DecksRepository + + +@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +class CreateDeckUseCase: + transaction: Transaction + decks_repository: DecksRepository + + @postgres_retry + async def __call__(self, *, data: schemas.DeckCreate) -> models.Deck: + async with self.transaction: + deck: typing.Final = await self.decks_repository.create(data.model_dump()) + await self.transaction.commit() + return deck diff --git a/app/use_cases/get_card.py b/app/use_cases/get_card.py new file mode 100644 index 0000000..650a8c8 --- /dev/null +++ b/app/use_cases/get_card.py @@ -0,0 +1,20 @@ +import dataclasses +import typing + +from db_retry import Transaction, postgres_retry + +from app import models +from app.repositories import CardsRepository + + +@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +class GetCardUseCase: + transaction: Transaction + cards_repository: CardsRepository + + @postgres_retry + async def __call__(self, *, card_id: int) -> models.Card: + async with self.transaction: + card: typing.Final = await self.cards_repository.get_one(models.Card.id == card_id) + await self.transaction.commit() + return card diff --git a/app/use_cases/get_deck.py b/app/use_cases/get_deck.py new file mode 100644 index 0000000..03e6837 --- /dev/null +++ b/app/use_cases/get_deck.py @@ -0,0 +1,20 @@ +import dataclasses +import typing + +from db_retry import Transaction, postgres_retry + +from app import models +from app.repositories import DecksRepository + + +@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +class GetDeckUseCase: + transaction: Transaction + decks_repository: DecksRepository + + @postgres_retry + async def __call__(self, *, deck_id: int) -> models.Deck: + async with self.transaction: + deck: typing.Final = await self.decks_repository.fetch_with_cards(deck_id) + await self.transaction.commit() + return deck diff --git a/app/use_cases/list_cards.py b/app/use_cases/list_cards.py new file mode 100644 index 0000000..5cda202 --- /dev/null +++ b/app/use_cases/list_cards.py @@ -0,0 +1,21 @@ +import dataclasses +import typing +from collections.abc import Sequence + +from db_retry import Transaction, postgres_retry + +from app import models +from app.repositories import CardsRepository + + +@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +class ListCardsUseCase: + transaction: Transaction + cards_repository: CardsRepository + + @postgres_retry + async def __call__(self, *, deck_id: int) -> Sequence[models.Card]: + async with self.transaction: + cards: typing.Final = await self.cards_repository.list_for_deck(deck_id) + await self.transaction.commit() + return cards diff --git a/app/use_cases/list_decks.py b/app/use_cases/list_decks.py new file mode 100644 index 0000000..f1f2cce --- /dev/null +++ b/app/use_cases/list_decks.py @@ -0,0 +1,21 @@ +import dataclasses +import typing +from collections.abc import Sequence + +from db_retry import Transaction, postgres_retry + +from app import models +from app.repositories import DecksRepository + + +@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +class ListDecksUseCase: + transaction: Transaction + decks_repository: DecksRepository + + @postgres_retry + async def __call__(self) -> Sequence[models.Deck]: + async with self.transaction: + decks: typing.Final = await self.decks_repository.get_many() + await self.transaction.commit() + return decks diff --git a/app/use_cases/update_cards.py b/app/use_cases/update_cards.py new file mode 100644 index 0000000..6a6774c --- /dev/null +++ b/app/use_cases/update_cards.py @@ -0,0 +1,21 @@ +import dataclasses +import typing +from collections.abc import Sequence + +from db_retry import Transaction, postgres_retry + +from app import models, schemas +from app.repositories import CardsRepository + + +@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +class UpdateCardsUseCase: + transaction: Transaction + cards_repository: CardsRepository + + @postgres_retry + async def __call__(self, *, deck_id: int, data: list[schemas.Card]) -> Sequence[models.Card]: + async with self.transaction: + cards: typing.Final = await self.cards_repository.upsert_cards(deck_id, data) + await self.transaction.commit() + return cards diff --git a/app/use_cases/update_deck.py b/app/use_cases/update_deck.py new file mode 100644 index 0000000..303238a --- /dev/null +++ b/app/use_cases/update_deck.py @@ -0,0 +1,20 @@ +import dataclasses +import typing + +from db_retry import Transaction, postgres_retry + +from app import models, schemas +from app.repositories import DecksRepository + + +@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +class UpdateDeckUseCase: + transaction: Transaction + decks_repository: DecksRepository + + @postgres_retry + async def __call__(self, *, deck_id: int, data: schemas.DeckCreate) -> models.Deck: + async with self.transaction: + deck: typing.Final = await self.decks_repository.update(data=data.model_dump(), item_id=deck_id) + await self.transaction.commit() + return deck diff --git a/pyproject.toml b/pyproject.toml index 842a9f1..639e7d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "psycopg", "sqlalchemy[asyncio]<2.1", "asyncpg", + "db-retry>=0.8.0", # tracing "opentelemetry-instrumentation-sqlalchemy", ] diff --git a/tests/conftest.py b/tests/conftest.py index d064b3e..04a2309 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,7 +47,7 @@ async def db_session(di_container: modern_di.Container) -> typing.AsyncIterator[ engine = create_primary_sa_engine() connection = await engine.connect() transaction = await connection.begin() - di_container.override(ioc.Dependencies.dynamic_engine, connection) + di_container.override(ioc.Database.dynamic_engine, connection) try: yield create_session(connection) diff --git a/tests/test_db_routing.py b/tests/test_db_routing.py index 453df75..e8530e1 100644 --- a/tests/test_db_routing.py +++ b/tests/test_db_routing.py @@ -21,7 +21,7 @@ @pytest.fixture async def primary_engine(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncEngine]: engine = create_sa_engine(settings.db_dsn_parsed) - di_container.override(ioc.Dependencies.database_engine, engine) + di_container.override(ioc.Database.database_engine, engine) yield engine await engine.dispose() @@ -29,7 +29,7 @@ async def primary_engine(di_container: modern_di.Container) -> typing.AsyncItera @pytest.fixture async def read_engine(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncEngine]: engine = create_read_sa_engine() - di_container.override(ioc.Dependencies.database_read_engine, engine) + di_container.override(ioc.Database.database_read_engine, engine) yield engine await engine.dispose() @@ -43,7 +43,7 @@ def build_request(method: HttpMethod) -> litestar.Request[typing.Any, typing.Any def resolve_engine(di_container: modern_di.Container, method: HttpMethod | None) -> AsyncEngine: context = {litestar.Request: build_request(method)} if method else None with di_container.build_child_container(scope=Scope.REQUEST, context=context) as request_container: - return request_container.resolve_provider(ioc.Dependencies.dynamic_engine) + return request_container.resolve_provider(ioc.Database.dynamic_engine) @pytest.mark.parametrize("method", [HttpMethod.GET, HttpMethod.HEAD]) diff --git a/tests/test_main.py b/tests/test_main.py index 1050a3a..1a9ecd3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -18,7 +18,7 @@ def test_main(monkeypatch: pytest.MonkeyPatch) -> None: async def test_session() -> None: - container = modern_di.Container(groups=[ioc.Dependencies]) + container = modern_di.Container(groups=ioc.ALL_GROUPS) request_container = container.build_child_container(scope=modern_di.Scope.REQUEST) try: request_container.resolve(AsyncSession) diff --git a/tests/test_settings.py b/tests/test_settings.py index 48949de..f1a9573 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -8,3 +8,7 @@ def test_api_bootstrapper_config_reads_instance_not_global() -> None: assert config.service_name == "custom-service" assert config.service_version == "9.9.9" + + +def test_pool_pre_ping_is_off_by_default() -> None: + assert Settings().db_pool_pre_ping is False diff --git a/uv.lock b/uv.lock index 117a941..6c2ec87 100644 --- a/uv.lock +++ b/uv.lock @@ -194,6 +194,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, ] +[[package]] +name = "db-retry" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asyncpg" }, + { name = "sqlalchemy", extra = ["asyncio"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/78/71a71ccabcd09175e4351c655b0d1439a6207f9401ce8df4189fa8bfcc84/db_retry-0.8.0.tar.gz", hash = "sha256:dccdc281422f8d01acc2b4f39e622d4a97e03b23adf3d3bf66b61c680c235b71", size = 8737, upload-time = "2026-10-16T15:55:58.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/1c/3becec5f059559f88c2b4e343673509bac363ebb255743d144da7af35f95/db_retry-0.8.0-py3-none-any.whl", hash = "sha256:edaf2aed767082eb84a6d22b8d79001d0f2c86d68853dda46a623c94fe819ad3", size = 9338, upload-time = "2026-10-16T15:55:57.339Z" }, +] + [[package]] name = "eof-fixer" version = "0.4.3" @@ -487,6 +500,7 @@ dependencies = [ { name = "advanced-alchemy" }, { name = "alembic" }, { name = "asyncpg" }, + { name = "db-retry" }, { name = "granian", extra = ["uvloop"] }, { name = "lite-bootstrap", extra = ["litestar-all"] }, { name = "litestar" }, @@ -517,6 +531,7 @@ requires-dist = [ { name = "advanced-alchemy" }, { name = "alembic" }, { name = "asyncpg" }, + { name = "db-retry", specifier = ">=0.8.0" }, { name = "granian", extras = ["uvloop"] }, { name = "lite-bootstrap", extras = ["litestar-all"], specifier = ">=1.13" }, { name = "litestar" },