This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Python 3.14 async REST API. FastAPI + SQLAlchemy 2 (async) + PostgreSQL + Alembic. Served by Granian (uvloop). Dependencies managed by uv. Linted with ruff, type-checked with ty (not mypy). Observability via lite-bootstrap (OpenTelemetry, Sentry). DI via modern-di. Repositories via advanced-alchemy.
The development workflow runs inside Docker via just (just --list / read the Justfile for the full recipe list). The application service mounts the repo and depends on a db Postgres service. Bare just runs the default pipeline (install + lint + build + test).
Things the recipe names don't make obvious:
just test [args]recreates a clean DB first (alembic downgrade base→upgrade head) and tears the stack down afterward;[args]are passed through topytest.just runapplies migrations then starts the app on :8000.just lintauto-fixes (eof-fixer +ruff format+ruff check --fix+ty check); CI (below) is the no-fix gate.
Running a single test (inside container or with DB available):
uv run pytest tests/test_decks.py::test_get_decks -xCI (.github/workflows/main.yml) runs ruff format --check, ruff check --no-fix, ty check, then alembic upgrade head and pytest against a Postgres service. Match this locally before pushing.
app/__main__.py boots Granian pointing at app.application:build_app (factory). build_app() in app/application.py:
- Creates a
modern_di.Containerwith theDependenciesgroup fromapp/ioc.py. - Builds a
FastAPIBootstrapperfromsettings.api_bootstrapper_config, injecting SQLAlchemy + asyncpg OpenTelemetry instrumentors. modern_di_fastapi.setup_di(app, container)wires DI scopes onto the FastAPI app.- Includes one
ROUTERper resource (app.api.decks.ROUTER,app.api.cards.ROUTER) under/apiviainclude_routers. Add a new resource by creatingapp/api/<name>.pywith its ownAPIRouter-basedROUTERand registering it there. - Registers
DuplicateKeyError→ 422 handler fromapp/exceptions.py.
app/ioc.py defines providers:
database_engine— singleton-ishAsyncEnginewithcreate_sa_engine/close_sa_enginefinalizer.session—Scope.REQUEST, finalized byclose_session.decks_repository,cards_repository—Scope.REQUEST, depend onsession, configured withauto_commit=True(commit happens at session close, not per call).
Endpoints inject repositories with FromDI(Repository) from modern_di_fastapi. Add new providers to Dependencies rather than constructing services manually in routes.
app/models.py—BigIntAuditBasefromadvanced_alchemy(autoid,created_at,updated_at). The module aliasesorm_registry.metadataontoorm.DeclarativeBase.metadataso Alembic autogenerate sees both. New models go here.app/repositories.py— SubclassSQLAlchemyAsyncRepositoryService[Model]with a nestedBaseRepository(SQLAlchemyAsyncRepository[Model]). Routes use the service methods (list,get_one_or_none,create,update,create_many,upsert_many).app/resources/db.py—create_sessionpassesjoin_transaction_mode="create_savepoint". This is inert in production (the session binds to an engine) but enables the test rollback pattern below: when a test binds the session to a connection already in a transaction, the session owns its own savepoint so the outer transaction survives commits — do not "fix" it.migrations/env.pyswaps the asyncpg driver for the syncpostgresqldriver and usesapp.models.METADATAastarget_metadata.
app/settings.py — pydantic_settings.BaseSettings. Env vars are unprefixed (DB_DSN, SERVICE_DEBUG, SERVICE_ENVIRONMENT, LOG_LEVEL, APP_HOST, APP_PORT, OPENTELEMETRY_ENDPOINT, SENTRY_DSN, CORS_ALLOWED_ORIGINS, ...). api_bootstrapper_config produces a FastAPIConfig for lite-bootstrap.
tests/conftest.py provides the test isolation pattern — read it before adding fixtures:
appfixture builds a fresh app viaLifespanManager.db_sessionopens a connection, begins a transaction, and overridesDependencies.database_enginewith the connection itself. Each session built against that connection usesjoin_transaction_mode="create_savepoint", soauto_commitreleases the session's own savepoint while the outer transaction is rolled back at teardown — each test starts clean.set_async_session_in_base_sqlalchemy_factorywiresdb_sessionintoSQLAlchemyFactory.__async_session__sopolyfactoryfactories intests/factories.py(DeckModelFactory,CardModelFactory) persist via the rolled-back session. Test modules that use these factories opt in withpytestmark = [pytest.mark.usefixtures("set_async_session_in_base_sqlalchemy_factory")].
pytest.ini_options sets asyncio_mode = "auto" — async tests do not need @pytest.mark.asyncio. Coverage runs by default (--cov=. --cov-report term-missing).
- Type-ignore syntax is
# ty: ignore[error-code](this project usesty, not mypy). Seeapp/application.py:39for an example. - Ruff is configured with
select = ["ALL"]and a curated ignore list inpyproject.toml. Don't sprinkle# noqa; prefer fixing or extending the project ignore list if a rule is genuinely wrong for the codebase. - Routes convert ORM objects to schemas explicitly, never via
typing.cast. Single objects:schemas.X.model_validate(instance). Collections:schemas.Xs.from_models(objects)(theCollection[T]seam inapp/schemas.py). Both rely onfrom_attributes=True. - Deck responses are deliberately two-shaped:
list_decks/create_deck/update_deckreturn the lightschemas.Deck(nocards), whileget_deckreturnsschemas.DeckWithCards. The split mirrors loading — lists/writes usenoload(no cards query), detail usesselectinloadviafetch_with_cards— so the type states exactly what each endpoint loads. - Line length is 120.