diff --git a/AGENTS.md b/AGENTS.md index ca1f7ba..d9c29d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,16 +96,17 @@ which raises at startup if a non-local environment is still running the default JWT secret. **Dependency injection** (`app/ioc.py`): one `modern_di.Container` built from -`ALL_GROUPS = [Database, Repositories, UseCases]`, attached via +`ALL_GROUPS = [Config, Database, Repositories, UseCases]`, attached via `modern_di_litestar.ModernDIPlugin`. Route handlers receive use cases as parameters typed `NamedDependency[SomeUseCase]`; nothing wires them by hand. `build_app` passes `autowired_groups=[ioc.UseCases]` to the plugin, which registers one Litestar dependency per provider on that group, named after the -provider attribute (`create_chat_use_case`, ...). `Database` and `Repositories` -are deliberately **not** autowired: a route handler has no business resolving -a session, a transaction or a repository directly. Provider scopes: -- `Database.database_engine`: app-scoped factory, `cache=` finalizer disposes - the engine. +provider attribute (`create_chat_use_case`, ...). `Config`, `Database` and +`Repositories` are deliberately **not** autowired: a route handler has no +business resolving settings, a session, a transaction or a repository directly. Provider scopes: +- `Config.settings`: app-scoped cached `Settings`. +- `Database.database_engine`: app-scoped factory that takes `Config.settings` + by type, `cache=` finalizer disposes the engine. - `Database.database_session`: request-scoped, finalizer closes the session. - `Database.transaction`: request-scoped `db_retry.Transaction`, the object every use case wraps its work in. @@ -156,6 +157,11 @@ enforces this. env vars (see `docker-compose.yml`). `api_bootstrapper_config` builds the `LitestarConfig` consumed by `lite-bootstrap`. `jwt_cookie_secure` defaults `False` for local `http://` development and must be `True` behind HTTPS. +The engine gets settings from `ioc.Config.settings`, so a test can change its +config with `container.override(ioc.Config.settings, Settings(...))` before the +engine is first built. `app/api/__main__.py`, `migrations/env.py`, `build_app` +and `app/api/auth.py` read the module-level `settings` instead, because they run +outside the container or before it exists. ## Conventions diff --git a/app/database/resources.py b/app/database/resources.py index 0778eeb..1645baa 100644 --- a/app/database/resources.py +++ b/app/database/resources.py @@ -4,10 +4,10 @@ from sqlalchemy.engine.interfaces import IsolationLevel from sqlalchemy.ext import asyncio as sa -from app.settings import settings +from app.settings import Settings -def create_database_engine() -> sa.AsyncEngine: +def create_database_engine(settings: Settings) -> sa.AsyncEngine: return sa.create_async_engine( url=settings.db_dsn_parsed, echo=settings.service_debug, diff --git a/app/ioc.py b/app/ioc.py index 7153a4c..78318b5 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -9,6 +9,7 @@ from app.repositories.messages_repository import MessagesRepository from app.repositories.revoked_tokens_repository import RevokedTokensRepository from app.repositories.users_repository import UsersRepository +from app.settings import Settings from app.use_cases.authenticate_user import AuthenticateUserUseCase from app.use_cases.check_token_revoked import CheckTokenRevokedUseCase from app.use_cases.create_chat import CreateChatUseCase @@ -24,6 +25,10 @@ from app.use_cases.revoke_token import RevokeTokenUseCase +class Config(Group): + settings = providers.Factory(creator=Settings, cache=True) + + class Database(Group): database_engine = providers.Factory( creator=database_resources.create_database_engine, @@ -99,4 +104,4 @@ class UseCases(Group, scope=Scope.REQUEST): ) -ALL_GROUPS: typing.Final[list[type[Group]]] = [Database, Repositories, UseCases] +ALL_GROUPS: typing.Final[list[type[Group]]] = [Config, Database, Repositories, UseCases] diff --git a/tests/api/test_statement_counter.py b/tests/api/test_statement_counter.py index f14a40e..67b2ea5 100644 --- a/tests/api/test_statement_counter.py +++ b/tests/api/test_statement_counter.py @@ -2,11 +2,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database.resources import create_database_engine +from app.settings import settings from tests.api.helpers import count_statements as _count_statements async def test_count_statements_ignores_other_connections(db_session: AsyncSession) -> None: - engine = create_database_engine() + engine = create_database_engine(settings) try: with _count_statements(db_session) as statements: async with engine.connect() as other_connection: diff --git a/tests/conftest.py b/tests/conftest.py index edbe170..60bfbcf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ from app import ioc from app.api.app import build_app from app.database.resources import create_database_engine, create_session +from app.settings import settings @pytest.fixture @@ -42,7 +43,7 @@ async def di_container(app: litestar.Litestar) -> typing.AsyncIterator[modern_di @contextlib.asynccontextmanager async def rolled_back_db_session(di_container: modern_di.Container) -> collections.abc.AsyncGenerator[AsyncSession]: - engine = create_database_engine() + engine = create_database_engine(settings) connection = await engine.connect() transaction = await connection.begin() di_container.override(ioc.Database.database_engine, connection) diff --git a/tests/test_main.py b/tests/test_main.py index 53e686c..a5b1e9b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,3 +1,5 @@ +import typing + import modern_di import sqlalchemy as sa from advanced_alchemy.exceptions import NotFoundError @@ -10,7 +12,7 @@ from app.database.resources import close_database_engine, create_database_engine from app.database.tables import UsersTable from app.exceptions import PermissionDeniedError -from app.settings import settings +from app.settings import Settings, settings from tests.conftest import rolled_back_db_session from tests.factories import UserFactory @@ -43,8 +45,19 @@ async def test_permission_denied_handler_defaults_message_when_empty() -> None: assert response.content == {"detail": "Permission denied"} +async def test_database_engine_uses_overridden_settings() -> None: + container: typing.Final = modern_di.Container(groups=ioc.ALL_GROUPS) + container.override(ioc.Config.settings, Settings(db_pool_size=2)) + try: + pool: typing.Final = container.resolve_provider(ioc.Database.database_engine).pool + assert isinstance(pool, QueuePool) + assert pool.size() == 2 + finally: + await container.close_async() + + async def test_create_database_engine_reads_settings_and_can_be_disposed() -> None: - engine = create_database_engine() + engine = create_database_engine(settings) try: assert isinstance(engine.pool, QueuePool) assert engine.pool.size() == settings.db_pool_size @@ -64,7 +77,7 @@ async def test_db_session_teardown_rolls_back_committed_writes(di_container: mod await session.commit() assert (await session.scalars(sa.select(UsersTable).where(UsersTable.id == user.id))).one() is user - engine = create_database_engine() + engine = create_database_engine(settings) try: async with engine.connect() as connection: result = await connection.scalars(sa.select(UsersTable.id).where(UsersTable.id == user.id)) diff --git a/tests/use_cases/test_autocommit_reads.py b/tests/use_cases/test_autocommit_reads.py index 538d56b..a3073d5 100644 --- a/tests/use_cases/test_autocommit_reads.py +++ b/tests/use_cases/test_autocommit_reads.py @@ -10,6 +10,7 @@ from app.actor import Actor from app.database.resources import create_database_engine from app.exceptions import PermissionDeniedError +from app.settings import settings UNKNOWN_ACTOR: typing.Final = Actor(id=-1) @@ -30,7 +31,7 @@ async def test_single_statement_read_sends_no_begin_or_commit( provider: modern_di.providers.Factory[typing.Any], kwargs: dict[str, typing.Any], ) -> None: - engine: typing.Final = create_database_engine() + engine: typing.Final = create_database_engine(settings) in_transaction: typing.Final[list[bool]] = [] def record(connection: sa.Connection, *_: object) -> None: diff --git a/tests/use_cases/test_connection_loss.py b/tests/use_cases/test_connection_loss.py index 31c0e9a..ef828d9 100644 --- a/tests/use_cases/test_connection_loss.py +++ b/tests/use_cases/test_connection_loss.py @@ -15,6 +15,7 @@ from app.database.resources import create_database_engine from app.exceptions import PermissionDeniedError from app.schemas.api import CreateChatRequest, MarkReadRequest, SendMessageRequest +from app.settings import settings UNKNOWN_ACTOR: typing.Final = Actor(id=-1) @@ -22,7 +23,7 @@ @pytest.fixture async def request_container(di_container: modern_di.Container) -> typing.AsyncIterator[modern_di.Container]: - engine: typing.Final = create_database_engine() + engine: typing.Final = create_database_engine(settings) di_container.override(ioc.Database.database_engine, engine) try: async with di_container.build_child_container(scope=modern_di.Scope.REQUEST) as container: diff --git a/tests/use_cases/test_create_chat.py b/tests/use_cases/test_create_chat.py index 640429f..2df9e64 100644 --- a/tests/use_cases/test_create_chat.py +++ b/tests/use_cases/test_create_chat.py @@ -14,6 +14,7 @@ from app.repositories.chat_members_repository import ChatMembersRepository from app.repositories.chats_repository import ChatsRepository from app.schemas import api as schemas +from app.settings import settings from app.use_cases.create_chat import CreateChatUseCase from tests.api.helpers import count_statements from tests.factories import UserFactory @@ -104,7 +105,7 @@ async def test_direct_chat_returns_the_chat_a_concurrent_request_created_for_the It reports created=False and inserts no members of its own. Broken by switching to ON CONFLICT DO NOTHING, whose RETURNING yields no row for the request that loses the race. """ - engine: typing.Final = create_database_engine() + engine: typing.Final = create_database_engine(settings) setup_session: typing.Final = create_session(engine) users: typing.Final = [ UserFactory.build( diff --git a/tests/use_cases/test_create_message.py b/tests/use_cases/test_create_message.py index 1ab0be4..fe326cf 100644 --- a/tests/use_cases/test_create_message.py +++ b/tests/use_cases/test_create_message.py @@ -18,6 +18,7 @@ from app.repositories.chats_repository import ChatsRepository from app.repositories.messages_repository import MessagesRepository from app.schemas import api as schemas +from app.settings import settings from app.use_cases.create_chat import CreateChatUseCase from app.use_cases.create_message import CreateMessageUseCase from tests.api.helpers import count_statements @@ -141,7 +142,7 @@ async def test_send_returns_the_message_a_concurrent_request_created_with_the_sa It reports created=False and leaves last_message_id alone. Broken by switching to ON CONFLICT DO NOTHING, whose RETURNING yields no row for the request that loses the race. """ - engine: typing.Final = create_database_engine() + engine: typing.Final = create_database_engine(settings) idempotency_key: typing.Final = uuid.uuid4() setup_session: typing.Final = create_session(engine) user: typing.Final = UserFactory.build(