diff --git a/.gitignore b/.gitignore index adb91f2..34c9d81 100644 --- a/.gitignore +++ b/.gitignore @@ -206,4 +206,5 @@ marimo/_static/ marimo/_lsp/ __marimo__/ .idea -.pi \ No newline at end of file +.pi +.dev \ No newline at end of file diff --git a/skills/alembic-migrations/SKILL.md b/skills/alembic-migrations/SKILL.md index af5c93c..21de739 100644 --- a/skills/alembic-migrations/SKILL.md +++ b/skills/alembic-migrations/SKILL.md @@ -40,19 +40,11 @@ Alembic only detects models that are **imported at runtime**. A canonical module must import every ORM model so metadata is complete. Do not assume `app/`. Use the project's existing `Base` definition -- -typically at `{pkg_name}/models/base.py` (see sqlalchemy-models skill). +typically at `{pkg_name}/db/base.py` (see sqlalchemy-models skill). Example: ```python -# {pkg_name}/models/base.py -from sqlalchemy.orm import DeclarativeBase - - -class Base(DeclarativeBase): - pass - - # {pkg_name}/models/__init__.py from {pkg_name}.models.user import User from {pkg_name}.models.order import Order @@ -61,7 +53,7 @@ from {pkg_name}.models.order import Order Alembic must reference: ```python -from {pkg_name}.models.base import Base +from {pkg_name}.db.base import Base target_metadata = Base.metadata ``` @@ -179,7 +171,7 @@ Before generating anything: exist. If they do, **modify the existing configuration** rather than reinitializing. Never run `alembic init` if `alembic/` already exists. 2. Identify where `Base` is defined. If the project used the - sqlalchemy-models skill, it will be at `{pkg_name}/models/base.py`. + sqlalchemy-models skill, it will be at `{pkg_name}/db/base.py`. 3. Identify which module imports all models (typically `{pkg_name}/models/__init__.py`). This is the module env.py must import. 4. Note the existing package root. For fastapi-init projects it is @@ -238,7 +230,7 @@ from alembic import context from sqlalchemy import engine_from_config, pool from {pkg_name}.core.config import settings -from {pkg_name}.models.base import Base +from {pkg_name}.db.base import Base import {pkg_name}.models # noqa: F401 — ensures all models are imported config = context.config @@ -280,9 +272,9 @@ else: run_migrations_online() ``` -**Naming conventions**: Projects should configure SQLAlchemy -`naming_convention` on `MetaData`. This is typically defined where -`Base` is created (see sqlalchemy-models skill) -- not here. +**Naming conventions**: The naming convention is defined on `Base` in +`db/base.py` (see sqlalchemy-models skill) and is picked up +automatically by Alembic through `target_metadata = Base.metadata`. **Async note**: Alembic migrations run synchronously even in async applications. diff --git a/skills/background-jobs-boundaries/SKILL.md b/skills/background-jobs-boundaries/SKILL.md index a0ad1b7..41edaa2 100644 --- a/skills/background-jobs-boundaries/SKILL.md +++ b/skills/background-jobs-boundaries/SKILL.md @@ -61,20 +61,22 @@ Example pattern: from fastapi import BackgroundTasks @app.post("/users/{user_id}/welcome") -def send_welcome(user_id: int, background_tasks: BackgroundTasks): +async def send_welcome(user_id: int, background_tasks: BackgroundTasks): background_tasks.add_task(send_welcome_email, user_id) return {"status": "scheduled"} -def send_welcome_email(user_id: int): - with SessionLocal() as session: - user = session.get(User, user_id) +async def send_welcome_email(user_id: int): + async with AsyncSessionLocal() as session: + user = await session.get(User, user_id) if not user: return - email_service.send_welcome(user.email) + await email_service.send_welcome(user.email) ``` +Background task functions must be `async def` when using async sessions. FastAPI runs async background tasks in the event loop, so this works without threads. For CPU-heavy or truly blocking work, use a real job queue instead. + Key properties: - Task arguments are **IDs or primitives** diff --git a/skills/code-quality/SKILL.md b/skills/code-quality/SKILL.md index 65ea539..3c55de8 100644 --- a/skills/code-quality/SKILL.md +++ b/skills/code-quality/SKILL.md @@ -71,6 +71,7 @@ developer with exact commands to verify it works. - Keep the quality contract **easy to run locally** and **easy to mirror in CI**. - Prefer **incremental adoption** in existing repos when a big-bang migration is unnecessary. - Keep configuration centralized where practical, preferably in `pyproject.toml`. +- Prefer narrow rule-level ignores over broad file-level ignores. Document non-obvious ignores briefly. A large ignore list signals stack drift. **MUST NOT:** @@ -117,7 +118,9 @@ This stack is preferred because it is: and existing suppressions. 5. **Call out removals** — explicitly name overlapping tools to delete and why. 6. **End with verification commands** — exact `ruff`, `pre-commit`, and - markdownlint commands to confirm the stack is working. + markdownlint commands to confirm the stack is working. CI quality steps + should mirror these: Ruff check, Ruff format check, Markdown lint, and + tests (if present). --- @@ -258,108 +261,6 @@ Examples: --- -# Migration Bias - -When the user wants to simplify or modernize tooling, the preferred migration target is: - -- **Ruff** -- **pre-commit** -- **Markdown lint** -- minimal sanity hooks - -Migration should aim to: - -- remove redundancy -- reduce tool count -- preserve developer ergonomics -- preserve CI clarity -- keep adoption understandable - -When migrating to Ruff, prefer to remove overlapping tools rather than run both indefinitely. - ---- - -# Config Discipline - -Prefer `pyproject.toml` as the canonical configuration home for: - -- Ruff -- pytest, when applicable -- other Python tooling that supports it - -Use separate config files only when a tool truly requires them. - -Do not create config sprawl. - ---- - -# Pre-commit Discipline - -Pre-commit should be: - -- fast enough that developers will actually use it -- aligned with the repo's real standards -- limited to checks with strong signal - -Do not overload pre-commit with slow, redundant, or low-value hooks. - -Pre-commit is an enforcement layer, not a philosophy engine. - ---- - -# CI Discipline - -CI should mostly replay the same code-health contract expected locally. - -Recommended CI quality steps: - -- Ruff check -- Ruff format check -- Markdown lint -- tests, if present - -Avoid introducing style tools in CI that developers are not expected to run locally. - ---- - -# Ignore Philosophy - -Use a minimal-ignore approach. - -Rules: - -- prefer fixing code over expanding ignore lists -- prefer narrow rule-level ignores over broad file-level ignores -- document non-obvious ignores briefly -- do not accumulate unexplained exceptions - -A large ignore list is usually a signal that the stack is drifting. - ---- - -# Adoption Philosophy - -This skill should make the stack **easy to adopt**. - -Preferred adoption characteristics: - -- quick install -- one obvious command path -- one obvious config location -- one obvious local enforcement layer -- one obvious CI replay - -For existing repos, favor changes that are: - -- understandable -- reviewable -- low-drama -- easy to roll out incrementally - -Do not require perfection before adoption begins. - ---- - # Verification Expectations When proposing or applying this stack, end with exact commands such as: diff --git a/skills/fastapi-errors/SKILL.md b/skills/fastapi-errors/SKILL.md index 1732d3d..1b88f6f 100644 --- a/skills/fastapi-errors/SKILL.md +++ b/skills/fastapi-errors/SKILL.md @@ -47,6 +47,8 @@ Do **not** apply when: # Base Exception +The `fastapi-init` skill scaffolds a minimal version of this class. This skill defines the complete pattern. If both are present, this skill's version is authoritative. + The service defines one base exception for intentional application failures. Preferred naming pattern: @@ -69,29 +71,31 @@ If the package name cannot be determined, use: AppError ``` -Define all exceptions in a single module — `errors.py` or `exceptions.py` at the package root. Do not scatter them across feature files. +Define all exceptions in a single module. In fastapi-init projects this is `core/exceptions.py`. In other layouts, `exceptions.py` at the package root is typical. Do not scatter them across feature files. Example base implementation: ```python class AppError(Exception): status_code = 500 + detail: str = "An unexpected error occurred." - def __init__(self, message: str | None = None, **context): - self.message = message or "Unhandled application error" + def __init__(self, detail: str | None = None, status_code: int | None = None, **context): + self.detail = detail if detail is not None else self.__class__.detail + self.status_code = status_code if status_code is not None else self.__class__.status_code self.context = context - super().__init__(self.message) + super().__init__(self.detail) def __str__(self) -> str: - return self.message + return self.detail ``` Key characteristics: - default HTTP status = **500** -- default message provided in the constructor -- optional context data for logging -- subclasses override `status_code` or pass formatted messages +- class-level `detail` default, overridable per-instance or per-subclass +- optional `**context` kwargs for structured logging +- subclasses override `status_code` and `detail` as class attributes --- @@ -104,6 +108,7 @@ Example: ```python class UserNotFoundError(AppError): status_code = 404 + detail = "User not found." def __init__(self, user_id: int): super().__init__(f"User {user_id} not found", user_id=user_id) @@ -127,17 +132,24 @@ Example: ```python @app.exception_handler(AppError) async def handle_app_error(request: Request, exc: AppError): + if exc.context: + logger.warning( + exc.detail, + extra={"error_context": exc.context, "status_code": exc.status_code}, + ) return JSONResponse( status_code=exc.status_code, - content={"error": str(exc)}, + content={"detail": exc.detail}, ) ``` +The `**context` kwargs are for structured logging at the boundary, not for the client response. Use them to attach identifiers (e.g., `user_id=123`) that aid debugging without leaking internals. + Response format is intentionally simple: ```json { - "error": "User 123 not found" + "detail": "User 123 not found" } ``` @@ -152,8 +164,6 @@ Use a fallback handler for uncaught exceptions: ```python @app.exception_handler(Exception) async def handle_unexpected_error(request: Request, exc: Exception): - wrapped = AppError() - logger.exception( "Unhandled exception", extra={ @@ -164,8 +174,8 @@ async def handle_unexpected_error(request: Request, exc: Exception): ) return JSONResponse( - status_code=wrapped.status_code, - content={"error": str(wrapped)}, + status_code=500, + content={"detail": "An unexpected error occurred."}, ) ``` @@ -185,10 +195,15 @@ Log at minimum: request path, HTTP method, exception type, and correlation ID if In existing repositories, **inspect current error patterns before introducing new ones**. -Follow these rules: +To find existing patterns, search for: + +- `class.*Error(Exception)` or `class.*Exception(Exception)` in the package +- `exception_handler` registrations in the app factory or main module +- `HTTPException` usage in service or domain code (a signal that the boundary is leaking) + +Then follow these rules: -- Look for an existing internal base exception. -- If one exists and is coherent, **extend it instead of creating a new base class**. +- If an existing internal base exception exists and is coherent, **extend it instead of creating a new base class**. - Integrate with existing exception handlers when possible. - Only introduce the recommended base-exception pattern if the repo lacks a clear contract or the user asks to refactor. @@ -209,7 +224,7 @@ from fastapi.exceptions import RequestValidationError async def handle_validation_error(request: Request, exc: RequestValidationError): return JSONResponse( status_code=422, - content={"error": "Invalid request data", "details": exc.errors()}, + content={"detail": "Invalid request data", "details": exc.errors()}, ) ``` @@ -249,17 +264,23 @@ These flow through the global `AppError` handler like any other domain exception For APIs consumed by clients that need to branch on error type — public APIs, client SDKs, multi-error workflows — a machine-readable `code` field is useful. Skip this for internal services or simple CRUD APIs where string parsing is acceptable. -Pattern: add an optional `code` class attribute to the base exception; domain subclasses override it. +Pattern: add an optional `code` class attribute to the existing base exception; domain subclasses override it. This extends the base class from the Base Exception section - do not redefine it. ```python class AppError(Exception): status_code = 500 - code: str | None = None # add this + detail: str = "An unexpected error occurred." + code: str | None = None # add this to the existing base + ... +``` +Subclasses set a value: + +```python class UserNotFoundError(AppError): status_code = 404 - code = "user_not_found" # subclasses set a value + code = "user_not_found" ... ``` @@ -268,7 +289,7 @@ Update the global handler to include `code` when present: ```python @app.exception_handler(AppError) async def handle_app_error(request: Request, exc: AppError): - content = {"error": str(exc)} + content = {"detail": exc.detail} if exc.code: content["code"] = exc.code return JSONResponse(status_code=exc.status_code, content=content) @@ -278,7 +299,7 @@ Response shape when `code` is set: ```json { - "error": "User 123 not found", + "detail": "User 123 not found", "code": "user_not_found" } ``` diff --git a/skills/fastapi-init/SKILL.md b/skills/fastapi-init/SKILL.md index cf3e3be..64b09f2 100644 --- a/skills/fastapi-init/SKILL.md +++ b/skills/fastapi-init/SKILL.md @@ -1,6 +1,6 @@ --- name: fastapi-init -description: Scaffold a complete, production-ready FastAPI project from scratch. Use this skill whenever the user wants to create, initialize, start, or bootstrap a FastAPI service, REST API, or Python web service — even if they just say "new service", "new API", or "new microservice". Handles uv setup, standard FastAPI directory layout, uvicorn runner, click CLI entry point, and a full pytest suite with DI overrides, TestClient, and SQLite fixtures. Always invoke for new Python API projects. +description: Scaffold a complete, production-ready FastAPI project from scratch. Use this skill whenever the user wants to create, initialize, start, or bootstrap a FastAPI service, REST API, or Python web service — even if they just say "new service", "new API", or "new microservice". Handles uv setup, standard FastAPI directory layout, uvicorn runner, click CLI entry point, and a full pytest suite with DI overrides, AsyncClient, and async SQLite fixtures. Always invoke for new Python API projects. disable-model-invocation: false --- @@ -12,6 +12,7 @@ Scaffold a new FastAPI project end-to-end. This skill coordinates with others in - **uv skill**: use for all `uv add`, `uv run`, and environment commands — never fall back to pip - **click-cli skill**: consult if the user wants an extended CLI beyond the basic server entry point +- **fastapi-errors skill**: the authority on the full error architecture — domain subclasses, error codes, auth error patterns, and existing-repo strategy --- @@ -33,7 +34,7 @@ cd {pkg_name} Remove the stub file uv generates (`hello.py`), then add dependencies: ```bash -uv add fastapi "uvicorn[standard]" sqlalchemy "pydantic-settings" click +uv add fastapi "uvicorn[standard]" "sqlalchemy[asyncio]" aiosqlite "pydantic-settings" click uv add --dev pytest pytest-asyncio httpx ``` @@ -76,11 +77,13 @@ Build this layout under the project root: │ │ └── health.py │ ├── db/ │ │ ├── __init__.py -│ │ └── session.py # engine + SessionLocal + get_db +│ │ ├── base.py # Base, TimestampMixin, naming convention +│ │ └── session.py # engine, AsyncSessionLocal, get_db │ ├── models/ │ │ └── __init__.py # SQLAlchemy declarative models │ └── schemas/ -│ └── __init__.py # Pydantic I/O schemas +│ ├── __init__.py +│ └── base.py # APIModel + ReadModel base schemas └── tests/ ├── __init__.py ├── conftest.py @@ -114,53 +117,85 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="{PKG_NAME}_", env_file=".env") + model_config = SettingsConfigDict( + env_prefix="{PKG_NAME}_", env_file=".env", extra="ignore", + ) app_name: str = "{pkg_name}" debug: bool = False - database_url: str = "sqlite:///./app.db" + database_url: str = "sqlite+aiosqlite:///./app.db" settings = Settings() ``` -### {pkg_name}/db/session.py +### {pkg_name}/db/base.py -`get_db` is the single canonical source of truth for database sessions in routes and tests — this is what makes DI overrides in tests work cleanly. Background tasks run outside the FastAPI DI lifecycle and must open sessions via `SessionLocal` directly. +Schema infrastructure - Base class, naming convention, and shared mixins. Models import `Base` from here. ```python -from typing import Generator +from datetime import datetime, timezone -from sqlalchemy import create_engine -from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker +from sqlalchemy import DateTime, MetaData +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +convention = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} -from {pkg_name}.core.config import settings + +def utcnow() -> datetime: + return datetime.now(timezone.utc) class Base(DeclarativeBase): - pass + metadata = MetaData(naming_convention=convention) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, onupdate=utcnow, + ) +``` + +### {pkg_name}/db/session.py +Connection infrastructure. `get_db` is the single canonical source of truth for database sessions in routes and tests - this is what makes DI overrides in tests work cleanly. Background tasks run outside the FastAPI DI lifecycle and must open sessions via `AsyncSessionLocal` directly. -engine = create_engine(settings.database_url) -SessionLocal = sessionmaker(bind=engine) +```python +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from {pkg_name}.core.config import settings -def get_db() -> Generator[Session, None, None]: - with SessionLocal() as session: +engine = create_async_engine(settings.database_url) +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: yield session ``` +`expire_on_commit=False` prevents lazy-load errors when accessing attributes after a commit on async sessions - SQLAlchemy cannot implicitly issue blocking I/O in an async context. + ### {pkg_name}/api/deps.py ```python from typing import Annotated from fastapi import Depends -from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession from {pkg_name}.db.session import get_db -DbSession = Annotated[Session, Depends(get_db)] +DbSession = Annotated[AsyncSession, Depends(get_db)] ``` Use `DbSession` as a type annotation on route parameters — it's self-documenting and avoids repeating `Depends(get_db)` everywhere. @@ -174,9 +209,10 @@ class {PkgName}Error(Exception): status_code: int = 500 detail: str = "An unexpected error occurred." - def __init__(self, detail: str | None = None, status_code: int | None = None): + def __init__(self, detail: str | None = None, status_code: int | None = None, **context): self.detail = detail if detail is not None else self.__class__.detail self.status_code = status_code if status_code is not None else self.__class__.status_code + self.context = context ``` Example subclass: @@ -186,6 +222,47 @@ class NotFoundError({PkgName}Error): detail = "Resource not found." ``` +See the **fastapi-errors** skill for the full error architecture: domain subclasses, error codes, auth error patterns, and existing-repo strategy. + +### {pkg_name}/schemas/base.py + +```python +from pydantic import BaseModel, ConfigDict + + +class APIModel(BaseModel): + model_config = ConfigDict( + extra="forbid", + str_strip_whitespace=True, + validate_assignment=True, + use_enum_values=True, + populate_by_name=True, + ) + + +class ReadModel(APIModel): + model_config = APIModel.model_config.copy() + model_config["from_attributes"] = True + + +class CreateModel(APIModel): + """Request body for resource creation.""" + + +class UpdateModel(APIModel): + """Partial update payload. Use model_dump(exclude_unset=True) in the service layer.""" + + +class QueryModel(APIModel): + """Search, list, and filtering inputs.""" + model_config = APIModel.model_config.copy() + model_config["extra"] = "ignore" + + +class CommandModel(APIModel): + """Action-oriented request body for non-CRUD endpoints.""" +``` + ### {pkg_name}/main.py Use the lifespan pattern — `on_event` is deprecated. @@ -194,6 +271,7 @@ Use the lifespan pattern — `on_event` is deprecated. from contextlib import asynccontextmanager from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from {pkg_name}.api.v1.routes import health @@ -212,9 +290,20 @@ app = FastAPI(title=settings.app_name, lifespan=lifespan) app.include_router(health.router, prefix="/api/v1") +# see fastapi-errors skill for extended patterns (context logging, error codes, auth errors) @app.exception_handler({PkgName}Error) async def {pkg_name}_error_handler(request: Request, exc: {PkgName}Error) -> JSONResponse: return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + +@app.exception_handler(RequestValidationError) +async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse: + return JSONResponse(status_code=422, content={"detail": "Invalid request data", "details": exc.errors()}) + + +@app.exception_handler(Exception) +async def unexpected_error_handler(request: Request, exc: Exception) -> JSONResponse: + return JSONResponse(status_code=500, content={"detail": "An unexpected error occurred."}) ``` ### {pkg_name}/api/v1/routes/health.py @@ -259,50 +348,50 @@ def serve(host: str, port: int, reload: bool): ### tests/conftest.py -The test suite is built around three layered fixtures. The design principle: never hit a real database, never reach outside the process, override DI at the boundary. +The test suite is built around three layered async fixtures. The design principle: never hit a real database, never reach outside the process, override DI at the boundary. ```python -import pytest -from fastapi.testclient import TestClient -from sqlalchemy import create_engine -from sqlalchemy.orm import Session, sessionmaker +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from {pkg_name}.db.session import Base, get_db +from {pkg_name}.db.base import Base +from {pkg_name}.db.session import get_db from {pkg_name}.main import app -TEST_DATABASE_URL = "sqlite:///:memory:" +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" -@pytest.fixture(scope="session") -def engine(): - """One engine for the whole test session — schema created once.""" - eng = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False}) - Base.metadata.create_all(eng) +@pytest_asyncio.fixture(scope="session") +async def engine(): + eng = create_async_engine(TEST_DATABASE_URL) + async with eng.begin() as conn: + await conn.run_sync(Base.metadata.create_all) yield eng - Base.metadata.drop_all(eng) - - -@pytest.fixture -def db_session(engine) -> Session: - """Per-test transaction that always rolls back — tests never bleed into each other.""" - connection = engine.connect() - transaction = connection.begin() - TestSession = sessionmaker(bind=connection) - session = TestSession() - yield session - session.close() - transaction.rollback() - connection.close() - - -@pytest.fixture -def client(db_session: Session) -> TestClient: - """TestClient with the real DB dependency swapped for the test SQLite session.""" - def override_get_db(): + async with eng.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await eng.dispose() + + +@pytest_asyncio.fixture +async def db_session(engine) -> AsyncSession: + async with engine.connect() as conn: + async with conn.begin() as trans: + session = async_sessionmaker(bind=conn, expire_on_commit=False)() + yield session + await session.close() + await trans.rollback() + + +@pytest_asyncio.fixture +async def client(db_session: AsyncSession) -> AsyncClient: + async def override_get_db(): yield db_session app.dependency_overrides[get_db] = override_get_db - with TestClient(app) as c: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: yield c app.dependency_overrides.clear() ``` @@ -310,11 +399,11 @@ def client(db_session: Session) -> TestClient: ### tests/api/v1/test_health.py ```python -from fastapi.testclient import TestClient +from httpx import AsyncClient -def test_health_returns_ok(client: TestClient): - response = client.get("/api/v1/health") +async def test_health_returns_ok(client: AsyncClient): + response = await client.get("/api/v1/health") assert response.status_code == 200 assert response.json() == {"status": "ok"} ``` @@ -325,11 +414,13 @@ def test_health_returns_ok(client: TestClient): Hold these across every test file in the project: -1. **No test classes** — top-level `test_*` functions only; pytest fixtures handle all setup and teardown -2. **No real databases** — all DB-touching tests use the `db_session` fixture; SQLite in-memory only -3. **DI overrides, not patches** — swap behavior via `app.dependency_overrides`; don't mock internals -4. **Shared fixtures in conftest.py** — test files stay clean; fixtures go in conftest -5. **Transaction-per-test isolation** — the rollback in `db_session` ensures tests never affect each other even if they write data +1. **Async tests** — all test functions are `async def`; `asyncio_mode = "auto"` means no per-test markers needed +2. **No test classes** — top-level `test_*` functions only; `pytest_asyncio` fixtures handle all setup and teardown +3. **No real databases** — all DB-touching tests use the `db_session` fixture; async SQLite in-memory only +4. **DI overrides, not patches** — swap behavior via `app.dependency_overrides`; don't mock internals +5. **Shared fixtures in conftest.py** — test files stay clean; fixtures go in conftest +6. **Transaction-per-test isolation** — the rollback in `db_session` ensures tests never affect each other even if they write data +7. **AsyncClient, not TestClient** — use `httpx.AsyncClient` with `ASGITransport` for endpoint tests --- @@ -344,6 +435,16 @@ Both should succeed with no errors before handing the project to the user. --- +## What's next + +The scaffold is ready to extend. Common next steps: + +- **Add models** - use the `sqlalchemy-models` skill to define ORM entities under `models/` +- **Initialize migrations** - use the `alembic-migrations` skill to set up Alembic after adding models +- **Add schemas** - use the `pydantic-schemas` skill for request/response schemas beyond the base classes + +--- + ## Completion checklist - [ ] `[project.scripts]` entry in pyproject.toml points to `{pkg_name}.cli:cli` diff --git a/skills/http-client-integration/SKILL.md b/skills/http-client-integration/SKILL.md index 5a09870..717a1fa 100644 --- a/skills/http-client-integration/SKILL.md +++ b/skills/http-client-integration/SKILL.md @@ -40,6 +40,7 @@ Reuse the repo's established patterns when they are sound. Do not introduce a se - Map upstream failures into domain-specific integration errors - Log and instrument at the integration boundary without leaking secrets - Keep auth, base URL, headers, and user agent construction centralized +- Include the correlation ID header on all outbound requests (see request-correlation skill) - Make the integration layer easy to mock in tests - Fail clearly on malformed upstream data - Source timeout values, retry counts, base URLs, and credentials from application configuration, not hardcoded constants diff --git a/skills/pydantic-schemas/SKILL.md b/skills/pydantic-schemas/SKILL.md index 05adff6..ab70e4d 100644 --- a/skills/pydantic-schemas/SKILL.md +++ b/skills/pydantic-schemas/SKILL.md @@ -211,6 +211,32 @@ These defaults enforce: - safe mutation in service-layer logic - predictable enum serialization +The remaining base classes are thin role markers. They exist to enforce +naming discipline, not to add behavior (except where noted): + +```python +class CreateModel(APIModel): + """Request body for resource creation.""" + +class UpdateModel(APIModel): + """Partial update payload. Use model_dump(exclude_unset=True) in the service layer.""" + +class QueryModel(APIModel): + """Search, list, and filtering inputs. All fields should be optional.""" + model_config = APIModel.model_config.copy() + model_config["extra"] = "ignore" + +class CommandModel(APIModel): + """Action-oriented request body for non-CRUD endpoints.""" + +class BatchModel(APIModel): + """Batch operation request body.""" +``` + +`QueryModel` uses `extra="ignore"` because query parameters may include +pagination or framework-injected fields that should not cause validation +errors. + ------------------------------------------------------------------------ # Read Model (ORM Serialization) @@ -218,17 +244,9 @@ These defaults enforce: Response models MUST support serialization from ORM objects. ```python -from pydantic import ConfigDict - class ReadModel(APIModel): - model_config = ConfigDict( - extra="forbid", - str_strip_whitespace=True, - validate_assignment=True, - use_enum_values=True, - populate_by_name=True, - from_attributes=True, - ) + model_config = APIModel.model_config.copy() + model_config["from_attributes"] = True ``` ORM objects SHOULD be converted using: @@ -322,18 +340,15 @@ Do not invent different filter naming conventions per endpoint. ------------------------------------------------------------------------ -# Command Request / Response Schemas +# Command, Batch, and Aggregate Schemas -Non-CRUD actions SHOULD use explicit command request/response schemas. +Non-CRUD endpoints need explicit schema types too - do not inline these +as untyped dicts. -Examples of command endpoints: +### Command Request / Response -- activate user -- cancel order -- refund payment -- generate report - -Example: +Non-CRUD actions (activate user, cancel order, refund payment, generate +report) use explicit command request/response schemas. ```python class RefundPaymentRequest(CommandModel): @@ -346,20 +361,14 @@ class RefundPaymentResponse(APIModel): refunded_at: datetime ``` -Do not inline command payloads as untyped dictionaries. - -Naming pattern — pick one and use it consistently: +Naming pattern - pick one and use it consistently: - `{Action}{Resource}Request`, or - `{Resource}{Action}Request` ------------------------------------------------------------------------- - -# Batch Request Schemas +### Batch Requests -Batch operations SHOULD use explicit batch request models. - -Example: +Batch operations use explicit batch request models. ```python class BulkDisableUsersRequest(BatchModel): @@ -369,20 +378,10 @@ class BulkDisableUsersRequest(BatchModel): Do not pass bare lists as request bodies when the payload has semantic meaning. ------------------------------------------------------------------------- - -# Aggregate / Summary Response Schemas - -Computed endpoints MUST use explicit response schemas. +### Aggregate / Summary Responses -Examples: - -- analytics -- reports -- summaries -- stats - -Example: +Computed endpoints (analytics, reports, summaries, stats) MUST use +explicit response schemas. ```python class RevenueSummary(APIModel): diff --git a/skills/pytest-service/SKILL.md b/skills/pytest-service/SKILL.md index b3006f3..3e30b6b 100644 --- a/skills/pytest-service/SKILL.md +++ b/skills/pytest-service/SKILL.md @@ -1,9 +1,9 @@ --- name: pytest-service description: Write disciplined backend tests for FastAPI services with pytest. Use - this skill when adding tests to a Python/FastAPI service, setting up SQLAlchemy - test fixtures, wiring TestClient with DI overrides, mocking external clients, or - improving test maintainability. Covers fixture design, factory patterns, SQLite + this skill when adding tests to a Python/FastAPI service, setting up async SQLAlchemy + test fixtures, wiring AsyncClient with DI overrides, mocking external clients, or + improving test maintainability. Covers fixture design, factory patterns, async SQLite in-memory databases, app.dependency_overrides, and avoiding fragile or redundant tests. disable-model-invocation: false @@ -32,14 +32,16 @@ Prefer **simple local tests with minimal infrastructure**. Rules: -- Prefer **`TestClient`** for FastAPI endpoint tests whenever possible -- Use **async clients only when the test genuinely requires async behavior** -- Default test databases to **SQLite via SQLAlchemy fixtures** +- Use **`httpx.AsyncClient`** with `ASGITransport` for FastAPI endpoint tests +- All test functions are **`async def`**; `asyncio_mode = "auto"` eliminates per-test markers +- Use **`pytest_asyncio` fixtures** for async setup/teardown +- Default test databases to **async SQLite via SQLAlchemy async fixtures** - Do **not introduce Docker databases** unless the repository already uses them for testing - External clients must be **mocked**, not called - **No test classes** — top-level `test_*` functions only; fixtures handle all setup - Each test covers a **distinct behavior** — no redundant assertions across tests - Avoid mutable default arguments in fixtures and helpers +- Dev deps assumed: `pytest`, `pytest-asyncio`, `httpx` Tests must run reliably with a simple `pytest`. No external services required. @@ -76,30 +78,31 @@ Choose fixture scope based on what the fixture creates and how expensive it is. - `scope="session"` — for things that are expensive to create and safe to share (e.g., the SQLAlchemy engine, schema creation) - `scope="function"` (default) — for anything that holds mutable state or must be isolated per test (e.g., sessions, clients, mocks) -A common pattern: session-scoped engine, function-scoped session with rollback. +A common pattern: session-scoped async engine, function-scoped async session with rollback. ``` python -@pytest.fixture(scope="session") -def engine(): - eng = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) - Base.metadata.create_all(eng) +@pytest_asyncio.fixture(scope="session") +async def engine(): + eng = create_async_engine("sqlite+aiosqlite:///:memory:") + async with eng.begin() as conn: + await conn.run_sync(Base.metadata.create_all) yield eng - Base.metadata.drop_all(eng) - - -@pytest.fixture -def db_session(engine) -> Session: - connection = engine.connect() - transaction = connection.begin() - TestSession = sessionmaker(bind=connection) - session = TestSession() - yield session - session.close() - transaction.rollback() - connection.close() + async with eng.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await eng.dispose() + + +@pytest_asyncio.fixture +async def db_session(engine) -> AsyncSession: + async with engine.connect() as conn: + async with conn.begin() as trans: + session = async_sessionmaker(bind=conn, expire_on_commit=False)() + yield session + await session.close() + await trans.rollback() ``` -The rollback in teardown means tests never bleed into each other even when they write data. +The rollback in teardown means tests never bleed into each other even when they write data. `expire_on_commit=False` prevents lazy-load errors after commit in async sessions. ## Factory fixtures @@ -154,13 +157,15 @@ Tests should control behavior explicitly rather than relying on fixture branchin Use **`app.dependency_overrides`** to swap FastAPI dependencies in tests. Do not patch internals directly. ``` python -@pytest.fixture -def client(db_session): - def override_get_db(): +@pytest_asyncio.fixture +async def client(db_session: AsyncSession) -> AsyncClient: + async def override_get_db(): yield db_session app.dependency_overrides[get_db] = override_get_db - with TestClient(app) as c: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: yield c app.dependency_overrides.clear() ``` @@ -169,11 +174,11 @@ Always clear `dependency_overrides` in teardown so overrides don't leak between ## FastAPI testing -Prefer `TestClient` for endpoint tests. +Use `httpx.AsyncClient` with `ASGITransport` for endpoint tests. ``` python -def test_health_endpoint(client): - response = client.get("/health") +async def test_health_endpoint(client: AsyncClient): + response = await client.get("/health") assert response.status_code == 200 assert response.json()["status"] == "ok" diff --git a/skills/request-correlation/SKILL.md b/skills/request-correlation/SKILL.md index dbbdbef..dafdd06 100644 --- a/skills/request-correlation/SKILL.md +++ b/skills/request-correlation/SKILL.md @@ -37,8 +37,8 @@ Correlation infrastructure must live in predictable modules. Use: -app/observability/correlation.py\ -app/observability/logging.py +{pkg_name}/observability/correlation.py\ +{pkg_name}/observability/logging.py Do not duplicate correlation logic elsewhere. @@ -55,7 +55,7 @@ present, otherwise generate one, then set it into context: ``` python import uuid from fastapi import Request -from app.observability.correlation import correlation_id +from {pkg_name}.observability.correlation import correlation_id @app.middleware("http") async def correlation_middleware(request: Request, call_next): @@ -127,7 +127,7 @@ Example: headers={"x-request-id": correlation_id.get()} ``` -Centralize HTTP client creation so headers are applied automatically. +Centralize HTTP client creation so headers are applied automatically. See the http-client-integration skill for the full outbound client pattern. **6. Propagate correlation to background jobs** diff --git a/skills/settings-config/SKILL.md b/skills/settings-config/SKILL.md index 8898a1c..5011cfa 100644 --- a/skills/settings-config/SKILL.md +++ b/skills/settings-config/SKILL.md @@ -113,10 +113,10 @@ Environment variables should follow a **consistent prefix convention**. Example: - APP_DATABASE_URL=postgresql://... + APP_DATABASE_URL=postgresql+asyncpg://... APP_DEBUG=true -The prefix prevents collisions with other services or system variables. +The prefix prevents collisions with other services or system variables. Database URLs must use an async-compatible driver scheme (e.g. `sqlite+aiosqlite://`, `postgresql+asyncpg://`). ------------------------------------------------------------------------ diff --git a/skills/sqlalchemy-models/SKILL.md b/skills/sqlalchemy-models/SKILL.md index 1426a12..7187d0b 100644 --- a/skills/sqlalchemy-models/SKILL.md +++ b/skills/sqlalchemy-models/SKILL.md @@ -94,7 +94,7 @@ This skill does **not**: - invent unrelated tables or domain entities - generate large CRUD/service layers unless the user asks - merge ORM models with transport schemas -- introduce async/session architecture changes unless required by the repo +- redesign the async session architecture unless required by the repo - rewrite the database stack beyond what the current project calls for ------------------------------------------------------------------------ @@ -111,33 +111,33 @@ When applying this skill: ------------------------------------------------------------------------ -## Canonical output requirements - -A correct solution produced by this skill should usually include: - -- a single shared `DeclarativeBase` -- SQLAlchemy 2.x typed fields with `Mapped[...]` -- `mapped_column(...)` for columns -- explicit `relationship(...)` declarations -- symmetric `back_populates` for bidirectional relationships -- a predictable `models/` package structure -- import patterns that avoid circular dependencies -- optional shared mixins only when they reduce duplication cleanly - ------------------------------------------------------------------------- - ## Preferred patterns ### 1) Base class -Prefer a single canonical base: +Prefer a single canonical base in `db/base.py`, separate from the engine and session factory. Include a naming convention so Alembic generates predictable constraint names: ```python +from sqlalchemy import MetaData from sqlalchemy.orm import DeclarativeBase +convention = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + class Base(DeclarativeBase): - pass + metadata = MetaData(naming_convention=convention) +``` + +Model files import Base from `db/base.py`: + +```python +from {pkg_name}.db.base import Base ``` Do not create multiple unrelated declarative bases unless the repo already @@ -270,17 +270,19 @@ other layouts it may be `app/models/` or similar. ```text {pkg_name}/ + db/ + base.py # Base, TimestampMixin, naming convention + session.py # engine, AsyncSessionLocal, get_db models/ __init__.py - base.py user.py post.py ``` Where appropriate: -- `base.py` contains `Base` and small shared mixins -- each entity gets its own module +- `Base` and shared mixins live in `db/base.py` (separate from engine and session) +- each entity gets its own module under `models/` - `models/__init__.py` should import all model classes so that `Base.metadata` is fully populated when Alembic (or any other tool) imports it — this is what makes autogenerate reliable @@ -297,14 +299,15 @@ Prefer explicit imports. Good: ```python -from app.models.user import User -from app.models.post import Post +from {pkg_name}.db.base import Base +from {pkg_name}.models.user import User +from {pkg_name}.models.post import Post ``` Avoid wildcard imports: ```python -from app.models import * +from {pkg_name}.models import * ``` Also avoid tangled cross-import chains between model modules. @@ -412,9 +415,10 @@ dependencies. - `settings-config` — database URL and other config values come from here - `pydantic-schemas` — API request/response schemas that mirror (but stay separate from) the ORM models -- `alembic-migrations` *(future)* — migration authoring from model metadata -- `crud-route-builder` *(future)* — service/route layer that consumes models -- `pytest-backend` *(future)* — test fixtures that use SQLite in-memory DB +- `alembic-migrations` — migration authoring from model metadata +- `pytest-service` — test fixtures that use SQLite in-memory DB + +A route/service layer skill is planned - check the plugin's current skill list for availability. Typical order when building from scratch: @@ -422,7 +426,6 @@ Typical order when building from scratch: 2. `sqlalchemy-models` 3. `pydantic-schemas` 4. `alembic-migrations` -5. `crud-route-builder` ------------------------------------------------------------------------