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
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: astral-sh/setup-uv@v3
- run: uv python install 3.13
- run: uv python install 3.14
- run: |
uv sync --all-extras --all-groups --frozen --no-install-project
uv run ruff format . --check
Expand All @@ -45,7 +45,7 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: astral-sh/setup-uv@v3
- run: uv python install 3.13
- run: uv python install 3.14
- run: |
uv sync --all-extras --all-groups --frozen --no-install-project
uv run alembic upgrade head
Expand Down
8 changes: 5 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.13-slim
FROM python:3.14-slim

# required for psycopg2
RUN apt update \
Expand All @@ -11,8 +11,10 @@ RUN apt update \
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
RUN useradd --no-create-home --gid root runner

ENV UV_PYTHON_PREFERENCE=only-system
ENV UV_NO_CACHE=true
ENV UV_PROJECT_ENVIRONMENT=/code/.venv \
UV_NO_MANAGED_PYTHON=1 \
UV_NO_CACHE=true \
UV_LINK_MODE=copy

WORKDIR /code

Expand Down
36 changes: 19 additions & 17 deletions app/api/decks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@
from sqlalchemy import orm

from app import models, schemas
from app.repositories import CardsService, DecksService
from app.repositories import CardsRepository, DecksRepository # noqa: TC001


@litestar.get("/decks/")
async def list_decks(decks_service: DecksService) -> schemas.Decks:
objects = await decks_service.list()
async def list_decks(decks_repository: DecksRepository) -> schemas.Decks:
objects = await decks_repository.list()
return schemas.Decks(items=objects) # type: ignore[arg-type]


@litestar.get("/decks/{deck_id:int}/")
async def get_deck(deck_id: int, decks_service: DecksService) -> schemas.Deck:
instance = await decks_service.get_one_or_none(
async def get_deck(deck_id: int, decks_repository: DecksRepository) -> schemas.Deck:
instance = await decks_repository.get_one_or_none(
models.Deck.id == deck_id,
load=[orm.selectinload(models.Deck.cards)],
)
Expand All @@ -33,46 +33,48 @@ async def get_deck(deck_id: int, decks_service: DecksService) -> schemas.Deck:
async def update_deck(
deck_id: int,
data: schemas.DeckCreate,
decks_service: DecksService,
decks_repository: DecksRepository,
) -> schemas.Deck:
try:
instance = await decks_service.update(data=data.model_dump(), item_id=deck_id)
instance = await decks_repository.update(data=data.model_dump(), item_id=deck_id)
except NotFoundError:
raise HTTPException(status_code=status_codes.HTTP_404_NOT_FOUND, detail="Deck is not found") from None
return schemas.Deck.model_validate(instance)


@litestar.post("/decks/")
async def create_deck(data: schemas.DeckCreate, decks_service: DecksService) -> schemas.Deck:
instance = await decks_service.create(data)
async def create_deck(data: schemas.DeckCreate, decks_repository: DecksRepository) -> schemas.Deck:
instance = await decks_repository.create(data)
return schemas.Deck.model_validate(instance)


@litestar.get("/decks/{deck_id:int}/cards/")
async def list_cards(deck_id: int, cards_service: CardsService) -> schemas.Cards:
objects = await cards_service.list(models.Card.deck_id == deck_id)
async def list_cards(deck_id: int, cards_repository: CardsRepository) -> schemas.Cards:
objects = await cards_repository.list(models.Card.deck_id == deck_id)
return schemas.Cards(items=objects) # type: ignore[arg-type]


@litestar.get("/cards/{card_id:int}/", return_dto=PydanticDTO[schemas.Card])
async def get_card(card_id: int, cards_service: CardsService) -> schemas.Card:
instance = await cards_service.get_one_or_none(models.Card.id == card_id)
async def get_card(card_id: int, cards_repository: CardsRepository) -> schemas.Card:
instance = await cards_repository.get_one_or_none(models.Card.id == card_id)
if not instance:
raise HTTPException(status_code=status_codes.HTTP_404_NOT_FOUND, detail="Card is not found")
return schemas.Card.model_validate(instance)


@litestar.post("/decks/{deck_id:int}/cards/")
async def create_cards(deck_id: int, data: list[schemas.CardCreate], cards_service: CardsService) -> schemas.Cards:
objects = await cards_service.create_many(
async def create_cards(
deck_id: int, data: list[schemas.CardCreate], cards_repository: CardsRepository
) -> schemas.Cards:
objects = await cards_repository.create_many(
data=[models.Card(**card.model_dump(), deck_id=deck_id) for card in data],
)
return schemas.Cards(items=objects) # type: ignore[arg-type]


@litestar.put("/decks/{deck_id:int}/cards/")
async def update_cards(deck_id: int, data: list[schemas.Card], cards_service: CardsService) -> schemas.Cards:
objects = await cards_service.upsert_many(
async def update_cards(deck_id: int, data: list[schemas.Card], cards_repository: CardsRepository) -> schemas.Cards:
objects = await cards_repository.upsert_many(
data=[models.Card(**card.model_dump(exclude={"deck_id"}), deck_id=deck_id) for card in data],
)
return schemas.Cards(items=objects) # type: ignore[arg-type]
Expand Down
12 changes: 8 additions & 4 deletions app/application.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import dataclasses
from typing import TYPE_CHECKING

import litestar
import modern_di
import modern_di_litestar
from advanced_alchemy.exceptions import DuplicateKeyError
Expand All @@ -14,8 +14,12 @@
from app.settings import settings


if TYPE_CHECKING:
import litestar


def build_app() -> litestar.Litestar:
di_container = modern_di.AsyncContainer(groups=[ioc.Dependencies])
di_container = modern_di.Container(groups=[ioc.Dependencies])
bootstrap_config = dataclasses.replace(
settings.api_bootstrapper_config,
application_config=AppConfig(
Expand All @@ -25,8 +29,8 @@ def build_app() -> litestar.Litestar:
route_handlers=[ROUTER],
plugins=[modern_di_litestar.ModernDIPlugin(di_container)],
dependencies={
"decks_service": modern_di_litestar.FromDI(repositories.DecksService),
"cards_service": modern_di_litestar.FromDI(repositories.CardsService),
"decks_repository": modern_di_litestar.FromDI(repositories.DecksRepository),
"cards_repository": modern_di_litestar.FromDI(repositories.CardsRepository),
},
request_max_body_size=settings.request_max_body_size,
),
Expand Down
5 changes: 4 additions & 1 deletion app/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import typing

import litestar
from advanced_alchemy.exceptions import DuplicateKeyError
from litestar import status_codes


if typing.TYPE_CHECKING:
from advanced_alchemy.exceptions import DuplicateKeyError


def duplicate_key_error_handler(_: object, exc: DuplicateKeyError) -> litestar.Response[dict[str, typing.Any]]:
return litestar.Response(
media_type=litestar.MediaType.JSON,
Expand Down
28 changes: 22 additions & 6 deletions app/ioc.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
from modern_di import Group, Scope, providers

from app import repositories
from app.resources.db import create_sa_engine, create_session
from app.repositories import CardsRepository, DecksRepository
from app.resources.db import close_sa_engine, close_session, create_sa_engine, create_session


class Dependencies(Group):
database_engine = providers.Resource(Scope.APP, create_sa_engine)
session = providers.Resource(Scope.REQUEST, create_session, engine=database_engine.cast)
database_engine = providers.Factory(
creator=create_sa_engine, cache_settings=providers.CacheSettings(finalizer=close_sa_engine)
)
session = providers.Factory(
scope=Scope.REQUEST, creator=create_session, cache_settings=providers.CacheSettings(finalizer=close_session)
)

decks_service = providers.Factory(Scope.REQUEST, repositories.DecksService, session=session.cast, auto_commit=True)
cards_service = providers.Factory(Scope.REQUEST, repositories.CardsService, session=session.cast, auto_commit=True)
decks_repository = providers.Factory(
scope=Scope.REQUEST,
creator=DecksRepository,
bound_type=DecksRepository,
kwargs={"session": session, "auto_commit": True},
skip_creator_parsing=True,
)
cards_repository = providers.Factory(
scope=Scope.REQUEST,
creator=CardsRepository,
bound_type=CardsRepository,
kwargs={"session": session, "auto_commit": True},
skip_creator_parsing=True,
)
2 changes: 1 addition & 1 deletion app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Deck(BigIntAuditBase):

name: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False)
description: orm.Mapped[str | None] = orm.mapped_column(sa.String, nullable=True)
cards: orm.Mapped[list["Card"]] = orm.relationship("Card", lazy="noload", uselist=True)
cards: orm.Mapped[list[Card]] = orm.relationship("Card", lazy="noload", uselist=True)


class Card(BigIntAuditBase):
Expand Down
18 changes: 8 additions & 10 deletions app/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@
from app import models


class DecksRepository(SQLAlchemyAsyncRepository[models.Deck]):
model_type = models.Deck
class DecksRepository(SQLAlchemyAsyncRepositoryService[models.Deck]):
class BaseRepository(SQLAlchemyAsyncRepository[models.Deck]):
model_type = models.Deck

repository_type = BaseRepository

class DecksService(SQLAlchemyAsyncRepositoryService[models.Deck]):
repository_type = DecksRepository

class CardsRepository(SQLAlchemyAsyncRepositoryService[models.Card]):
class BaseRepository(SQLAlchemyAsyncRepository[models.Card]):
model_type = models.Card

class CardsRepository(SQLAlchemyAsyncRepository[models.Card]):
model_type = models.Card


class CardsService(SQLAlchemyAsyncRepositoryService[models.Card]):
repository_type = CardsRepository
repository_type = BaseRepository
28 changes: 14 additions & 14 deletions app/resources/db.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import logging
import typing

Expand All @@ -9,22 +10,19 @@
logger = logging.getLogger(__name__)


async def create_sa_engine() -> typing.AsyncIterator[sa.AsyncEngine]:
logger.info("Initializing SQLAlchemy engine")
engine = sa.create_async_engine(
def create_sa_engine() -> sa.AsyncEngine:
return sa.create_async_engine(
url=settings.db_dsn_parsed,
echo=settings.service_debug,
echo_pool=settings.service_debug,
pool_size=settings.db_pool_size,
pool_pre_ping=settings.db_pool_pre_ping,
max_overflow=settings.db_max_overflow,
)
logger.info("SQLAlchemy engine has been initialized")
try:
yield engine
finally:
await engine.dispose()
logger.info("SQLAlchemy engine has been cleaned up")


async def close_sa_engine(engine: sa.AsyncEngine) -> None:
await engine.dispose()


class CustomAsyncSession(sa.AsyncSession):
Expand All @@ -35,8 +33,10 @@ async def close(self) -> None:
return await super().close()


async def create_session(engine: sa.AsyncEngine) -> typing.AsyncIterator[sa.AsyncSession]:
async with CustomAsyncSession(engine, expire_on_commit=False, autoflush=False) as session:
logger.info("session created")
yield session
logger.info("session closed")
def create_session(engine: sa.AsyncEngine) -> sa.AsyncSession:
return CustomAsyncSession(engine, expire_on_commit=False, autoflush=False)


async def close_session(session: sa.AsyncSession) -> None:
task: typing.Final = asyncio.create_task(session.close())
await asyncio.shield(task)
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ name = "litestar-sqlalchemy-template"
version = "0"
description = "Async template on Litestar and SQLAlchemy 2"
readme = "README.md"
requires-python = ">=3.13"
requires-python = ">=3.14"
authors = [
{ name = "Artur Shiriev", email = "me@shiriev.ru" },
]
license = "MIT License"
dependencies = [
"litestar",
"lite-bootstrap[litestar-all]",
"modern-di-litestar>=1",
"modern-di-litestar>=2",
"advanced-alchemy",
"pydantic-settings",
"granian[uvloop]",
Expand Down Expand Up @@ -45,7 +45,7 @@ lint = [
fix = true
unsafe-fixes = true
line-length = 120
target-version = "py313"
target-version = "py314"
extend-exclude = ["bin"]

[tool.ruff.lint]
Expand All @@ -72,7 +72,7 @@ isort.no-lines-before = ["standard-library", "local-folder"]
]

[tool.mypy]
python_version = "3.13"
python_version = "3.14"
strict = true
pretty = true

Expand Down
40 changes: 30 additions & 10 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import typing

import litestar
import modern_di
import modern_di_litestar
import pytest
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession

from app import ioc
from app.application import build_app
from app.resources.db import create_sa_engine


if typing.TYPE_CHECKING:
import litestar
import modern_di


@pytest.fixture
Expand All @@ -29,17 +33,21 @@ async def client(app: litestar.Litestar) -> typing.AsyncIterator[AsyncClient]:


@pytest.fixture
def di_container(app: litestar.Litestar) -> modern_di.AsyncContainer:
return modern_di_litestar.fetch_di_container(app)
async def di_container(app: litestar.Litestar) -> typing.AsyncIterator[modern_di.Container]:
container = modern_di_litestar.fetch_di_container(app)
try:
yield container
finally:
await container.close_async()


@pytest.fixture(autouse=True)
async def db_session(di_container: modern_di.AsyncContainer) -> typing.AsyncIterator[AsyncSession]:
engine = await di_container.resolve_provider(ioc.Dependencies.database_engine)
@pytest.fixture
async def db_session(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncSession]:
engine = create_sa_engine()
connection = await engine.connect()
transaction = await connection.begin()
await connection.begin_nested()
di_container.override(ioc.Dependencies.database_engine, connection)
di_container.override(dependency_type=AsyncEngine, mock=connection)

try:
yield AsyncSession(connection, expire_on_commit=False, autoflush=False)
Expand All @@ -48,3 +56,15 @@ async def db_session(di_container: modern_di.AsyncContainer) -> typing.AsyncIter
await transaction.rollback()
await connection.close()
await engine.dispose()
di_container.reset_override()


@pytest.fixture
async def set_async_session_in_base_sqlalchemy_factory(
db_session: AsyncSession,
) -> typing.AsyncIterator[None]:
try:
SQLAlchemyFactory.__async_session__ = db_session
yield
finally:
SQLAlchemyFactory.__async_session__ = None
Loading