Skip to content

Commit 90a37fd

Browse files
authored
feat: provide settings through the container and derive engine URLs (#104)
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.
1 parent 5d03abd commit 90a37fd

9 files changed

Lines changed: 60 additions & 49 deletions

File tree

‎AGENTS.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,13 @@ CI (`.github/workflows/main.yml`) runs `ruff format --check`, `ruff check --no-f
3939

4040
### DI scopes (modern-di)
4141

42-
`app/ioc.py` splits providers into three groups, collected in `ALL_GROUPS`.
42+
`app/ioc.py` splits providers into four groups, collected in `ALL_GROUPS`.
43+
44+
`Config`:
45+
- `settings`: cached `Settings`, overridable in tests (see Settings below).
4346

4447
`Database`:
45-
- `database_engine`: singleton-ish primary `AsyncEngine` (`create_primary_sa_engine`, `close_sa_engine` finalizer).
48+
- `database_engine`: singleton-ish primary `AsyncEngine` (`create_sa_engine` with the URL from `Config.settings.derive(...)`, `close_sa_engine` finalizer).
4649
- `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.
4750
- `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.
4851
- `session`: `Scope.REQUEST`, bound to `dynamic_engine`, finalized by `close_session`.
@@ -95,7 +98,7 @@ class ListDecksUseCase:
9598

9699
### Settings
97100

98-
`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.
101+
`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.
99102

100103
### Tests
101104

‎app/ioc.py‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
choose_sa_engine,
1010
close_sa_engine,
1111
close_session,
12-
create_primary_sa_engine,
13-
create_read_sa_engine,
12+
create_sa_engine,
1413
create_session,
14+
read_engine_url,
1515
)
16+
from app.settings import Settings
1617
from app.use_cases.create_cards import CreateCardsUseCase
1718
from app.use_cases.create_deck import CreateDeckUseCase
1819
from app.use_cases.get_card import GetCardUseCase
@@ -23,16 +24,22 @@
2324
from app.use_cases.update_deck import UpdateDeckUseCase
2425

2526

27+
class Config(Group):
28+
settings = providers.Factory(creator=Settings, cache=True)
29+
30+
2631
class Database(Group):
2732
database_engine = providers.Factory(
28-
creator=create_primary_sa_engine,
33+
creator=create_sa_engine,
2934
cache=providers.CacheSettings(finalizer=close_sa_engine),
3035
bound_type=None,
36+
kwargs={"settings": Config.settings, "url": Config.settings.derive(lambda s: s.db_dsn_parsed)},
3137
)
3238
database_read_engine = providers.Factory(
33-
creator=create_read_sa_engine,
39+
creator=create_sa_engine,
3440
cache=providers.CacheSettings(finalizer=close_sa_engine),
3541
bound_type=None,
42+
kwargs={"settings": Config.settings, "url": Config.settings.derive(read_engine_url), "read_only": True},
3643
)
3744
dynamic_engine = providers.Factory(
3845
scope=Scope.REQUEST,
@@ -85,4 +92,4 @@ class UseCases(Group, scope=Scope.REQUEST):
8592
update_cards_use_case = providers.Factory(creator=UpdateCardsUseCase)
8693

8794

88-
ALL_GROUPS: typing.Final[list[type[Group]]] = [Database, Repositories, UseCases]
95+
ALL_GROUPS: typing.Final[list[type[Group]]] = [Config, Database, Repositories, UseCases]

‎app/resources/db.py‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from sqlalchemy.engine.url import URL, make_url
88
from sqlalchemy.ext import asyncio as sa
99

10-
from app.settings import settings
10+
from app.settings import Settings
1111

1212

1313
logger = logging.getLogger(__name__)
@@ -16,7 +16,7 @@
1616
READ_METHODS: typing.Final = frozenset({"GET", "HEAD"})
1717

1818

19-
def create_sa_engine(url: URL, *, read_only: bool = False) -> sa.AsyncEngine:
19+
def create_sa_engine(*, settings: Settings, url: URL, read_only: bool = False) -> sa.AsyncEngine:
2020
return sa.create_async_engine(
2121
url=url,
2222
echo=settings.service_debug,
@@ -31,15 +31,8 @@ def create_sa_engine(url: URL, *, read_only: bool = False) -> sa.AsyncEngine:
3131
)
3232

3333

34-
def create_primary_sa_engine() -> sa.AsyncEngine:
35-
return create_sa_engine(settings.db_dsn_parsed)
36-
37-
38-
def create_read_sa_engine() -> sa.AsyncEngine:
39-
return create_sa_engine(
40-
make_url(settings.db_replica_dsn) if settings.db_replica_dsn else settings.db_dsn_parsed,
41-
read_only=True,
42-
)
34+
def read_engine_url(settings: Settings) -> URL:
35+
return make_url(settings.db_replica_dsn) if settings.db_replica_dsn else settings.db_dsn_parsed
4336

4437

4538
def autocommit_isolation_level() -> IsolationLevel | None:

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ license = "MIT"
1111
dependencies = [
1212
"fastapi>=0.76",
1313
"lite-bootstrap[fastapi-all]",
14+
"modern-di>=4.3.0,<5",
1415
"modern-di-fastapi>=4,<5",
1516
"advanced-alchemy",
1617
"pydantic-settings",

‎tests/conftest.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99

1010
from app import ioc
1111
from app.application import build_app
12-
from app.resources.db import create_primary_sa_engine, create_session
12+
from app.resources.db import create_sa_engine, create_session
13+
from app.settings import settings
1314

1415

1516
if typing.TYPE_CHECKING:
@@ -49,7 +50,7 @@ async def di_container(app: fastapi.FastAPI) -> typing.AsyncIterator[modern_di.C
4950

5051
@pytest.fixture
5152
async def db_session(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncSession]:
52-
engine = create_primary_sa_engine()
53+
engine = create_sa_engine(settings=settings, url=settings.db_dsn_parsed)
5354
connection = await engine.connect()
5455
transaction = await connection.begin()
5556
di_container.override(ioc.Database.dynamic_engine, connection)
@@ -96,7 +97,7 @@ def record(*args: object) -> None:
9697

9798
@pytest.fixture
9899
async def committing_engine(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncEngine]:
99-
engine = create_primary_sa_engine()
100+
engine = create_sa_engine(settings=settings, url=settings.db_dsn_parsed)
100101
di_container.override(ioc.Database.dynamic_engine, engine)
101102
try:
102103
yield engine

‎tests/test_create_deck.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
from app import models, schemas
99
from app.repositories import DecksRepository
10-
from app.resources.db import create_primary_sa_engine, create_session
10+
from app.resources.db import create_sa_engine, create_session
11+
from app.settings import settings
1112
from app.use_cases.create_deck import CreateDeckUseCase
1213

1314

@@ -26,7 +27,7 @@ async def _wait_until_blocked_on_a_lock(engine: AsyncEngine, pid: int) -> None:
2627

2728

2829
async def test_create_deck_returns_the_deck_a_concurrent_request_created_with_the_same_key() -> None:
29-
engine: typing.Final = create_primary_sa_engine()
30+
engine: typing.Final = create_sa_engine(settings=settings, url=settings.db_dsn_parsed)
3031
idempotency_key: typing.Final = uuid.uuid4()
3132
try:
3233
async with engine.connect() as concurrent_request:

‎tests/test_db_routing.py‎

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,33 @@
11
import typing
22

33
import fastapi
4+
import modern_di
45
import pytest
56
import sqlalchemy as sa
67
from modern_di import Scope
78
from sqlalchemy.exc import DBAPIError
89

910
from app import ioc, models
10-
from app.resources.db import create_read_sa_engine, create_sa_engine
11-
from app.settings import settings
11+
from app.resources.db import create_sa_engine, read_engine_url
12+
from app.settings import Settings, settings
1213

1314

1415
if typing.TYPE_CHECKING:
15-
import modern_di
16+
from sqlalchemy.engine.url import URL
1617
from sqlalchemy.ext.asyncio import AsyncEngine
1718

1819

1920
@pytest.fixture
2021
async def primary_engine(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncEngine]:
21-
engine = create_sa_engine(settings.db_dsn_parsed)
22+
engine = create_sa_engine(settings=settings, url=settings.db_dsn_parsed)
2223
di_container.override(ioc.Database.database_engine, engine)
2324
yield engine
2425
await engine.dispose()
2526

2627

2728
@pytest.fixture
2829
async def read_engine(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncEngine]:
29-
engine = create_read_sa_engine()
30+
engine = create_sa_engine(settings=settings, url=read_engine_url(settings), read_only=True)
3031
di_container.override(ioc.Database.database_read_engine, engine)
3132
yield engine
3233
await engine.dispose()
@@ -61,21 +62,26 @@ def test_no_request_uses_primary(di_container: modern_di.Container, primary_engi
6162
assert resolve_engine(di_container, None) is primary_engine
6263

6364

65+
async def resolve_read_engine_url(app_settings: Settings) -> URL:
66+
container: typing.Final = modern_di.Container(groups=ioc.ALL_GROUPS)
67+
container.override(ioc.Config.settings, app_settings)
68+
try:
69+
return container.resolve_provider(ioc.Database.database_read_engine).url
70+
finally:
71+
await container.close_async()
72+
73+
6474
async def test_read_engine_built_from_primary_dsn_without_replica() -> None:
65-
engine = create_read_sa_engine()
66-
assert engine.url == settings.db_dsn_parsed
67-
await engine.dispose()
75+
assert await resolve_read_engine_url(Settings(db_replica_dsn="")) == settings.db_dsn_parsed
6876

6977

70-
async def test_read_engine_built_from_replica_dsn(monkeypatch: pytest.MonkeyPatch) -> None:
71-
monkeypatch.setattr(settings, "db_replica_dsn", "postgresql+asyncpg://postgres:password@replica/postgres")
72-
engine = create_read_sa_engine()
73-
assert engine.url.host == "replica"
74-
await engine.dispose()
78+
async def test_read_engine_built_from_overridden_replica_dsn() -> None:
79+
replica_settings: typing.Final = Settings(db_replica_dsn="postgresql+asyncpg://postgres:password@replica/postgres")
80+
assert (await resolve_read_engine_url(replica_settings)).host == "replica"
7581

7682

7783
async def test_read_engine_runs_each_statement_in_its_own_transaction() -> None:
78-
engine = create_read_sa_engine()
84+
engine = create_sa_engine(settings=settings, url=read_engine_url(settings), read_only=True)
7985
async with engine.connect() as connection:
8086
first = await connection.scalar(sa.text("SELECT transaction_timestamp()"))
8187
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:
8490

8591

8692
async def test_read_engine_rejects_writes() -> None:
87-
engine = create_read_sa_engine()
93+
engine = create_sa_engine(settings=settings, url=read_engine_url(settings), read_only=True)
8894
async with engine.connect() as connection:
8995
with pytest.raises(DBAPIError, match="read-only transaction"):
9096
await connection.execute(sa.insert(models.Deck).values(name="read only"))

‎tests/test_pool.py‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
from fastapi import status
77

88
from app import ioc
9-
from app.resources.db import create_primary_sa_engine
10-
from app.settings import settings
9+
from app.resources.db import create_sa_engine
10+
from app.settings import Settings
1111
from tests import factories
1212

1313

@@ -26,13 +26,10 @@
2626
async def test_exhausted_pool_returns_503(
2727
client: AsyncClient,
2828
di_container: modern_di.Container,
29-
monkeypatch: pytest.MonkeyPatch,
3029
request_kwargs: dict[str, typing.Any],
3130
) -> None:
32-
monkeypatch.setattr(settings, "db_pool_size", 1)
33-
monkeypatch.setattr(settings, "db_max_overflow", 0)
34-
monkeypatch.setattr(settings, "db_pool_timeout", 0.1)
35-
engine: typing.Final = create_primary_sa_engine()
31+
pool_settings: typing.Final = Settings(db_pool_size=1, db_max_overflow=0, db_pool_timeout=0.1)
32+
engine: typing.Final = create_sa_engine(settings=pool_settings, url=pool_settings.db_dsn_parsed)
3633
di_container.override(ioc.Database.dynamic_engine, engine)
3734
try:
3835
async with engine.connect():

‎uv.lock‎

Lines changed: 5 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)