Refresh examples for modern stack; promote AGENTS.md to canonical agent ruleset (#89)
* Refresh examples for modern stack; promote AGENTS.md to canonical agent ruleset Replaces stale code in README.md (python-jose, async_asgi_testclient, json_encoders, contradictory `Field(ge=18, default=None)`, unused `model_validator` import) with current equivalents (PyJWT, httpx + ASGITransport, `@field_serializer`). Adds a top-of-file pointer to AGENTS.md, a tested-versions line, and inline notes for `Annotated[T, Depends(...)]` and SQLAlchemy 2.0 async without rewriting every example. Adds a `dependency_overrides` testing subsection and a BackgroundTasks vs. task-queue decision table. Rewrites AGENTS.md as a terse, machine-readable mirror: compatibility matrix at the top, Do/Don't blocks throughout, an explicit anti-patterns table covering common AI-agent mistakes, and a quick-reference table. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Sync code-only fixes into README_ZH.md Mirrors the safe code-only changes from the parent commit into the Chinese translation: - python-jose -> PyJWT (import + exception class) in both dependency examples - async_asgi_testclient -> httpx.AsyncClient + ASGITransport (and fixes the previously broken example that mixed both imports) - Pydantic json_encoders -> @field_serializer for the CustomModel example - Field(ge=18, default=None) -> Field(ge=18) (the constraint and the default contradict each other) - Removes unused `model_validator` import No prose changes. Existing Chinese descriptions still accurately describe the new code. Sections newly added in README.md (BackgroundTasks vs task queue, dependency_overrides, the agent banner, the version line, and the Annotated/SQLAlchemy 2.0 inline notes) are not ported here because they require translation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Trim docs/changelog voice from refresh Pass over the refresh to keep the article in best-practices voice, not docs or changelog: - Drop the "Note on syntax" blockquote in README's Dependencies section. It hedged ("Both styles work. New code should prefer Annotated.") while every example below kept the default-arg form. Removing the callout returns the section to its original shape; a full Annotated migration can be its own PR. - Remove the "json_encoders is deprecated in Pydantic v2" inline explainer. The code change already says it. - Rewrite the databases blockquote into a single in-voice line ("For new projects, reach for SQLAlchemy 2.0's async API...") instead of an apologetic disclaimer. - Collapse the "Earlier versions of this article recommended async_asgi_testclient" blockquote into a direct sentence. - Drop the "this file wins — README is narrative, this is the spec" precedence line in AGENTS.md. The two files are peers; neither outranks the other. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
106
README.md
106
README.md
@@ -5,6 +5,12 @@ After several years of building production systems,
|
||||
we've made both good and bad decisions that significantly impacted our developer experience.
|
||||
Here are some lessons worth sharing.
|
||||
|
||||
> **Working with an AI agent?** See [AGENTS.md](./AGENTS.md) for the same rules in a
|
||||
> terse, machine-readable format with a version matrix, Do/Don't blocks, and an
|
||||
> anti-patterns checklist.
|
||||
|
||||
**Tested against:** Python 3.11+ · FastAPI 0.115+ · Pydantic 2.7+ · SQLAlchemy 2.0+ · Alembic 1.13+ · httpx 0.27+ · PyJWT 2.9+ · ruff 0.6+
|
||||
|
||||
*[简体中文](./README_ZH.md)*
|
||||
|
||||
## Contents <!-- omit from toc -->
|
||||
@@ -25,6 +31,7 @@ Here are some lessons worth sharing.
|
||||
- [Follow the REST](#follow-the-rest)
|
||||
- [FastAPI response serialization](#fastapi-response-serialization)
|
||||
- [If you must use sync SDK, then run it in a thread pool.](#if-you-must-use-sync-sdk-then-run-it-in-a-thread-pool)
|
||||
- [BackgroundTasks vs a real task queue](#backgroundtasks-vs-a-real-task-queue)
|
||||
- [ValueErrors might become Pydantic ValidationError](#valueerrors-might-become-pydantic-validationerror)
|
||||
- [Docs](#docs)
|
||||
- [Set DB keys naming conventions](#set-db-keys-naming-conventions)
|
||||
@@ -211,7 +218,7 @@ class UserBase(BaseModel):
|
||||
first_name: str = Field(min_length=1, max_length=128)
|
||||
username: str = Field(min_length=1, max_length=128, pattern="^[A-Za-z0-9-_]+$")
|
||||
email: EmailStr
|
||||
age: int = Field(ge=18, default=None) # must be greater or equal to 18
|
||||
age: int = Field(ge=18) # required, must be greater or equal to 18
|
||||
favorite_band: MusicBand | None = None # only "AEROSMITH", "QUEEN", "AC/DC" values are allowed to be inputted
|
||||
website: AnyUrl | None = None
|
||||
```
|
||||
@@ -219,24 +226,23 @@ class UserBase(BaseModel):
|
||||
Having a controllable global base model allows us to customize all the models within the app. For instance, we can enforce a standard datetime format or introduce a common method for all subclasses of the base model.
|
||||
```python
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
def datetime_to_gmt_str(dt: datetime) -> str:
|
||||
if not dt.tzinfo:
|
||||
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||
from pydantic import BaseModel, ConfigDict, field_serializer
|
||||
|
||||
|
||||
class CustomModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
json_encoders={datetime: datetime_to_gmt_str},
|
||||
populate_by_name=True,
|
||||
)
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@field_serializer("*", when_used="json", check_fields=False)
|
||||
def _serialize_datetimes(self, value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=ZoneInfo("UTC"))
|
||||
return value.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||
return value
|
||||
|
||||
def serializable_dict(self, **kwargs):
|
||||
"""Return a dict which contains only serializable fields."""
|
||||
@@ -273,7 +279,7 @@ auth_settings = AuthConfig()
|
||||
|
||||
|
||||
# src.config
|
||||
from pydantic import PostgresDsn, RedisDsn, model_validator
|
||||
from pydantic import PostgresDsn, RedisDsn
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from src.constants import Environment
|
||||
@@ -345,7 +351,8 @@ Dependencies can use other dependencies and avoid code repetition for similar lo
|
||||
```python
|
||||
# dependencies.py
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
import jwt # PyJWT
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
async def valid_post_id(post_id: UUID4) -> dict[str, Any]:
|
||||
post = await service.get_by_id(post_id)
|
||||
@@ -360,7 +367,7 @@ async def parse_jwt_data(
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
|
||||
except JWTError:
|
||||
except InvalidTokenError:
|
||||
raise InvalidCredentials()
|
||||
|
||||
return {"user_id": payload["id"]}
|
||||
@@ -397,7 +404,8 @@ but `parse_jwt_data` is called only once, in the very first call.
|
||||
# dependencies.py
|
||||
from fastapi import BackgroundTasks
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
import jwt # PyJWT
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
async def valid_post_id(post_id: UUID4) -> Mapping:
|
||||
post = await service.get_by_id(post_id)
|
||||
@@ -412,7 +420,7 @@ async def parse_jwt_data(
|
||||
) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
|
||||
except JWTError:
|
||||
except InvalidTokenError:
|
||||
raise InvalidCredentials()
|
||||
|
||||
return {"user_id": payload["id"]}
|
||||
@@ -558,6 +566,28 @@ async def call_my_sync_library():
|
||||
await run_in_threadpool(client.make_request, data=my_data)
|
||||
```
|
||||
|
||||
### BackgroundTasks vs a real task queue
|
||||
FastAPI's `BackgroundTasks` is convenient — and a footgun if you misuse it. Tasks run **after the response is sent, in the same worker process**. If the worker dies, the task is gone. There is no retry, no visibility, no scheduling.
|
||||
|
||||
| Use `BackgroundTasks` when… | Use Celery / Arq / RQ when… |
|
||||
|--------------------------------------------------|-------------------------------------------------|
|
||||
| Task is short (< 1 second) | Task takes seconds to minutes |
|
||||
| Failure can be silently dropped | You need retries or dead-letter handling |
|
||||
| It's in-process (send email, log a row) | It's CPU-heavy or needs a separate worker pool |
|
||||
| You don't need scheduling or rate limiting | You need cron, ETA, or rate limiting |
|
||||
|
||||
```python
|
||||
from fastapi import BackgroundTasks
|
||||
|
||||
|
||||
@router.post("/signup")
|
||||
async def signup(data: SignupIn, bg: BackgroundTasks):
|
||||
user = await service.create_user(data)
|
||||
bg.add_task(send_welcome_email, user.email) # fire-and-forget, in-process
|
||||
return user
|
||||
```
|
||||
Rule of thumb: if you'd page someone when the task is lost, it doesn't belong in `BackgroundTasks`.
|
||||
|
||||
### ValueErrors might become Pydantic ValidationError
|
||||
If you raise a `ValueError` in a Pydantic schema that's used directly in a request body, FastAPI will return a detailed validation error response to users.
|
||||
```python
|
||||
@@ -686,6 +716,8 @@ Being consistent with names is important. Some rules we followed:
|
||||
- Usually, database handles data processing much faster and cleaner than CPython will ever do.
|
||||
- It's preferable to do all the complex joins and simple data manipulations with SQL.
|
||||
- It's preferable to aggregate JSONs in DB for responses with nested objects.
|
||||
|
||||
For new projects, reach for SQLAlchemy 2.0's async API (`AsyncSession`, `async_sessionmaker`). The example below uses `encode/databases` for brevity — the SQL-first principle is what matters; the client is interchangeable.
|
||||
```python
|
||||
# src.posts.service
|
||||
from typing import Any
|
||||
@@ -769,28 +801,50 @@ async def get_creator_posts(creator: dict[str, Any] = Depends(valid_creator_id))
|
||||
```
|
||||
### Set tests client async from day 0
|
||||
Writing integration tests with DB will likely lead to messed up event loop errors in the future.
|
||||
Set the async test client immediately, e.g. [httpx](https://github.com/encode/starlette/issues/652)
|
||||
Set the async test client immediately, using [httpx](https://www.python-httpx.org/) with `ASGITransport`. Don't reach for `async_asgi_testclient` — it's unmaintained.
|
||||
|
||||
```python
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from async_asgi_testclient import TestClient
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from src.main import app # inited FastAPI app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[TestClient, None]:
|
||||
host, port = "127.0.0.1", "9000"
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=(host, port)), base_url="http://test") as client:
|
||||
yield client
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_post(client: TestClient):
|
||||
async def test_create_post(client: AsyncClient):
|
||||
resp = await client.post("/posts")
|
||||
|
||||
assert resp.status_code == 201
|
||||
```
|
||||
|
||||
#### Override dependencies in tests
|
||||
Don't monkeypatch internals. FastAPI's `dependency_overrides` lets you swap any dependency for a test fake — auth, external clients, anything you don't want hitting the network.
|
||||
|
||||
```python
|
||||
from src.auth.dependencies import parse_jwt_data
|
||||
from src.main import app
|
||||
|
||||
|
||||
def fake_user():
|
||||
return {"user_id": "00000000-0000-0000-0000-000000000001"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_auth():
|
||||
app.dependency_overrides[parse_jwt_data] = fake_user
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
```
|
||||
|
||||
Unless you have synchronous database connections (excuse me?) or don't plan to write integration tests.
|
||||
|
||||
### Use ruff
|
||||
|
||||
Reference in New Issue
Block a user