Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions app/database/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion app/ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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]
3 changes: 2 additions & 1 deletion tests/api/test_statement_counter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 16 additions & 3 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing

import modern_di
import sqlalchemy as sa
from advanced_alchemy.exceptions import NotFoundError
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down
3 changes: 2 additions & 1 deletion tests/use_cases/test_autocommit_reads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion tests/use_cases/test_connection_loss.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@
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)


@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:
Expand Down
3 changes: 2 additions & 1 deletion tests/use_cases/test_create_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion tests/use_cases/test_create_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading