Skip to content

Commit a8c9572

Browse files
lesnik512claude
andauthored
refactor: deepen ORM→HTTP serialization behind a Collection seam (#54)
Port of modern-python/litestar-sqlalchemy-template#29, adapted to this repo's idiom and extended to converge on the litestar template's end state: no casts anywhere, every handler routed through its schema. Introduce a deep generic `Collection[T]` seam in app/schemas.py so the ORM→schema coercion happens once behind a small interface. `Cards`/`Decks` become `Collection[Card]`/`Collection[Deck]` subclasses (schema names unchanged). List handlers call `schemas.Xs.from_models(objects)`. Kill every `typing.cast` in app/api/decks.py — the local equivalent of the leak the litestar PR removed. Single-object handlers now return via `schemas.X.model_validate(instance)`; collections via `from_models`. Update the CLAUDE.md convention to mandate explicit ORM→schema conversion and forbid casts. Wire contract unchanged; 19 tests pass, 100% coverage held. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fd1a892 commit a8c9572

3 files changed

Lines changed: 27 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,5 @@ Endpoints inject repositories with `FromDI(Repository)` from `modern_di_fastapi`
7070

7171
- Type-ignore syntax is `# ty: ignore[error-code]` (this project uses `ty`, not mypy). See `app/application.py:39` for an example.
7272
- Ruff is configured with `select = ["ALL"]` and a curated ignore list in `pyproject.toml`. Don't sprinkle `# noqa`; prefer fixing or extending the project ignore list if a rule is genuinely wrong for the codebase.
73-
- Routes return `typing.cast("schemas.X", obj)` over ORM/dict objects rather than constructing Pydantic models — the schemas use `from_attributes=True`.
73+
- Routes convert ORM objects to schemas explicitly, never via `typing.cast`. Single objects: `schemas.X.model_validate(instance)`. Collections: `schemas.Xs.from_models(objects)` (the `Collection[T]` seam in `app/schemas.py`). Both rely on `from_attributes=True`.
7474
- Line length is 120.

app/api/decks.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ async def list_decks(
1515
decks_repository: DecksRepository = FromDI(DecksRepository),
1616
) -> schemas.Decks:
1717
objects = await decks_repository.get_many()
18-
return typing.cast("schemas.Decks", {"items": objects})
18+
return schemas.Decks.from_models(objects)
1919

2020

2121
@ROUTER.get("/decks/{deck_id}/")
@@ -24,7 +24,7 @@ async def get_deck(
2424
decks_repository: DecksRepository = FromDI(DecksRepository),
2525
) -> schemas.Deck:
2626
instance = await decks_repository.fetch_with_cards(deck_id)
27-
return typing.cast("schemas.Deck", instance)
27+
return schemas.Deck.model_validate(instance)
2828

2929

3030
@ROUTER.put("/decks/{deck_id}/")
@@ -34,7 +34,7 @@ async def update_deck(
3434
decks_repository: DecksRepository = FromDI(DecksRepository),
3535
) -> schemas.Deck:
3636
instance = await decks_repository.update(data=data.model_dump(), item_id=deck_id)
37-
return typing.cast("schemas.Deck", instance)
37+
return schemas.Deck.model_validate(instance)
3838

3939

4040
@ROUTER.post("/decks/")
@@ -43,7 +43,7 @@ async def create_deck(
4343
decks_repository: DecksRepository = FromDI(DecksRepository),
4444
) -> schemas.Deck:
4545
instance = await decks_repository.create(data.model_dump())
46-
return typing.cast("schemas.Deck", instance)
46+
return schemas.Deck.model_validate(instance)
4747

4848

4949
@ROUTER.get("/decks/{deck_id}/cards/")
@@ -52,7 +52,7 @@ async def list_cards(
5252
cards_repository: CardsRepository = FromDI(CardsRepository),
5353
) -> schemas.Cards:
5454
objects = await cards_repository.list_for_deck(deck_id)
55-
return typing.cast("schemas.Cards", {"items": objects})
55+
return schemas.Cards.from_models(objects)
5656

5757

5858
@ROUTER.get("/cards/{card_id}/")
@@ -61,7 +61,7 @@ async def get_card(
6161
cards_repository: CardsRepository = FromDI(CardsRepository),
6262
) -> schemas.Card:
6363
instance = await cards_repository.get_one(models.Card.id == card_id)
64-
return typing.cast("schemas.Card", instance)
64+
return schemas.Card.model_validate(instance)
6565

6666

6767
@ROUTER.post("/decks/{deck_id}/cards/")
@@ -71,7 +71,7 @@ async def create_cards(
7171
cards_repository: CardsRepository = FromDI(CardsRepository),
7272
) -> schemas.Cards:
7373
objects = await cards_repository.add_cards(deck_id, data)
74-
return typing.cast("schemas.Cards", {"items": objects})
74+
return schemas.Cards.from_models(objects)
7575

7676

7777
@ROUTER.put("/decks/{deck_id}/cards/")
@@ -81,4 +81,4 @@ async def update_cards(
8181
cards_repository: CardsRepository = FromDI(CardsRepository),
8282
) -> schemas.Cards:
8383
objects = await cards_repository.upsert_cards(deck_id, data)
84-
return typing.cast("schemas.Cards", {"items": objects})
84+
return schemas.Cards.from_models(objects)

app/schemas.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
1+
from typing import TYPE_CHECKING, Self
2+
13
import pydantic
24
from pydantic import BaseModel, PositiveInt
35

46

7+
if TYPE_CHECKING:
8+
from collections.abc import Iterable
9+
10+
511
class Base(BaseModel):
612
model_config = pydantic.ConfigDict(from_attributes=True)
713

814

15+
class Collection[T: Base](Base):
16+
items: list[T]
17+
18+
@classmethod
19+
def from_models(cls, objects: Iterable[object]) -> Self:
20+
return cls.model_validate({"items": list(objects)})
21+
22+
923
class CardBase(Base):
1024
front: str
1125
back: str | None = None
@@ -21,8 +35,8 @@ class Card(CardBase):
2135
deck_id: PositiveInt | None = None
2236

2337

24-
class Cards(Base):
25-
items: list[Card]
38+
class Cards(Collection[Card]):
39+
pass
2640

2741

2842
class DeckBase(Base):
@@ -39,5 +53,5 @@ class Deck(DeckBase):
3953
cards: list[Card] | None
4054

4155

42-
class Decks(Base):
43-
items: list[Deck]
56+
class Decks(Collection[Deck]):
57+
pass

0 commit comments

Comments
 (0)