start-vibing-stacks 2.17.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/stacks/python/skills/api-security-python/SKILL.md +118 -15
- package/stacks/python/skills/async-patterns/SKILL.md +166 -62
- package/stacks/python/skills/django-patterns/SKILL.md +102 -11
- package/stacks/python/skills/fastapi-patterns/SKILL.md +277 -62
- package/stacks/python/skills/pydantic-validation/SKILL.md +106 -11
- package/stacks/python/skills/pytest-testing/SKILL.md +172 -54
- package/stacks/python/skills/python-patterns/SKILL.md +49 -7
- package/stacks/python/skills/python-performance/SKILL.md +183 -3
- package/stacks/python/skills/scripting-automation/SKILL.md +205 -119
|
@@ -1,134 +1,349 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fastapi-patterns
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "FastAPI patterns for Python 3.13/3.14 production APIs in 2026. Covers lifespan context manager (replaces deprecated @app.on_event), Pydantic V2 (Rust-backed), SQLAlchemy 2.0 async with Mapped[] type hints + async_sessionmaker(expire_on_commit=False) + pool_pre_ping, dependency injection with @lru_cache for settings and yield-based deps for cleanup, mandatory async/sync separation (NEVER mix sync DB driver in async def — freezes the worker), security headers + CORS via Starlette middleware, BackgroundTasks vs Celery/ARQ, deployment with uvicorn (production-ready since 2024 — gunicorn no longer required for most workloads). Invoke when writing FastAPI routes, dependencies, middleware, lifespan, or DB integration."
|
|
4
5
|
---
|
|
5
6
|
|
|
6
|
-
# FastAPI Patterns —
|
|
7
|
+
# FastAPI Patterns — Production APIs (2026)
|
|
7
8
|
|
|
8
|
-
**ALWAYS invoke when writing FastAPI routes, dependencies, or
|
|
9
|
+
**ALWAYS invoke when writing FastAPI routes, dependencies, middleware, lifespan, or DB integration.**
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
> Pair with `pydantic-validation` for schemas, `api-security-python` for OWASP/Sanctum equivalents, `async-patterns` for `asyncio.timeout`/TaskGroup, `pytest-testing` for `httpx.AsyncClient` test client.
|
|
12
|
+
|
|
13
|
+
## Project Structure
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
app/
|
|
17
|
+
├── main.py # FastAPI app + lifespan
|
|
18
|
+
├── api/
|
|
19
|
+
│ ├── v1/
|
|
20
|
+
│ │ ├── routes/ # Thin endpoint handlers (HTTP only)
|
|
21
|
+
│ │ └── deps.py # Auth, db, current_user, request_id
|
|
22
|
+
│ └── deps.py
|
|
23
|
+
├── core/
|
|
24
|
+
│ ├── config.py # Pydantic Settings, @lru_cache
|
|
25
|
+
│ ├── security.py # JWT issue/verify, password hashing
|
|
26
|
+
│ └── exceptions.py
|
|
27
|
+
├── db/
|
|
28
|
+
│ ├── base.py # SQLAlchemy DeclarativeBase
|
|
29
|
+
│ ├── models/ # SQLAlchemy ORM (Mapped[])
|
|
30
|
+
│ └── session.py # engine + async_sessionmaker
|
|
31
|
+
├── schemas/ # Pydantic V2 models (Base/Create/Update/Response)
|
|
32
|
+
├── services/ # Business logic — routers DELEGATE here
|
|
33
|
+
└── tests/
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Lifespan — startup/shutdown the modern way
|
|
37
|
+
|
|
38
|
+
`@app.on_event("startup"|"shutdown")` is **deprecated**. Use a single `lifespan` async context manager:
|
|
11
39
|
|
|
12
40
|
```python
|
|
13
|
-
|
|
41
|
+
# app/main.py
|
|
42
|
+
from contextlib import asynccontextmanager
|
|
43
|
+
from fastapi import FastAPI
|
|
44
|
+
import httpx
|
|
45
|
+
|
|
46
|
+
from app.db.session import engine
|
|
47
|
+
from app.core.config import settings
|
|
48
|
+
|
|
49
|
+
@asynccontextmanager
|
|
50
|
+
async def lifespan(app: FastAPI):
|
|
51
|
+
# ---- startup ----
|
|
52
|
+
app.state.http = httpx.AsyncClient(timeout=10.0)
|
|
53
|
+
yield
|
|
54
|
+
# ---- shutdown ----
|
|
55
|
+
await app.state.http.aclose()
|
|
56
|
+
await engine.dispose()
|
|
57
|
+
|
|
58
|
+
app = FastAPI(
|
|
59
|
+
title="MyAPI",
|
|
60
|
+
version="1.0",
|
|
61
|
+
lifespan=lifespan,
|
|
62
|
+
docs_url="/docs" if settings.DEBUG else None,
|
|
63
|
+
)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Anything that needs setup once and teardown on shutdown (HTTP clients, DB pool, message broker, cache) goes here — not in module-level globals.
|
|
67
|
+
|
|
68
|
+
## Route — thin, typed, delegates
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from fastapi import APIRouter, Depends, status
|
|
14
72
|
from app.schemas.user import UserCreate, UserResponse
|
|
15
73
|
from app.services.user import UserService
|
|
16
|
-
from app.api.deps import
|
|
74
|
+
from app.api.v1.deps import get_user_service, get_current_user
|
|
17
75
|
|
|
18
76
|
router = APIRouter(prefix="/users", tags=["users"])
|
|
19
77
|
|
|
20
|
-
@router.post(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
78
|
+
@router.post(
|
|
79
|
+
"",
|
|
80
|
+
response_model=UserResponse,
|
|
81
|
+
status_code=status.HTTP_201_CREATED,
|
|
82
|
+
summary="Create user",
|
|
83
|
+
)
|
|
84
|
+
async def create_user(
|
|
85
|
+
body: UserCreate,
|
|
86
|
+
svc: UserService = Depends(get_user_service),
|
|
87
|
+
) -> UserResponse:
|
|
88
|
+
return await svc.create(body)
|
|
24
89
|
|
|
25
90
|
@router.get("/me", response_model=UserResponse)
|
|
26
|
-
async def get_me(user=Depends(get_current_user)):
|
|
91
|
+
async def get_me(user = Depends(get_current_user)) -> UserResponse:
|
|
27
92
|
return user
|
|
28
93
|
```
|
|
29
94
|
|
|
30
|
-
|
|
95
|
+
Routes don't touch the DB. Services do. Routes don't catch exceptions to translate codes either — exception handlers do.
|
|
96
|
+
|
|
97
|
+
## Dependency Injection — patterns
|
|
31
98
|
|
|
32
99
|
```python
|
|
33
|
-
# app/api/deps.py
|
|
100
|
+
# app/api/v1/deps.py
|
|
101
|
+
from functools import lru_cache
|
|
34
102
|
from fastapi import Depends, HTTPException, status
|
|
35
|
-
from fastapi.security import
|
|
103
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
104
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
36
105
|
|
|
37
|
-
|
|
106
|
+
from app.core.config import Settings, get_settings # @lru_cache below
|
|
107
|
+
from app.db.session import async_session
|
|
108
|
+
from app.services.user import UserService
|
|
109
|
+
|
|
110
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
|
|
38
111
|
|
|
39
|
-
|
|
112
|
+
# yield-based — auto cleanup on response (or exception)
|
|
113
|
+
async def get_db() -> AsyncSession:
|
|
40
114
|
async with async_session() as session:
|
|
41
|
-
|
|
115
|
+
try:
|
|
116
|
+
yield session
|
|
117
|
+
except Exception:
|
|
118
|
+
await session.rollback()
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
# composition — DI just plugs sub-deps in
|
|
122
|
+
def get_user_service(db: AsyncSession = Depends(get_db)) -> UserService:
|
|
123
|
+
return UserService(db)
|
|
42
124
|
|
|
43
125
|
async def get_current_user(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
)
|
|
47
|
-
user = await
|
|
126
|
+
token: str = Depends(oauth2_scheme),
|
|
127
|
+
svc: UserService = Depends(get_user_service),
|
|
128
|
+
):
|
|
129
|
+
user = await svc.from_token(token)
|
|
48
130
|
if not user:
|
|
49
|
-
raise HTTPException(
|
|
131
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
|
|
50
132
|
return user
|
|
51
133
|
```
|
|
52
134
|
|
|
53
|
-
## Pydantic Settings (env config)
|
|
54
|
-
|
|
55
135
|
```python
|
|
56
|
-
|
|
136
|
+
# app/core/config.py — settings cached, validated at import
|
|
137
|
+
from functools import lru_cache
|
|
138
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
57
139
|
|
|
58
140
|
class Settings(BaseSettings):
|
|
141
|
+
model_config = SettingsConfigDict(env_file=".env", extra="forbid")
|
|
142
|
+
|
|
59
143
|
DATABASE_URL: str
|
|
60
144
|
SECRET_KEY: str
|
|
61
145
|
DEBUG: bool = False
|
|
62
146
|
ALLOWED_ORIGINS: list[str] = ["http://localhost:3000"]
|
|
63
147
|
|
|
64
|
-
|
|
65
|
-
|
|
148
|
+
@lru_cache
|
|
149
|
+
def get_settings() -> Settings:
|
|
150
|
+
return Settings() # raises ValidationError if env missing/invalid
|
|
151
|
+
|
|
152
|
+
settings = get_settings()
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## SQLAlchemy 2.0 Async — `Mapped[]` style
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
# app/db/base.py
|
|
159
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
160
|
+
|
|
161
|
+
class Base(DeclarativeBase):
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
# app/db/models/user.py
|
|
165
|
+
from datetime import datetime
|
|
166
|
+
from uuid import UUID, uuid4
|
|
167
|
+
from sqlalchemy import String
|
|
168
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
169
|
+
|
|
170
|
+
from app.db.base import Base
|
|
171
|
+
|
|
172
|
+
class User(Base):
|
|
173
|
+
__tablename__ = "users"
|
|
174
|
+
|
|
175
|
+
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
|
|
176
|
+
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
|
177
|
+
name: Mapped[str] = mapped_column(String(255))
|
|
178
|
+
is_active: Mapped[bool] = mapped_column(default=True)
|
|
179
|
+
created_at: Mapped[datetime] = mapped_column(server_default="now()")
|
|
180
|
+
|
|
181
|
+
# app/db/session.py
|
|
182
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
|
183
|
+
from app.core.config import settings
|
|
184
|
+
|
|
185
|
+
engine = create_async_engine(
|
|
186
|
+
settings.DATABASE_URL, # postgresql+asyncpg://… for PG; mysql+asyncmy://… for MySQL/MariaDB
|
|
187
|
+
pool_size=20,
|
|
188
|
+
max_overflow=10,
|
|
189
|
+
pool_pre_ping=True, # detect stale conns; mandatory in 2026
|
|
190
|
+
pool_recycle=1800, # recycle conns every 30 min
|
|
191
|
+
)
|
|
192
|
+
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`Mapped[]` is mandatory in 2.0 — `Column()` without `Mapped[]` loses type inference. `expire_on_commit=False` prevents the "DetachedInstanceError" trap when you return ORM rows from an endpoint after the session closed.
|
|
196
|
+
|
|
197
|
+
### Repository pattern
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
# app/services/user.py
|
|
201
|
+
from uuid import UUID
|
|
202
|
+
from sqlalchemy import select
|
|
203
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
204
|
+
from app.db.models.user import User
|
|
205
|
+
|
|
206
|
+
class UserService:
|
|
207
|
+
def __init__(self, db: AsyncSession) -> None:
|
|
208
|
+
self.db = db
|
|
209
|
+
|
|
210
|
+
async def get_by_id(self, id: UUID) -> User | None:
|
|
211
|
+
result = await self.db.execute(select(User).where(User.id == id))
|
|
212
|
+
return result.scalar_one_or_none()
|
|
213
|
+
|
|
214
|
+
async def create(self, data) -> User:
|
|
215
|
+
user = User(email=data.email, name=data.name)
|
|
216
|
+
self.db.add(user)
|
|
217
|
+
await self.db.commit()
|
|
218
|
+
await self.db.refresh(user)
|
|
219
|
+
return user
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Async / Sync — never mix
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
# WRONG — sync psycopg2 inside async def → freezes the event loop for ALL users
|
|
226
|
+
@router.get("/users")
|
|
227
|
+
async def list_users():
|
|
228
|
+
rows = sync_psycopg2.execute("SELECT * FROM users") # ❌
|
|
229
|
+
return rows
|
|
230
|
+
|
|
231
|
+
# CORRECT — async driver (asyncpg) inside async def
|
|
232
|
+
@router.get("/users")
|
|
233
|
+
async def list_users(db: AsyncSession = Depends(get_db)):
|
|
234
|
+
return (await db.execute(select(User))).scalars().all()
|
|
66
235
|
|
|
67
|
-
|
|
236
|
+
# CORRECT — sync driver inside def → FastAPI runs in threadpool, doesn't block loop
|
|
237
|
+
@router.get("/legacy")
|
|
238
|
+
def list_legacy_users(): # plain def
|
|
239
|
+
return SyncSession().query(User).all()
|
|
68
240
|
```
|
|
69
241
|
|
|
242
|
+
If you're stuck with sync drivers (psycopg2, pymongo sync, mysqlclient), **declare the route as `def`, not `async def`**. FastAPI offloads it. Mixing the two paradigms inside `async def` is the #1 cause of "the API hangs under load".
|
|
243
|
+
|
|
70
244
|
## Middleware
|
|
71
245
|
|
|
72
246
|
```python
|
|
73
247
|
from fastapi.middleware.cors import CORSMiddleware
|
|
248
|
+
from starlette.middleware.gzip import GZipMiddleware
|
|
249
|
+
from uuid import uuid4
|
|
74
250
|
|
|
251
|
+
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
|
75
252
|
app.add_middleware(
|
|
76
253
|
CORSMiddleware,
|
|
77
|
-
allow_origins=settings.ALLOWED_ORIGINS,
|
|
254
|
+
allow_origins=settings.ALLOWED_ORIGINS, # explicit list — never ["*"] with credentials
|
|
78
255
|
allow_credentials=True,
|
|
79
|
-
allow_methods=["
|
|
80
|
-
allow_headers=["
|
|
256
|
+
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
|
257
|
+
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
|
|
81
258
|
)
|
|
82
259
|
|
|
83
|
-
# Custom middleware
|
|
84
260
|
@app.middleware("http")
|
|
85
261
|
async def add_request_id(request, call_next):
|
|
86
|
-
|
|
87
|
-
request.state.request_id =
|
|
262
|
+
rid = request.headers.get("x-request-id", str(uuid4()))
|
|
263
|
+
request.state.request_id = rid
|
|
88
264
|
response = await call_next(request)
|
|
89
|
-
response.headers["X-Request-ID"] =
|
|
265
|
+
response.headers["X-Request-ID"] = rid
|
|
90
266
|
return response
|
|
91
267
|
```
|
|
92
268
|
|
|
93
|
-
|
|
269
|
+
For the full security-headers / CORS / rate-limit pattern see `api-security-python`.
|
|
94
270
|
|
|
95
|
-
|
|
96
|
-
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
|
271
|
+
## Exception handlers — translate, don't catch
|
|
97
272
|
|
|
98
|
-
|
|
99
|
-
|
|
273
|
+
```python
|
|
274
|
+
# app/core/exceptions.py
|
|
275
|
+
from fastapi import HTTPException, Request, status
|
|
276
|
+
from fastapi.responses import JSONResponse
|
|
100
277
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
self.session = session
|
|
278
|
+
class NotFoundError(Exception):
|
|
279
|
+
def __init__(self, resource: str, id: str) -> None:
|
|
280
|
+
self.resource, self.id = resource, id
|
|
105
281
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
282
|
+
def register_exception_handlers(app: FastAPI) -> None:
|
|
283
|
+
@app.exception_handler(NotFoundError)
|
|
284
|
+
async def not_found(_: Request, exc: NotFoundError):
|
|
285
|
+
return JSONResponse(
|
|
286
|
+
status_code=404,
|
|
287
|
+
content={"type": "/problems/not-found",
|
|
288
|
+
"title": f"{exc.resource} not found",
|
|
289
|
+
"instance": exc.id},
|
|
109
290
|
)
|
|
110
|
-
return result.scalar_one_or_none()
|
|
111
291
|
```
|
|
112
292
|
|
|
113
|
-
|
|
293
|
+
Use `application/problem+json` shape — see the universal `openapi-design` skill.
|
|
114
294
|
|
|
115
|
-
|
|
116
|
-
# uvicorn with gunicorn (production)
|
|
117
|
-
gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
|
|
295
|
+
## Background work — pick by need
|
|
118
296
|
|
|
119
|
-
|
|
120
|
-
|
|
297
|
+
| Tool | When |
|
|
298
|
+
|---|---|
|
|
299
|
+
| `BackgroundTasks` (built-in) | < 1 s, in-process, fire-and-forget after response |
|
|
300
|
+
| **ARQ** | Lightweight async tasks via Redis; preferred for FastAPI |
|
|
301
|
+
| **Dramatiq** | Actor-based, simpler than Celery |
|
|
302
|
+
| **Celery** | Distributed, complex retries/chains, multi-language workforce |
|
|
303
|
+
| **Temporal / Hatchet** | Long-running workflows (hours/days), need replay |
|
|
304
|
+
|
|
305
|
+
## Production Deployment
|
|
306
|
+
|
|
307
|
+
```dockerfile
|
|
308
|
+
# syntax=docker/dockerfile:1.7
|
|
309
|
+
FROM python:3.13-slim-bookworm AS builder
|
|
310
|
+
COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /usr/local/bin/uv
|
|
121
311
|
WORKDIR /app
|
|
122
|
-
COPY
|
|
123
|
-
RUN
|
|
312
|
+
COPY pyproject.toml uv.lock ./
|
|
313
|
+
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-install-project
|
|
124
314
|
COPY . .
|
|
125
|
-
|
|
315
|
+
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
|
|
316
|
+
|
|
317
|
+
FROM gcr.io/distroless/python3-debian12:nonroot
|
|
318
|
+
WORKDIR /app
|
|
319
|
+
COPY --from=builder --chown=nonroot:nonroot /app /app
|
|
320
|
+
USER nonroot
|
|
321
|
+
EXPOSE 8000
|
|
322
|
+
CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
|
126
323
|
```
|
|
127
324
|
|
|
325
|
+
`uvicorn` is **production-ready** since the rewrite — `gunicorn` as a process manager is no longer required for most deployments. Use `--workers N` to fork. For Kubernetes, a single worker per pod + horizontal scaling is generally cleaner than multi-worker pods.
|
|
326
|
+
|
|
128
327
|
## FORBIDDEN
|
|
129
328
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
329
|
+
| Pattern | Why |
|
|
330
|
+
|---|---|
|
|
331
|
+
| `@app.on_event("startup"\|"shutdown")` | Deprecated — use `lifespan` |
|
|
332
|
+
| `class Config:` inside Pydantic models | Pydantic V1 syntax — use `model_config = ConfigDict(...)` |
|
|
333
|
+
| `from sqlalchemy import Column` for new models | 2.0 style is `mapped_column` + `Mapped[]` |
|
|
334
|
+
| Sync DB call inside `async def` route | Blocks event loop for everyone |
|
|
335
|
+
| `def` route doing async work | Run inside `async def` to use `await` |
|
|
336
|
+
| `Settings()` called per-request | Reads env each time — wrap in `@lru_cache` |
|
|
337
|
+
| `response_model=None` on a public endpoint | Loses validation + auto-docs |
|
|
338
|
+
| `allow_origins=["*"]` with `allow_credentials=True` | Browser silently blocks; auth breaks |
|
|
339
|
+
| Catching bare `Exception` in routes | Hides real bugs; use exception handlers |
|
|
340
|
+
| `engine` created at module scope without `pool_pre_ping=True` | Stale conns → 500s after pool idle |
|
|
341
|
+
|
|
342
|
+
## See Also
|
|
343
|
+
|
|
344
|
+
- `pydantic-validation` — V2 schema patterns (Base/Create/Update/Response)
|
|
345
|
+
- `api-security-python` — security headers, CORS, JWT, rate limit, CSRF
|
|
346
|
+
- `async-patterns` — asyncio.timeout, TaskGroup, httpx best practice
|
|
347
|
+
- `pytest-testing` — async client fixture + DB rollback
|
|
348
|
+
- `_shared/skills/openapi-design` — `application/problem+json` shape
|
|
349
|
+
- `_shared/skills/observability` — request IDs, structured logs, GenAI semconv
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pydantic-validation
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "Runtime type-safety for Python boundaries with Pydantic V2 (Rust-backed pydantic-core, 5–50× faster than V1). Covers the Base/Create/Update/Response/InDB multi-model pattern, V2 validators (`field_validator` + `model_validator(mode='after')`), `ConfigDict(extra='forbid')` to block mass-assignment, `TypeAdapter` for non-BaseModel types (lists of dicts, primitives), discriminated unions for polymorphic payloads, JSON Schema export for OpenAPI, snake_case ↔ camelCase aliasing, and Pydantic Settings for env config (`SettingsConfigDict`). Invoke whenever you cross a trust boundary: HTTP body, query params, env vars, queue messages, ORM-to-API conversion, or LLM tool-call validation."
|
|
4
5
|
---
|
|
5
6
|
|
|
6
|
-
# Pydantic
|
|
7
|
+
# Pydantic V2 — Runtime Validation at Boundaries
|
|
7
8
|
|
|
8
|
-
**ALWAYS use Pydantic for API schemas, config, and data
|
|
9
|
+
**ALWAYS use Pydantic for API schemas, config, and any external data boundary.**
|
|
10
|
+
|
|
11
|
+
> Pydantic V1 is end-of-life — the V1 → V2 migration tool (`bump-pydantic`) handles the bulk. Anything below assumes V2 (`pydantic >= 2.x`, `pydantic-settings >= 2.x`).
|
|
12
|
+
|
|
13
|
+
## Why V2
|
|
14
|
+
|
|
15
|
+
- Core rewritten in Rust → 5–50× faster than V1
|
|
16
|
+
- Built-in JSON parser → no `json.loads` first
|
|
17
|
+
- Cleaner discriminated unions, stricter coercion modes
|
|
18
|
+
- Native support in FastAPI, LangChain (LLM tool args), msgspec interop
|
|
9
19
|
|
|
10
20
|
## Multi-Model Pattern
|
|
11
21
|
|
|
@@ -69,17 +79,78 @@ class OrderCreate(BaseModel):
|
|
|
69
79
|
## Settings (env config)
|
|
70
80
|
|
|
71
81
|
```python
|
|
72
|
-
from
|
|
82
|
+
from functools import lru_cache
|
|
83
|
+
from pydantic import Field
|
|
84
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
73
85
|
|
|
74
86
|
class Settings(BaseSettings):
|
|
75
|
-
|
|
76
|
-
|
|
87
|
+
model_config = SettingsConfigDict(
|
|
88
|
+
env_file=".env",
|
|
89
|
+
env_file_encoding="utf-8",
|
|
90
|
+
extra="forbid", # unknown env vars → error early
|
|
91
|
+
env_nested_delimiter="__", # DB__HOST → settings.db.host
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
DATABASE_URL: str = Field(min_length=10)
|
|
95
|
+
SECRET_KEY: str = Field(min_length=32)
|
|
77
96
|
DEBUG: bool = False
|
|
78
97
|
REDIS_URL: str = "redis://localhost:6379"
|
|
79
98
|
|
|
80
|
-
|
|
99
|
+
@lru_cache
|
|
100
|
+
def get_settings() -> Settings:
|
|
101
|
+
return Settings() # validated once, raises on missing/invalid env
|
|
102
|
+
|
|
103
|
+
settings = get_settings()
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Discriminated Unions (polymorphic payloads)
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from typing import Annotated, Literal
|
|
110
|
+
from pydantic import BaseModel, Field, TypeAdapter
|
|
111
|
+
|
|
112
|
+
class CardPayment(BaseModel):
|
|
113
|
+
type: Literal["card"]
|
|
114
|
+
last4: str
|
|
115
|
+
brand: str
|
|
116
|
+
|
|
117
|
+
class PixPayment(BaseModel):
|
|
118
|
+
type: Literal["pix"]
|
|
119
|
+
key: str
|
|
81
120
|
|
|
82
|
-
|
|
121
|
+
# `type` field tells Pydantic which variant to instantiate — no isinstance dance
|
|
122
|
+
Payment = Annotated[CardPayment | PixPayment, Field(discriminator="type")]
|
|
123
|
+
|
|
124
|
+
class Order(BaseModel):
|
|
125
|
+
id: str
|
|
126
|
+
payment: Payment
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## TypeAdapter — validate things that aren't BaseModel
|
|
130
|
+
|
|
131
|
+
When you receive a list of dicts, a primitive with constraints, or a payload you don't want to wrap in a class:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from pydantic import TypeAdapter, conint
|
|
135
|
+
|
|
136
|
+
PositiveInt = conint(gt=0)
|
|
137
|
+
positive = TypeAdapter(PositiveInt)
|
|
138
|
+
positive.validate_python(42) # 42
|
|
139
|
+
positive.validate_python(-1) # ValidationError
|
|
140
|
+
|
|
141
|
+
UserList = TypeAdapter(list[UserResponse])
|
|
142
|
+
users = UserList.validate_python(rows) # validates the whole list, not row-by-row
|
|
143
|
+
schema = UserList.json_schema() # OpenAPI / docs
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## JSON Schema export (OpenAPI, LLM tool calls)
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from app.schemas.user import UserCreate
|
|
150
|
+
|
|
151
|
+
UserCreate.model_json_schema()
|
|
152
|
+
# Use the result as `parameters` of an LLM tool definition (Anthropic, OpenAI),
|
|
153
|
+
# or merge into your OpenAPI spec.
|
|
83
154
|
```
|
|
84
155
|
|
|
85
156
|
## camelCase API (Python snake_case → JSON camelCase)
|
|
@@ -100,9 +171,33 @@ class UserResponse(ApiModel):
|
|
|
100
171
|
created_at: datetime # JSON: "createdAt"
|
|
101
172
|
```
|
|
102
173
|
|
|
174
|
+
## Strict mode (when you mean it)
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from pydantic import BaseModel, ConfigDict
|
|
178
|
+
|
|
179
|
+
class StrictUser(BaseModel):
|
|
180
|
+
model_config = ConfigDict(strict=True) # NO coercion: "1" != 1, "true" != True
|
|
181
|
+
id: int
|
|
182
|
+
is_active: bool
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Strict mode is the right default for queue messages, internal RPC, and anything machine-to-machine. For HTTP endpoints, the default lax mode (with explicit `Field(...)` constraints) is more tolerant of legacy clients.
|
|
186
|
+
|
|
103
187
|
## FORBIDDEN
|
|
104
188
|
|
|
105
189
|
1. **Raw dicts for API I/O** — always Pydantic models
|
|
106
|
-
2.
|
|
107
|
-
3. **
|
|
108
|
-
4. **
|
|
190
|
+
2. **`dict(model)` to serialize** — use `model.model_dump()` / `model_dump_json()`
|
|
191
|
+
3. **Skipping validation** — `model_validate(data)`, never `Model(**data)` for untrusted input
|
|
192
|
+
4. **Single model for everything** — Base/Create/Update/Response/InDB
|
|
193
|
+
5. **Missing `from_attributes=True`** — needed when reading from ORM rows
|
|
194
|
+
6. **Missing `extra="forbid"` on Settings or write payloads** — opens door to mass-assignment
|
|
195
|
+
7. **Pydantic V1 syntax** (`@validator`, `class Config:`, `BaseSettings` from `pydantic`) — V1 is EOL, use V2 (`@field_validator`, `model_config = ConfigDict(...)`, `pydantic_settings.BaseSettings`)
|
|
196
|
+
8. **Hand-rolled discriminator branching** (`if data["type"] == "card": ...`) — use `Annotated[Union[...], Field(discriminator=...)]`
|
|
197
|
+
|
|
198
|
+
## See Also
|
|
199
|
+
|
|
200
|
+
- `fastapi-patterns` — request/response wiring
|
|
201
|
+
- `api-security-python` — `extra="forbid"` as anti-mass-assignment defense
|
|
202
|
+
- `_shared/skills/openapi-design` — schemas → OpenAPI 3.2
|
|
203
|
+
|