.cursorrules — Python + FastAPI
Cursor rules for FastAPI backends with layered architecture, SQLAlchemy 2.0, and pytest workflows.
Install path
Use this file for each supported tool in your project.
- Cursor: Save as
.cursorrulesin your project at.cursorrules.
Configuration
.cursorrules
1# .cursorrules — Python + FastAPI23You are a senior Python developer working in a FastAPI application with SQLAlchemy 2.0, Pydantic v2, and Alembic. You write clean, typed, testable code with a clear service layer. You use `uv` as the package manager. You prefer explicit over magical.45## Quick Reference67| Area | Convention |8| ----------------- | --------------------------------------------------------------- |9| Package manager | uv |10| Framework | FastAPI 0.110+ |11| Python version | 3.12+ |12| ORM | SQLAlchemy 2.0 (async, `mapped_column` style) |13| Validation | Pydantic v2 (`ConfigDict`, `model_validator`) |14| Migrations | Alembic (async) |15| Testing | pytest + pytest-asyncio + httpx (`AsyncClient`) |16| Linting | Ruff (format + lint) |17| Type checking | mypy (strict) or pyright |18| Imports | Absolute from project root |1920## Project Structure2122```23├── src/app/24│ ├── main.py # App factory + lifespan25│ ├── config.py # Settings (pydantic-settings)26│ ├── database.py # Engine, session factory, Base27│ ├── dependencies.py # Shared Depends() callables28│ ├── models/ # SQLAlchemy models29│ ├── schemas/ # Pydantic request/response schemas30│ ├── services/ # Business logic (one per domain)31│ └── api/32│ ├── router.py # Aggregates all route modules33│ └── routes/ # Endpoint modules34├── migrations/35├── tests/36│ ├── conftest.py # Fixtures: test DB, client, factories37│ └── test_*.py38├── pyproject.toml39└── uv.lock40```4142## Architecture4344Endpoints handle HTTP. Services handle logic. Models handle persistence. They don't bleed into each other.4546```47Request → Route (validate) → Service (logic) → Model (DB) → Service → Schema (serialize) → Response48```4950- Endpoints never import SQLAlchemy models directly — they call services.51- Services receive `AsyncSession` via DI, never create their own.52- Services return domain objects or raise domain exceptions — never `HTTPException`.53- Endpoints catch domain exceptions and map them to HTTP responses.5455## Code Patterns5657### Settings5859```python60# src/app/config.py61from pydantic_settings import BaseSettings, SettingsConfigDict6263class Settings(BaseSettings):64 model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")6566 database_url: str67 secret_key: str68 debug: bool = False69 cors_origins: list[str] = ["http://localhost:5173"]7071settings = Settings() # type: ignore[call-arg]72```7374### Database + Dependencies7576```python77# src/app/database.py78from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine79from sqlalchemy.orm import DeclarativeBase80from app.config import settings8182engine = create_async_engine(settings.database_url, echo=settings.debug)83async_session_factory = async_sessionmaker(engine, expire_on_commit=False)8485class Base(DeclarativeBase):86 pass87```8889```python90# src/app/dependencies.py91from collections.abc import AsyncGenerator92from sqlalchemy.ext.asyncio import AsyncSession93from app.database import async_session_factory9495async def get_session() -> AsyncGenerator[AsyncSession, None]:96 async with async_session_factory() as session:97 yield session98```99100### SQLAlchemy Models101102Use `mapped_column` style (SQLAlchemy 2.0). No legacy `Column()`.103104```python105# src/app/models/post.py106from datetime import datetime107from uuid import uuid4108from sqlalchemy import ForeignKey, String, Text109from sqlalchemy.orm import Mapped, mapped_column, relationship110from app.database import Base111112class Post(Base):113 __tablename__ = "posts"114115 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))116 title: Mapped[str] = mapped_column(String(200))117 content: Mapped[str] = mapped_column(Text)118 status: Mapped[str] = mapped_column(String(20), default="draft")119 author_id: Mapped[str] = mapped_column(ForeignKey("users.id"))120 created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)121 updated_at: Mapped[datetime | None] = mapped_column(default=None, onupdate=datetime.utcnow)122123 author: Mapped["User"] = relationship(back_populates="posts")124```125126### Pydantic Schemas127128Separate schemas for create, update, and response. Never expose the ORM model directly.129130```python131# src/app/schemas/post.py132from datetime import datetime133from pydantic import BaseModel, ConfigDict134135class PostCreate(BaseModel):136 title: str137 content: str138 status: str = "draft"139140class PostUpdate(BaseModel):141 title: str | None = None142 content: str | None = None143 status: str | None = None144145class PostResponse(BaseModel):146 model_config = ConfigDict(from_attributes=True)147148 id: str149 title: str150 content: str151 status: str152 author_id: str153 created_at: datetime154 updated_at: datetime | None155```156157### Service Layer158159Services are async functions. They receive the session, perform logic, return results or raise domain exceptions.160161```python162# src/app/services/post.py163from sqlalchemy import select164from sqlalchemy.ext.asyncio import AsyncSession165from app.models.post import Post166from app.schemas.post import PostCreate, PostUpdate167168class PostNotFoundError(Exception):169 def __init__(self, post_id: str) -> None:170 self.post_id = post_id171 super().__init__(f"Post {post_id} not found")172173async def list_posts(session: AsyncSession, *, status: str | None = None, offset: int = 0, limit: int = 20) -> list[Post]:174 query = select(Post).offset(offset).limit(limit)175 if status:176 query = query.where(Post.status == status)177 result = await session.execute(query)178 return list(result.scalars().all())179180async def get_post(session: AsyncSession, post_id: str) -> Post:181 post = await session.get(Post, post_id)182 if not post:183 raise PostNotFoundError(post_id)184 return post185186async def create_post(session: AsyncSession, data: PostCreate, *, author_id: str) -> Post:187 post = Post(**data.model_dump(), author_id=author_id)188 session.add(post)189 await session.flush()190 return post191```192193### Endpoints194195Thin wrappers: parse input, call service, return response.196197```python198# src/app/api/routes/posts.py199from fastapi import APIRouter, Depends, HTTPException, Query200from sqlalchemy.ext.asyncio import AsyncSession201from app.dependencies import get_current_user, get_session202from app.schemas.post import PostCreate, PostResponse, PostUpdate203from app.services.post import PostNotFoundError, create_post, get_post, list_posts204205router = APIRouter(prefix="/posts", tags=["posts"])206207@router.get("", response_model=list[PostResponse])208async def list_posts_endpoint(209 session: AsyncSession = Depends(get_session),210 status: str | None = Query(None),211 offset: int = Query(0, ge=0),212 limit: int = Query(20, ge=1, le=100),213):214 return await list_posts(session, status=status, offset=offset, limit=limit)215216@router.post("", response_model=PostResponse, status_code=201)217async def create_post_endpoint(218 data: PostCreate,219 session: AsyncSession = Depends(get_session),220 current_user=Depends(get_current_user),221):222 post = await create_post(session, data, author_id=current_user.id)223 await session.commit()224 return post225226@router.get("/{post_id}", response_model=PostResponse)227async def get_post_endpoint(post_id: str, session: AsyncSession = Depends(get_session)):228 try:229 return await get_post(session, post_id)230 except PostNotFoundError:231 raise HTTPException(status_code=404, detail="Post not found")232```233234### App Factory235236```python237# src/app/main.py238from contextlib import asynccontextmanager239from collections.abc import AsyncIterator240from fastapi import FastAPI241from fastapi.middleware.cors import CORSMiddleware242from app.config import settings243from app.api.router import api_router244from app.database import engine245246@asynccontextmanager247async def lifespan(app: FastAPI) -> AsyncIterator[None]:248 yield249 await engine.dispose()250251def create_app() -> FastAPI:252 app = FastAPI(title="API", lifespan=lifespan, debug=settings.debug)253 app.add_middleware(CORSMiddleware, allow_origins=settings.cors_origins,254 allow_credentials=True, allow_methods=["*"], allow_headers=["*"])255 app.include_router(api_router, prefix="/api/v1")256 return app257258app = create_app()259```260261## Testing262263Test at the API level (integration) and service level (unit).264265```python266# tests/conftest.py267import pytest268from httpx import ASGITransport, AsyncClient269from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine270from app.database import Base271from app.dependencies import get_session272from app.main import create_app273274@pytest.fixture275async def session():276 engine = create_async_engine("sqlite+aiosqlite:///./test.db")277 async with engine.begin() as conn:278 await conn.run_sync(Base.metadata.create_all)279 factory = async_sessionmaker(engine, expire_on_commit=False)280 async with factory() as session:281 yield session282 async with engine.begin() as conn:283 await conn.run_sync(Base.metadata.drop_all)284 await engine.dispose()285286@pytest.fixture287async def client(session: AsyncSession):288 app = create_app()289 app.dependency_overrides[get_session] = lambda: session290 async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:291 yield c292```293294```python295# tests/test_posts.py296import pytest297pytestmark = pytest.mark.anyio298299async def test_create_post(client, auth_headers):300 resp = await client.post("/api/v1/posts", json={"title": "Test", "content": "Hello"}, headers=auth_headers)301 assert resp.status_code == 201302 assert resp.json()["status"] == "draft"303304async def test_get_nonexistent_post_returns_404(client):305 resp = await client.get("/api/v1/posts/nonexistent-id")306 assert resp.status_code == 404307```308309## Conventions310311- **Types:** Annotate all function signatures. Use `str | None` not `Optional[str]`. Use `list[str]` not `List[str]`.312- **Naming:** snake_case files/functions, PascalCase classes, UPPER_SNAKE_CASE constants.313- **Async everywhere:** All endpoints and services are `async def`. Never use blocking I/O in async code.314- **Migrations:** Always review autogenerated Alembic migrations. Name them descriptively: `"add_status_to_posts"`.315316## Commands317318```bash319uv run fastapi dev # Dev server (hot reload)320uv run pytest # Run tests321uv run pytest --cov=app # Tests with coverage322uv run ruff check . && uv run ruff format . # Lint + format323uv run mypy src/ # Type check324uv run alembic upgrade head # Apply migrations325uv run alembic revision --autogenerate -m "description"326```327328## When Generating Code3293301. **Search the codebase first.** Check for existing services, schemas, and utilities.3312. **Follow the service layer.** Endpoints → services → models. No shortcuts.3323. **No new dependencies** without asking.3334. **Every endpoint needs:** Pydantic validation, proper status codes, error handling, response model.3345. **Every new model needs:** a migration.3356. **Type everything.** If mypy would complain, fix it before suggesting.336
Community feedback
0 found this helpful
Works with: