From 7dc8bd1c5c1de6ad62ee439d399ee37fc3d49b72 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 19 Oct 2026 17:17:54 +0300 Subject: [PATCH] feat: provide settings through the container and derive engine URLs Engines take Settings from ioc.Config.settings and get their URL via provider.derive, so tests override settings instead of monkeypatching the module-level instance. Requires modern-di 4.3.0. --- AGENTS.md | 9 ++++++--- app/ioc.py | 17 ++++++++++++----- app/resources/db.py | 15 ++++----------- pyproject.toml | 1 + tests/conftest.py | 7 ++++--- tests/test_create_deck.py | 5 +++-- tests/test_db_routing.py | 36 +++++++++++++++++++++--------------- tests/test_pool.py | 11 ++++------- uv.lock | 8 +++++--- 9 files changed, 60 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9f11e58..91f3bc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,10 +39,13 @@ CI (`.github/workflows/main.yml`) runs `ruff format --check`, `ruff check --no-f ### DI scopes (modern-di) -`app/ioc.py` splits providers into three groups, collected in `ALL_GROUPS`. +`app/ioc.py` splits providers into four groups, collected in `ALL_GROUPS`. + +`Config`: +- `settings`: cached `Settings`, overridable in tests (see Settings below). `Database`: -- `database_engine`: singleton-ish primary `AsyncEngine` (`create_primary_sa_engine`, `close_sa_engine` finalizer). +- `database_engine`: singleton-ish primary `AsyncEngine` (`create_sa_engine` with the URL from `Config.settings.derive(...)`, `close_sa_engine` finalizer). - `database_read_engine`: singleton-ish read `AsyncEngine` 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 read that runs several queries can see a write that commits in between. Load related rows in the same query (`fetch_with_cards` uses `joinedload`). 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`: `Scope.REQUEST`; `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`: `Scope.REQUEST`, bound to `dynamic_engine`, finalized by `close_session`. @@ -95,7 +98,7 @@ class ListDecksUseCase: ### Settings -`app/settings.py`: `pydantic_settings.BaseSettings`. Env vars are unprefixed (`DB_DSN`, `DB_REPLICA_DSN`, `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `LOG_LEVEL`, `APP_HOST`, `APP_PORT`, `OPENTELEMETRY_ENDPOINT`, `SENTRY_DSN`, `CORS_ALLOWED_ORIGINS`, `APP_WORKERS`, `DB_POOL_TIMEOUT`, `DB_POOL_RECYCLE`, ...). `api_bootstrapper_config` produces a `FastAPIConfig` for `lite-bootstrap`. `LOG_LEVEL` is case-insensitive and sets both Granian's log level and the app's root logger. +`app/settings.py`: `pydantic_settings.BaseSettings`. Env vars are unprefixed (`DB_DSN`, `DB_REPLICA_DSN`, `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `LOG_LEVEL`, `APP_HOST`, `APP_PORT`, `OPENTELEMETRY_ENDPOINT`, `SENTRY_DSN`, `CORS_ALLOWED_ORIGINS`, `APP_WORKERS`, `DB_POOL_TIMEOUT`, `DB_POOL_RECYCLE`, ...). `api_bootstrapper_config` produces a `FastAPIConfig` for `lite-bootstrap`. `LOG_LEVEL` is case-insensitive and sets both Granian's log level and the app's root logger. 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. ### Tests 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 3e39f7a..029b3de 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 80d70a6..435ea15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ license = "MIT" dependencies = [ "fastapi>=0.76", "lite-bootstrap[fastapi-all]", + "modern-di>=4.3.0,<5", "modern-di-fastapi>=4,<5", "advanced-alchemy", "pydantic-settings", diff --git a/tests/conftest.py b/tests/conftest.py index 7f35f01..3c8233f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,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: @@ -49,7 +50,7 @@ async def di_container(app: fastapi.FastAPI) -> typing.AsyncIterator[modern_di.C @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) @@ -96,7 +97,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 8026741..dbb1573 100644 --- a/tests/test_create_deck.py +++ b/tests/test_create_deck.py @@ -7,7 +7,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 @@ -26,7 +27,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 e0a56e6..3389373 100644 --- a/tests/test_db_routing.py +++ b/tests/test_db_routing.py @@ -1,24 +1,25 @@ import typing import fastapi +import modern_di import pytest import sqlalchemy as sa from modern_di import Scope 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.engine.url import URL 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() @@ -26,7 +27,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() @@ -61,21 +62,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()")) @@ -84,7 +90,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 97126b2..0404b34 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -6,8 +6,8 @@ from fastapi import 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 @@ -26,13 +26,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 828ff64..ae22b06 100644 --- a/uv.lock +++ b/uv.lock @@ -268,6 +268,7 @@ dependencies = [ { name = "fastapi" }, { name = "granian", extra = ["uvloop"] }, { name = "lite-bootstrap", extra = ["fastapi-all"] }, + { name = "modern-di" }, { name = "modern-di-fastapi" }, { name = "opentelemetry-instrumentation-sqlalchemy" }, { name = "psycopg" }, @@ -300,6 +301,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.76" }, { name = "granian", extras = ["uvloop"] }, { name = "lite-bootstrap", extras = ["fastapi-all"] }, + { name = "modern-di", specifier = ">=4.3.0,<5" }, { name = "modern-di-fastapi", specifier = ">=4,<5" }, { name = "opentelemetry-instrumentation-sqlalchemy" }, { name = "psycopg" }, @@ -595,11 +597,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]]