dotmd

.cursorrules — Python + FastAPI

Cursor rules for FastAPI backends with layered architecture, SQLAlchemy 2.0, and pytest workflows.

By dotmd TeamCC0Published Feb 19, 2026View source ↗

Install path

Use this file for each supported tool in your project.

  • Cursor: Save as .cursorrules in your project at .cursorrules.

Configuration

.cursorrules

1# .cursorrules — Python + FastAPI
2
3You 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.
4
5## Quick Reference
6
7| 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 |
19
20## Project Structure
21
22```
23├── src/app/
24│ ├── main.py # App factory + lifespan
25│ ├── config.py # Settings (pydantic-settings)
26│ ├── database.py # Engine, session factory, Base
27│ ├── dependencies.py # Shared Depends() callables
28│ ├── models/ # SQLAlchemy models
29│ ├── schemas/ # Pydantic request/response schemas
30│ ├── services/ # Business logic (one per domain)
31│ └── api/
32│ ├── router.py # Aggregates all route modules
33│ └── routes/ # Endpoint modules
34├── migrations/
35├── tests/
36│ ├── conftest.py # Fixtures: test DB, client, factories
37│ └── test_*.py
38├── pyproject.toml
39└── uv.lock
40```
41
42## Architecture
43
44Endpoints handle HTTP. Services handle logic. Models handle persistence. They don't bleed into each other.
45
46```
47Request → Route (validate) → Service (logic) → Model (DB) → Service → Schema (serialize) → Response
48```
49
50- 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.
54
55## Code Patterns
56
57### Settings
58
59```python
60# src/app/config.py
61from pydantic_settings import BaseSettings, SettingsConfigDict
62
63class Settings(BaseSettings):
64 model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
65
66 database_url: str
67 secret_key: str
68 debug: bool = False
69 cors_origins: list[str] = ["http://localhost:5173"]
70
71settings = Settings() # type: ignore[call-arg]
72```
73
74### Database + Dependencies
75
76```python
77# src/app/database.py
78from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
79from sqlalchemy.orm import DeclarativeBase
80from app.config import settings
81
82engine = create_async_engine(settings.database_url, echo=settings.debug)
83async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
84
85class Base(DeclarativeBase):
86 pass
87```
88
89```python
90# src/app/dependencies.py
91from collections.abc import AsyncGenerator
92from sqlalchemy.ext.asyncio import AsyncSession
93from app.database import async_session_factory
94
95async def get_session() -> AsyncGenerator[AsyncSession, None]:
96 async with async_session_factory() as session:
97 yield session
98```
99
100### SQLAlchemy Models
101
102Use `mapped_column` style (SQLAlchemy 2.0). No legacy `Column()`.
103
104```python
105# src/app/models/post.py
106from datetime import datetime
107from uuid import uuid4
108from sqlalchemy import ForeignKey, String, Text
109from sqlalchemy.orm import Mapped, mapped_column, relationship
110from app.database import Base
111
112class Post(Base):
113 __tablename__ = "posts"
114
115 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)
122
123 author: Mapped["User"] = relationship(back_populates="posts")
124```
125
126### Pydantic Schemas
127
128Separate schemas for create, update, and response. Never expose the ORM model directly.
129
130```python
131# src/app/schemas/post.py
132from datetime import datetime
133from pydantic import BaseModel, ConfigDict
134
135class PostCreate(BaseModel):
136 title: str
137 content: str
138 status: str = "draft"
139
140class PostUpdate(BaseModel):
141 title: str | None = None
142 content: str | None = None
143 status: str | None = None
144
145class PostResponse(BaseModel):
146 model_config = ConfigDict(from_attributes=True)
147
148 id: str
149 title: str
150 content: str
151 status: str
152 author_id: str
153 created_at: datetime
154 updated_at: datetime | None
155```
156
157### Service Layer
158
159Services are async functions. They receive the session, perform logic, return results or raise domain exceptions.
160
161```python
162# src/app/services/post.py
163from sqlalchemy import select
164from sqlalchemy.ext.asyncio import AsyncSession
165from app.models.post import Post
166from app.schemas.post import PostCreate, PostUpdate
167
168class PostNotFoundError(Exception):
169 def __init__(self, post_id: str) -> None:
170 self.post_id = post_id
171 super().__init__(f"Post {post_id} not found")
172
173async 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())
179
180async 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 post
185
186async 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 post
191```
192
193### Endpoints
194
195Thin wrappers: parse input, call service, return response.
196
197```python
198# src/app/api/routes/posts.py
199from fastapi import APIRouter, Depends, HTTPException, Query
200from sqlalchemy.ext.asyncio import AsyncSession
201from app.dependencies import get_current_user, get_session
202from app.schemas.post import PostCreate, PostResponse, PostUpdate
203from app.services.post import PostNotFoundError, create_post, get_post, list_posts
204
205router = APIRouter(prefix="/posts", tags=["posts"])
206
207@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)
215
216@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 post
225
226@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```
233
234### App Factory
235
236```python
237# src/app/main.py
238from contextlib import asynccontextmanager
239from collections.abc import AsyncIterator
240from fastapi import FastAPI
241from fastapi.middleware.cors import CORSMiddleware
242from app.config import settings
243from app.api.router import api_router
244from app.database import engine
245
246@asynccontextmanager
247async def lifespan(app: FastAPI) -> AsyncIterator[None]:
248 yield
249 await engine.dispose()
250
251def 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 app
257
258app = create_app()
259```
260
261## Testing
262
263Test at the API level (integration) and service level (unit).
264
265```python
266# tests/conftest.py
267import pytest
268from httpx import ASGITransport, AsyncClient
269from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
270from app.database import Base
271from app.dependencies import get_session
272from app.main import create_app
273
274@pytest.fixture
275async 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 session
282 async with engine.begin() as conn:
283 await conn.run_sync(Base.metadata.drop_all)
284 await engine.dispose()
285
286@pytest.fixture
287async def client(session: AsyncSession):
288 app = create_app()
289 app.dependency_overrides[get_session] = lambda: session
290 async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
291 yield c
292```
293
294```python
295# tests/test_posts.py
296import pytest
297pytestmark = pytest.mark.anyio
298
299async 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 == 201
302 assert resp.json()["status"] == "draft"
303
304async def test_get_nonexistent_post_returns_404(client):
305 resp = await client.get("/api/v1/posts/nonexistent-id")
306 assert resp.status_code == 404
307```
308
309## Conventions
310
311- **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"`.
315
316## Commands
317
318```bash
319uv run fastapi dev # Dev server (hot reload)
320uv run pytest # Run tests
321uv run pytest --cov=app # Tests with coverage
322uv run ruff check . && uv run ruff format . # Lint + format
323uv run mypy src/ # Type check
324uv run alembic upgrade head # Apply migrations
325uv run alembic revision --autogenerate -m "description"
326```
327
328## When Generating Code
329
3301. **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: