diff --git a/AGENTS.md b/AGENTS.md index 5569971..6d8bdaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ class ListDecksUseCase: **Migrations**: `migrations/env.py` reads `app.models.METADATA` and rewrites the DSN driver from `postgresql+asyncpg` to `postgresql+psycopg` (Alembic runs on sync psycopg 3). Always run autogen against an upgraded DB; `just migration` enforces this. -**Settings** (`app/settings.py`): `pydantic_settings.BaseSettings` reads from env vars (see `docker-compose.yml` for `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `DB_DSN`; `DB_REPLICA_DSN` is optional). Pool and server sizing uses `DB_POOL_SIZE`, `DB_MAX_OVERFLOW`, `DB_POOL_TIMEOUT`, `DB_POOL_RECYCLE` and `APP_WORKERS` (see **Pool and server sizing**). `api_bootstrapper_config` builds the `LitestarConfig` consumed by `lite-bootstrap`. +**Settings** (`app/settings.py`): `pydantic_settings.BaseSettings` reads from env vars (see `docker-compose.yml` for `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `DB_DSN`; `DB_REPLICA_DSN` is optional). Pool and server sizing uses `DB_POOL_SIZE`, `DB_MAX_OVERFLOW`, `DB_POOL_TIMEOUT`, `DB_POOL_RECYCLE` and `APP_WORKERS` (see **Pool and server sizing**). `api_bootstrapper_config` builds the `LitestarConfig` consumed by `lite-bootstrap`. Components get settings from the container through `ioc.Config.settings`. The engines take the whole object and get their URL from `Config.settings.derive(...)`, so a test can change their config with `container.override(ioc.Config.settings, Settings(...))` on a container that has not built the engines yet, since the engines are cached. `app/__main__.py`, `migrations/env.py` and `build_app` read the module-level `settings` instead, because they run outside the container or before it exists. ## Conventions diff --git a/app/ioc.py b/app/ioc.py index 6449a0e..2de0a34 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -9,10 +9,11 @@ choose_sa_engine, close_sa_engine, close_session, - create_primary_sa_engine, - create_read_sa_engine, + create_sa_engine, create_session, + read_engine_url, ) +from app.settings import Settings from app.use_cases.create_cards import CreateCardsUseCase from app.use_cases.create_deck import CreateDeckUseCase from app.use_cases.get_card import GetCardUseCase @@ -23,16 +24,22 @@ from app.use_cases.update_deck import UpdateDeckUseCase +class Config(Group): + settings = providers.Factory(creator=Settings, cache=True) + + class Database(Group): database_engine = providers.Factory( - creator=create_primary_sa_engine, + creator=create_sa_engine, cache=providers.CacheSettings(finalizer=close_sa_engine), bound_type=None, + kwargs={"settings": Config.settings, "url": Config.settings.derive(lambda s: s.db_dsn_parsed)}, ) database_read_engine = providers.Factory( - creator=create_read_sa_engine, + creator=create_sa_engine, cache=providers.CacheSettings(finalizer=close_sa_engine), bound_type=None, + kwargs={"settings": Config.settings, "url": Config.settings.derive(read_engine_url), "read_only": True}, ) dynamic_engine = providers.Factory( scope=Scope.REQUEST, @@ -85,4 +92,4 @@ class UseCases(Group, scope=Scope.REQUEST): update_cards_use_case = providers.Factory(creator=UpdateCardsUseCase) -ALL_GROUPS: typing.Final[list[type[Group]]] = [Database, Repositories, UseCases] +ALL_GROUPS: typing.Final[list[type[Group]]] = [Config, Database, Repositories, UseCases] diff --git a/app/resources/db.py b/app/resources/db.py index 109c694..0be3fdc 100644 --- a/app/resources/db.py +++ b/app/resources/db.py @@ -7,7 +7,7 @@ from sqlalchemy.engine.url import URL, make_url from sqlalchemy.ext import asyncio as sa -from app.settings import settings +from app.settings import Settings logger = logging.getLogger(__name__) @@ -16,7 +16,7 @@ READ_METHODS: typing.Final = frozenset({"GET", "HEAD"}) -def create_sa_engine(url: URL, *, read_only: bool = False) -> sa.AsyncEngine: +def create_sa_engine(*, settings: Settings, url: URL, read_only: bool = False) -> sa.AsyncEngine: return sa.create_async_engine( url=url, echo=settings.service_debug, @@ -31,15 +31,8 @@ def create_sa_engine(url: URL, *, read_only: bool = False) -> sa.AsyncEngine: ) -def create_primary_sa_engine() -> sa.AsyncEngine: - return create_sa_engine(settings.db_dsn_parsed) - - -def create_read_sa_engine() -> sa.AsyncEngine: - return create_sa_engine( - make_url(settings.db_replica_dsn) if settings.db_replica_dsn else settings.db_dsn_parsed, - read_only=True, - ) +def read_engine_url(settings: Settings) -> URL: + return make_url(settings.db_replica_dsn) if settings.db_replica_dsn else settings.db_dsn_parsed def autocommit_isolation_level() -> IsolationLevel | None: diff --git a/pyproject.toml b/pyproject.toml index 802502e..10e1cc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ license = "MIT" dependencies = [ "litestar", "lite-bootstrap[litestar-all]>=1.13", + "modern-di>=4.3.0,<5", "modern-di-litestar>=4,<5", "advanced-alchemy", "pydantic-settings", diff --git a/tests/conftest.py b/tests/conftest.py index dc7cfe2..89f047b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,8 @@ from app import ioc from app.application import build_app -from app.resources.db import create_primary_sa_engine, create_session +from app.resources.db import create_sa_engine, create_session +from app.settings import settings if typing.TYPE_CHECKING: @@ -50,7 +51,7 @@ async def di_container(app: litestar.Litestar) -> typing.AsyncIterator[modern_di @pytest.fixture async def db_session(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncSession]: - engine = create_primary_sa_engine() + engine = create_sa_engine(settings=settings, url=settings.db_dsn_parsed) connection = await engine.connect() transaction = await connection.begin() di_container.override(ioc.Database.dynamic_engine, connection) @@ -97,7 +98,7 @@ def record(*args: object) -> None: @pytest.fixture async def committing_engine(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncEngine]: - engine = create_primary_sa_engine() + engine = create_sa_engine(settings=settings, url=settings.db_dsn_parsed) di_container.override(ioc.Database.dynamic_engine, engine) try: yield engine diff --git a/tests/test_create_deck.py b/tests/test_create_deck.py index 6e091fc..8e584d3 100644 --- a/tests/test_create_deck.py +++ b/tests/test_create_deck.py @@ -8,7 +8,8 @@ from app import models, schemas from app.repositories import DecksRepository -from app.resources.db import create_primary_sa_engine, create_session +from app.resources.db import create_sa_engine, create_session +from app.settings import settings from app.use_cases.create_deck import CreateDeckUseCase @@ -23,7 +24,7 @@ async def _wait_until_blocked_on_a_lock(engine: AsyncEngine, pid: int) -> None: async def test_create_deck_returns_the_deck_a_concurrent_request_created_with_the_same_key() -> None: - engine: typing.Final = create_primary_sa_engine() + engine: typing.Final = create_sa_engine(settings=settings, url=settings.db_dsn_parsed) idempotency_key: typing.Final = uuid.uuid4() try: async with engine.connect() as concurrent_request: diff --git a/tests/test_db_routing.py b/tests/test_db_routing.py index e8530e1..bd0bf71 100644 --- a/tests/test_db_routing.py +++ b/tests/test_db_routing.py @@ -1,26 +1,27 @@ import typing import litestar +import modern_di import pytest import sqlalchemy as sa from litestar.enums import HttpMethod from litestar.testing import RequestFactory from modern_di import Scope +from sqlalchemy.engine.url import URL from sqlalchemy.exc import DBAPIError from app import ioc, models -from app.resources.db import create_read_sa_engine, create_sa_engine -from app.settings import settings +from app.resources.db import create_sa_engine, read_engine_url +from app.settings import Settings, settings if typing.TYPE_CHECKING: - import modern_di from sqlalchemy.ext.asyncio import AsyncEngine @pytest.fixture async def primary_engine(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncEngine]: - engine = create_sa_engine(settings.db_dsn_parsed) + engine = create_sa_engine(settings=settings, url=settings.db_dsn_parsed) di_container.override(ioc.Database.database_engine, engine) yield engine await engine.dispose() @@ -28,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() + engine = create_sa_engine(settings=settings, url=read_engine_url(settings), read_only=True) di_container.override(ioc.Database.database_read_engine, engine) yield engine await engine.dispose() @@ -69,21 +70,26 @@ def test_no_request_uses_primary(di_container: modern_di.Container, primary_engi assert resolve_engine(di_container, None) is primary_engine +async def resolve_read_engine_url(app_settings: Settings) -> URL: + container: typing.Final = modern_di.Container(groups=ioc.ALL_GROUPS) + container.override(ioc.Config.settings, app_settings) + try: + return container.resolve_provider(ioc.Database.database_read_engine).url + finally: + await container.close_async() + + async def test_read_engine_built_from_primary_dsn_without_replica() -> None: - engine = create_read_sa_engine() - assert engine.url == settings.db_dsn_parsed - await engine.dispose() + assert await resolve_read_engine_url(Settings(db_replica_dsn="")) == settings.db_dsn_parsed -async def test_read_engine_built_from_replica_dsn(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "db_replica_dsn", "postgresql+asyncpg://postgres:password@replica/postgres") - engine = create_read_sa_engine() - assert engine.url.host == "replica" - await engine.dispose() +async def test_read_engine_built_from_overridden_replica_dsn() -> None: + replica_settings: typing.Final = Settings(db_replica_dsn="postgresql+asyncpg://postgres:password@replica/postgres") + assert (await resolve_read_engine_url(replica_settings)).host == "replica" async def test_read_engine_runs_each_statement_in_its_own_transaction() -> None: - engine = create_read_sa_engine() + engine = create_sa_engine(settings=settings, url=read_engine_url(settings), read_only=True) async with engine.connect() as connection: first = await connection.scalar(sa.text("SELECT transaction_timestamp()")) second = await connection.scalar(sa.text("SELECT transaction_timestamp()")) @@ -92,7 +98,7 @@ async def test_read_engine_runs_each_statement_in_its_own_transaction() -> None: async def test_read_engine_rejects_writes() -> None: - engine = create_read_sa_engine() + engine = create_sa_engine(settings=settings, url=read_engine_url(settings), read_only=True) async with engine.connect() as connection: with pytest.raises(DBAPIError, match="read-only transaction"): await connection.execute(sa.insert(models.Deck).values(name="read only")) diff --git a/tests/test_pool.py b/tests/test_pool.py index d71af67..cdf057e 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -5,8 +5,8 @@ from litestar import status_codes as status from app import ioc -from app.resources.db import create_primary_sa_engine -from app.settings import settings +from app.resources.db import create_sa_engine +from app.settings import Settings from tests import factories @@ -25,13 +25,10 @@ async def test_exhausted_pool_returns_503( client: AsyncClient, di_container: modern_di.Container, - monkeypatch: pytest.MonkeyPatch, request_kwargs: dict[str, typing.Any], ) -> None: - monkeypatch.setattr(settings, "db_pool_size", 1) - monkeypatch.setattr(settings, "db_max_overflow", 0) - monkeypatch.setattr(settings, "db_pool_timeout", 0.1) - engine: typing.Final = create_primary_sa_engine() + pool_settings: typing.Final = Settings(db_pool_size=1, db_max_overflow=0, db_pool_timeout=0.1) + engine: typing.Final = create_sa_engine(settings=pool_settings, url=pool_settings.db_dsn_parsed) di_container.override(ioc.Database.dynamic_engine, engine) try: async with engine.connect(): diff --git a/uv.lock b/uv.lock index 64e73a1..939f247 100644 --- a/uv.lock +++ b/uv.lock @@ -504,6 +504,7 @@ dependencies = [ { name = "granian", extra = ["uvloop"] }, { name = "lite-bootstrap", extra = ["litestar-all"] }, { name = "litestar" }, + { name = "modern-di" }, { name = "modern-di-litestar" }, { name = "opentelemetry-instrumentation-sqlalchemy" }, { name = "psycopg" }, @@ -536,6 +537,7 @@ requires-dist = [ { name = "granian", extras = ["uvloop"] }, { name = "lite-bootstrap", extras = ["litestar-all"], specifier = ">=1.13" }, { name = "litestar" }, + { name = "modern-di", specifier = ">=4.3.0,<5" }, { name = "modern-di-litestar", specifier = ">=4,<5" }, { name = "opentelemetry-instrumentation-sqlalchemy" }, { name = "psycopg" }, @@ -624,11 +626,11 @@ wheels = [ [[package]] name = "modern-di" -version = "4.0.0" +version = "4.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/e3/9c92577190555fbc78de895ae30e5093290decd682cd31e170d1a01d8a71/modern_di-4.0.0.tar.gz", hash = "sha256:5554fa458dfb59ab0325a57b1e550c3e35524f4236604884529980f9dc1f5424", size = 37359, upload-time = "2026-10-09T17:33:38.946Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/c5/93cc4d790047ac77e0eaa332adb3e2fb43726169614e926f5ea7953aa0b1/modern_di-4.3.0.tar.gz", hash = "sha256:8436f37f308a45265adce77833a187748f2c4e3929a15f997337da2d7d046733", size = 44914, upload-time = "2026-10-19T12:53:28.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/0d/3bf3ef9c0fda1e44ab7ed4e6e81a24d4428d0ff85ea1a01f3ab54ec6c956/modern_di-4.0.0-py3-none-any.whl", hash = "sha256:0634d9a9e5159d92572c93090c0f9d939291c2de9b0b86de925ba7e0c91f30ce", size = 49954, upload-time = "2026-10-09T17:33:37.977Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/8fa15e799ca5b01c1e1d6415523f1e956cabecddf41a2da4a4839065a56e/modern_di-4.3.0-py3-none-any.whl", hash = "sha256:d058ce95c5313e4d05ab9d389f3667f872cfcc5fbadc5926ee8c1b83ca6d7bb4", size = 56143, upload-time = "2026-10-19T12:53:27.084Z" }, ] [[package]]