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
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: api-security-python
|
|
3
|
-
version:
|
|
4
|
-
description:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "Python (FastAPI / Django / Flask) overlay on top of _shared/security-baseline v2 (OWASP Top 10:2025). Production-grade hardening: security headers via Starlette middleware and SECURE_* settings, strict CORS allowlist, rate limiting (slowapi / django-ratelimit), HttpOnly+Secure+SameSite cookies, JWT with algorithms=[ALG] pinning + jti for revocation using PyJWT (python-jose is unmaintained as of 2025), CSRF built-in for Django and double-submit cookie for FastAPI, Pydantic V2 extra='forbid' against mass-assignment, file-upload magic-byte sniffing, Argon2id passwords, parameterised SQL/ORM. Cross-references 2025-A03 (Software Supply Chain Failures) and 2025-A10 (Mishandling Exceptional Conditions)."
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
# API Security — Python
|
|
7
|
+
# API Security — Python (FastAPI / Django / Flask)
|
|
8
8
|
|
|
9
9
|
**ALWAYS invoke when building API endpoints, auth flows, or admin actions.**
|
|
10
10
|
|
|
11
|
-
>
|
|
11
|
+
> Stack-specific overlay on top of `_shared/skills/security-baseline` v2 (OWASP Top 10:2025). The shared skill defines §A01–§A10 anchors; this one wires the Python equivalents.
|
|
12
12
|
|
|
13
13
|
## Layered Defense
|
|
14
14
|
|
|
@@ -136,13 +136,16 @@ response.set_cookie(
|
|
|
136
136
|
|
|
137
137
|
## 5. JWT / OAuth2 — FastAPI
|
|
138
138
|
|
|
139
|
+
> **Library choice (2026):** Use **PyJWT** (`pyjwt[crypto]`) or **authlib** for OAuth2/OIDC flows. `python-jose` has been effectively unmaintained since 2024 and should be removed from dependencies. For the resource-server side that just verifies tokens, PyJWT is enough.
|
|
140
|
+
|
|
139
141
|
```python
|
|
140
142
|
from datetime import datetime, timedelta, timezone
|
|
141
|
-
from jose import jwt, JWTError
|
|
142
143
|
import os, uuid
|
|
144
|
+
import jwt
|
|
145
|
+
from jwt import InvalidTokenError
|
|
143
146
|
|
|
144
147
|
SECRET = os.environ["JWT_SECRET"]
|
|
145
|
-
ALG = "HS256"
|
|
148
|
+
ALG = "HS256" # use RS256/EdDSA when verifying tokens minted elsewhere
|
|
146
149
|
|
|
147
150
|
def issue_access_token(user_id: str, role: str) -> str:
|
|
148
151
|
now = datetime.now(timezone.utc)
|
|
@@ -151,8 +154,11 @@ def issue_access_token(user_id: str, role: str) -> str:
|
|
|
151
154
|
"sub": user_id,
|
|
152
155
|
"role": role,
|
|
153
156
|
"iat": now,
|
|
157
|
+
"nbf": now,
|
|
154
158
|
"exp": now + timedelta(minutes=15),
|
|
155
159
|
"jti": str(uuid.uuid4()),
|
|
160
|
+
"iss": os.environ["JWT_ISSUER"],
|
|
161
|
+
"aud": os.environ["JWT_AUDIENCE"],
|
|
156
162
|
},
|
|
157
163
|
SECRET,
|
|
158
164
|
algorithm=ALG,
|
|
@@ -160,9 +166,18 @@ def issue_access_token(user_id: str, role: str) -> str:
|
|
|
160
166
|
|
|
161
167
|
async def current_user(token: str = Depends(oauth2_scheme)) -> User:
|
|
162
168
|
try:
|
|
163
|
-
payload = jwt.decode(
|
|
164
|
-
|
|
169
|
+
payload = jwt.decode(
|
|
170
|
+
token,
|
|
171
|
+
SECRET,
|
|
172
|
+
algorithms=[ALG], # pin — never accept "alg: none"
|
|
173
|
+
audience=os.environ["JWT_AUDIENCE"],
|
|
174
|
+
issuer=os.environ["JWT_ISSUER"],
|
|
175
|
+
options={"require": ["exp", "iat", "sub", "jti"]},
|
|
176
|
+
)
|
|
177
|
+
except InvalidTokenError:
|
|
165
178
|
raise HTTPException(401, "Invalid token")
|
|
179
|
+
if await is_revoked(payload["jti"]): # check revocation list (Redis set)
|
|
180
|
+
raise HTTPException(401, "Token revoked")
|
|
166
181
|
user = await User.get_or_none(id=payload["sub"])
|
|
167
182
|
if not user:
|
|
168
183
|
raise HTTPException(401, "User not found")
|
|
@@ -170,9 +185,11 @@ async def current_user(token: str = Depends(oauth2_scheme)) -> User:
|
|
|
170
185
|
```
|
|
171
186
|
|
|
172
187
|
Rules:
|
|
173
|
-
- Access tokens
|
|
174
|
-
- Pin `algorithms=[ALG]`. Never accept `alg: none
|
|
175
|
-
-
|
|
188
|
+
- Access tokens ≤ 15 min. Refresh tokens: rotate on use, store **hash** in DB, revocable.
|
|
189
|
+
- Pin `algorithms=[ALG]`. Never accept `alg: none` or omit the kwarg (confusion attack).
|
|
190
|
+
- Always validate `aud` and `iss` when present.
|
|
191
|
+
- Include `jti` and check a revocation set for sensitive scopes.
|
|
192
|
+
- Store JWTs in **HttpOnly+Secure+SameSite cookies**, not `localStorage` (XSS exfiltration).
|
|
176
193
|
|
|
177
194
|
---
|
|
178
195
|
|
|
@@ -280,6 +297,91 @@ user = await User.get(id=user_id) # Tortoise / SQLAlchemy / Dj
|
|
|
280
297
|
|
|
281
298
|
---
|
|
282
299
|
|
|
300
|
+
## 11. Outbound calls — SSRF guard (OWASP 2025 still relevant)
|
|
301
|
+
|
|
302
|
+
SSRF was demoted from a top-level OWASP category in the 2025 list but remains in scope. Any time you fetch a URL the user controls (preview cards, webhooks, image proxies), validate the destination:
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
import ipaddress
|
|
306
|
+
import socket
|
|
307
|
+
from urllib.parse import urlparse
|
|
308
|
+
import httpx
|
|
309
|
+
|
|
310
|
+
PRIVATE = ipaddress.collapse_addresses([
|
|
311
|
+
ipaddress.ip_network("10.0.0.0/8"),
|
|
312
|
+
ipaddress.ip_network("172.16.0.0/12"),
|
|
313
|
+
ipaddress.ip_network("192.168.0.0/16"),
|
|
314
|
+
ipaddress.ip_network("169.254.0.0/16"), # link-local + AWS metadata
|
|
315
|
+
ipaddress.ip_network("127.0.0.0/8"),
|
|
316
|
+
ipaddress.ip_network("::1/128"),
|
|
317
|
+
])
|
|
318
|
+
|
|
319
|
+
def safe_url(url: str) -> str:
|
|
320
|
+
p = urlparse(url)
|
|
321
|
+
if p.scheme not in {"http", "https"}:
|
|
322
|
+
raise ValueError("scheme")
|
|
323
|
+
host = p.hostname
|
|
324
|
+
if not host:
|
|
325
|
+
raise ValueError("host")
|
|
326
|
+
for fam, _, _, _, sockaddr in socket.getaddrinfo(host, None):
|
|
327
|
+
ip = ipaddress.ip_address(sockaddr[0])
|
|
328
|
+
if any(ip in net for net in PRIVATE):
|
|
329
|
+
raise ValueError("private")
|
|
330
|
+
return url
|
|
331
|
+
|
|
332
|
+
async def fetch_user_url(url: str):
|
|
333
|
+
safe_url(url)
|
|
334
|
+
async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as c:
|
|
335
|
+
return await c.get(url)
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Disable redirect-following or re-validate every hop — otherwise an attacker can redirect from a public IP to `169.254.169.254` (cloud metadata).
|
|
339
|
+
|
|
340
|
+
## 12. OWASP 2025 deltas — Python specifics
|
|
341
|
+
|
|
342
|
+
### §A03 — Software Supply Chain Failures (NEW in 2025)
|
|
343
|
+
|
|
344
|
+
```toml
|
|
345
|
+
# pyproject.toml — pin everything; uv produces a deterministic lockfile
|
|
346
|
+
[project]
|
|
347
|
+
dependencies = ["fastapi>=0.115,<0.116", "pydantic>=2.6,<3"]
|
|
348
|
+
|
|
349
|
+
[tool.uv]
|
|
350
|
+
dev-dependencies = ["pip-audit>=2.7", "ruff>=0.5"]
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
# Lock + verify in CI
|
|
355
|
+
uv lock --check # fails if lockfile drifted
|
|
356
|
+
uv export --format requirements-txt --no-dev | pip-audit -r /dev/stdin --strict
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
- Use `uv` (or Poetry) to produce a deterministic lockfile; never `pip install` without one in production.
|
|
360
|
+
- Run `pip-audit` (PyPA) **and** subscribe to GHSA advisories for your deps.
|
|
361
|
+
- Pin GitHub Actions by SHA, not tag — see `_shared/skills/secrets-management`.
|
|
362
|
+
|
|
363
|
+
### §A10 — Mishandling Exceptional Conditions (NEW in 2025)
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
# WRONG — silently swallows everything, including programming bugs
|
|
367
|
+
try:
|
|
368
|
+
user = await get_user(id)
|
|
369
|
+
except Exception:
|
|
370
|
+
user = None
|
|
371
|
+
|
|
372
|
+
# CORRECT — narrow except + log + propagate or translate
|
|
373
|
+
try:
|
|
374
|
+
user = await get_user(id)
|
|
375
|
+
except UserNotFoundError:
|
|
376
|
+
raise HTTPException(404, "user not found")
|
|
377
|
+
except DatabaseUnavailableError as e:
|
|
378
|
+
logger.exception("db-down", extra={"user_id": id})
|
|
379
|
+
raise HTTPException(503, "service unavailable") from e
|
|
380
|
+
# Programming errors (KeyError, TypeError) are NOT caught — let the global handler 500 + log
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Bare `except Exception:` (or worse, `except:`) is now an explicit OWASP pattern to flag. Use `logger.exception()` so the stack trace lands in logs, return a **generic** message to the client, never the original exception text.
|
|
384
|
+
|
|
283
385
|
## Endpoint Checklist
|
|
284
386
|
|
|
285
387
|
- [ ] `Depends(current_user)` for protected routes
|
|
@@ -306,7 +408,8 @@ user = await User.get(id=user_id) # Tortoise / SQLAlchemy / Dj
|
|
|
306
408
|
|
|
307
409
|
## See Also
|
|
308
410
|
|
|
309
|
-
- `security-baseline` — OWASP Top 10
|
|
310
|
-
- `secrets-management` —
|
|
311
|
-
- `
|
|
312
|
-
- `
|
|
411
|
+
- `_shared/skills/security-baseline` v2 — OWASP Top 10:2025 (§A01–§A10 anchors)
|
|
412
|
+
- `_shared/skills/secrets-management` v2 — OIDC federation, gitleaks 3-layer, SOPS+age
|
|
413
|
+
- `_shared/skills/observability` v2 — structured logs without PII, GenAI semconv 1.41+
|
|
414
|
+
- `pydantic-validation` v2 — `extra="forbid"` against mass-assignment
|
|
415
|
+
- `fastapi-patterns` v2 — lifespan, DI, exception handlers
|
|
@@ -1,103 +1,207 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: async-patterns
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "Async Python concurrency patterns for Python 3.13 / 3.14. Covers structured concurrency with `asyncio.TaskGroup` (3.11+), the new `asyncio.timeout()` context manager (3.11+, replaces `wait_for`), `asyncio.Runner` for one-shot scripts, AnyIO for backend-agnostic code (asyncio + trio), httpx.AsyncClient connection pooling, semaphore-based rate limiting, producer/consumer with backpressure, free-threaded mode (PEP 779 — officially supported in 3.14) for true CPU parallelism. Invoke when writing any async/await code, fan-out/fan-in flows, or deciding between threads and async."
|
|
4
5
|
---
|
|
5
6
|
|
|
6
|
-
# Async
|
|
7
|
+
# Async Patterns — Python 3.13/3.14
|
|
7
8
|
|
|
8
|
-
**ALWAYS invoke when writing async/await code or
|
|
9
|
+
**ALWAYS invoke when writing async/await code, fan-out flows, or rate-limited callers.**
|
|
9
10
|
|
|
10
|
-
##
|
|
11
|
+
## Decision Map — when to reach for what
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
Job kind?
|
|
15
|
+
├── I/O-bound (HTTP, DB, file, queue) → asyncio (one event loop)
|
|
16
|
+
├── CPU-bound, single-threaded → just call it; don't async
|
|
17
|
+
├── CPU-bound, multi-thread, GIL build → multiprocessing (workaround for GIL)
|
|
18
|
+
└── CPU-bound, multi-thread, 3.14 free-th → threading on python3.14t (real parallelism)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Free-threaded mode is now **officially supported** in Python 3.14 (PEP 779). Ships as a separate binary (`python3.14t`); per-thread overhead ~10–15% slower than the GIL build, so only switch when you actually need parallel CPU.
|
|
22
|
+
|
|
23
|
+
## Structured Concurrency — `TaskGroup` over `gather`
|
|
11
24
|
|
|
12
25
|
```python
|
|
13
26
|
import asyncio
|
|
14
27
|
|
|
15
|
-
# Gather — run multiple coroutines concurrently
|
|
16
|
-
async def fetch_all():
|
|
17
|
-
results = await asyncio.gather(
|
|
18
|
-
fetch_users(),
|
|
19
|
-
fetch_products(),
|
|
20
|
-
fetch_orders(),
|
|
21
|
-
)
|
|
22
|
-
users, products, orders = results
|
|
23
|
-
|
|
24
|
-
# TaskGroup (Python 3.11+) — structured concurrency with proper cancellation
|
|
25
28
|
async def fetch_all_safe():
|
|
26
29
|
async with asyncio.TaskGroup() as tg:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# If
|
|
30
|
+
users = tg.create_task(fetch_users())
|
|
31
|
+
products = tg.create_task(fetch_products())
|
|
32
|
+
orders = tg.create_task(fetch_orders())
|
|
33
|
+
# If ANY task raises, all siblings are cancelled, exception(s) re-raised as ExceptionGroup
|
|
34
|
+
return users.result(), products.result(), orders.result()
|
|
31
35
|
```
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
`TaskGroup` (3.11+) is the modern default. It propagates exceptions as `ExceptionGroup` and cancels siblings on failure — exactly what you want. `asyncio.gather(...)` only swallows errors when you pass `return_exceptions=True`, which is rarely correct.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
# OK only when partial failure is the desired semantics
|
|
41
|
+
results = await asyncio.gather(*coros, return_exceptions=True)
|
|
42
|
+
ok = [r for r in results if not isinstance(r, Exception)]
|
|
43
|
+
errors = [r for r in results if isinstance(r, Exception)]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Timeouts — `asyncio.timeout()` over `wait_for`
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# 3.11+ — context manager, composes inside TaskGroup, supports deadlines
|
|
50
|
+
async def fetch_with_budget():
|
|
51
|
+
try:
|
|
52
|
+
async with asyncio.timeout(5):
|
|
53
|
+
return await fetch_data()
|
|
54
|
+
except TimeoutError:
|
|
55
|
+
return default_value
|
|
56
|
+
|
|
57
|
+
# Deadline-style — useful for chained operations sharing a budget
|
|
58
|
+
async def call_with_deadline(deadline_ts: float):
|
|
59
|
+
async with asyncio.timeout_at(deadline_ts):
|
|
60
|
+
await step1()
|
|
61
|
+
await step2()
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Old `asyncio.wait_for(coro, 5)` still works but doesn't compose well inside `TaskGroup` — prefer `timeout()`.
|
|
65
|
+
|
|
66
|
+
## HTTP Client — httpx (reuse the client)
|
|
34
67
|
|
|
35
68
|
```python
|
|
36
69
|
import httpx
|
|
37
70
|
|
|
38
|
-
# REUSE
|
|
39
|
-
async def fetch_data():
|
|
40
|
-
async with httpx.AsyncClient(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
71
|
+
# REUSE — connection pooling, http/2, keepalive
|
|
72
|
+
async def fetch_data(url: str) -> dict:
|
|
73
|
+
async with httpx.AsyncClient(
|
|
74
|
+
timeout=httpx.Timeout(connect=5, read=10, write=10, pool=5),
|
|
75
|
+
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
|
|
76
|
+
http2=True,
|
|
77
|
+
) as client:
|
|
78
|
+
r = await client.get(url)
|
|
79
|
+
r.raise_for_status()
|
|
80
|
+
return r.json()
|
|
81
|
+
|
|
82
|
+
# In FastAPI — store on app.state in lifespan, reuse for the whole process
|
|
83
|
+
async def lifespan(app):
|
|
84
|
+
app.state.http = httpx.AsyncClient(timeout=10.0)
|
|
85
|
+
yield
|
|
86
|
+
await app.state.http.aclose()
|
|
51
87
|
```
|
|
52
88
|
|
|
53
|
-
|
|
89
|
+
`httpx.AsyncClient` opened per-request is **wrong** — you lose the pool, pay TCP+TLS handshake every call. Reuse one per process.
|
|
90
|
+
|
|
91
|
+
## Concurrent calls with bounded fan-out
|
|
54
92
|
|
|
55
93
|
```python
|
|
56
|
-
|
|
57
|
-
sem = asyncio.Semaphore(10)
|
|
94
|
+
async def fetch_many(urls: list[str], client: httpx.AsyncClient) -> list[dict]:
|
|
95
|
+
sem = asyncio.Semaphore(10) # max 10 in flight at once
|
|
58
96
|
|
|
59
|
-
async def
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
97
|
+
async def one(url):
|
|
98
|
+
async with sem:
|
|
99
|
+
r = await client.get(url)
|
|
100
|
+
r.raise_for_status()
|
|
101
|
+
return r.json()
|
|
102
|
+
|
|
103
|
+
async with asyncio.TaskGroup() as tg:
|
|
104
|
+
tasks = [tg.create_task(one(u)) for u in urls]
|
|
105
|
+
return [t.result() for t in tasks]
|
|
63
106
|
```
|
|
64
107
|
|
|
65
|
-
|
|
108
|
+
Always cap fan-out — unbounded `gather`/`TaskGroup` over thousands of URLs will exhaust file descriptors, TLS sessions, or the remote rate limit.
|
|
109
|
+
|
|
110
|
+
## Producer / Consumer — with backpressure
|
|
66
111
|
|
|
67
112
|
```python
|
|
68
|
-
async def producer(queue: asyncio.Queue):
|
|
69
|
-
for item in
|
|
70
|
-
await queue.put(item)
|
|
71
|
-
|
|
113
|
+
async def producer(queue: asyncio.Queue, items):
|
|
114
|
+
for item in items:
|
|
115
|
+
await queue.put(item) # blocks if queue full → backpressure
|
|
116
|
+
for _ in range(N_CONSUMERS):
|
|
117
|
+
await queue.put(None) # one sentinel per consumer
|
|
72
118
|
|
|
73
119
|
async def consumer(queue: asyncio.Queue):
|
|
74
120
|
while True:
|
|
75
121
|
item = await queue.get()
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
122
|
+
try:
|
|
123
|
+
if item is None:
|
|
124
|
+
return
|
|
125
|
+
await process(item)
|
|
126
|
+
finally:
|
|
127
|
+
queue.task_done()
|
|
128
|
+
|
|
129
|
+
async def main():
|
|
130
|
+
queue = asyncio.Queue(maxsize=100) # the cap is the backpressure
|
|
131
|
+
async with asyncio.TaskGroup() as tg:
|
|
132
|
+
tg.create_task(producer(queue, items))
|
|
133
|
+
for _ in range(N_CONSUMERS):
|
|
134
|
+
tg.create_task(consumer(queue))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Entry-points — `asyncio.Runner` (3.11+)
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
import asyncio
|
|
141
|
+
|
|
142
|
+
async def main(): ...
|
|
143
|
+
|
|
144
|
+
# Modern — explicit lifecycle, works repeatedly, handles uvloop substitution
|
|
145
|
+
with asyncio.Runner() as runner:
|
|
146
|
+
runner.run(main())
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`asyncio.run(main())` is fine for one-shot scripts; `Runner` is better when you need to call multiple top-level coroutines or swap event loops (uvloop in production).
|
|
150
|
+
|
|
151
|
+
## AnyIO — when the library must support both backends
|
|
152
|
+
|
|
153
|
+
If you ship a library used by both asyncio and trio consumers, write against `anyio`:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
import anyio
|
|
157
|
+
from anyio import create_task_group, fail_after
|
|
158
|
+
|
|
159
|
+
async def fetch(url): ...
|
|
80
160
|
|
|
81
161
|
async def main():
|
|
82
|
-
|
|
83
|
-
|
|
162
|
+
async with create_task_group() as tg:
|
|
163
|
+
with fail_after(5): # AnyIO's timeout
|
|
164
|
+
for url in urls:
|
|
165
|
+
tg.start_soon(fetch, url)
|
|
166
|
+
|
|
167
|
+
anyio.run(main) # works on asyncio AND trio
|
|
84
168
|
```
|
|
85
169
|
|
|
86
|
-
##
|
|
170
|
+
## Threads + async — the right escape hatch
|
|
171
|
+
|
|
172
|
+
Sync code that you can't replace (legacy SDKs, blocking C extensions) must run **off the event loop**:
|
|
87
173
|
|
|
88
174
|
```python
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
175
|
+
import asyncio
|
|
176
|
+
|
|
177
|
+
# Run blocking CPU/IO in the default thread pool
|
|
178
|
+
result = await asyncio.to_thread(blocking_function, arg1, arg2)
|
|
179
|
+
|
|
180
|
+
# Bigger pool for many concurrent blocking calls
|
|
181
|
+
loop = asyncio.get_running_loop()
|
|
182
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=32) as pool:
|
|
183
|
+
result = await loop.run_in_executor(pool, blocking_function, arg)
|
|
95
184
|
```
|
|
96
185
|
|
|
186
|
+
For pure-CPU work on the standard GIL build, prefer `multiprocessing` or `concurrent.futures.ProcessPoolExecutor` — threads won't go faster.
|
|
187
|
+
|
|
97
188
|
## FORBIDDEN
|
|
98
189
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
190
|
+
| Pattern | Why |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `time.sleep(x)` inside `async def` | Blocks the entire event loop — use `await asyncio.sleep(x)` |
|
|
193
|
+
| `requests.get(...)` inside `async def` | Blocks event loop — use `httpx.AsyncClient` |
|
|
194
|
+
| Bare `asyncio.gather(...)` without error handling | Swallows exceptions of completed tasks; use `TaskGroup` |
|
|
195
|
+
| `asyncio.wait_for` instead of `asyncio.timeout()` | Older API, doesn't compose inside TaskGroup |
|
|
196
|
+
| New `httpx.AsyncClient()` per request | Loses pool/keepalive — reuse |
|
|
197
|
+
| Unbounded `TaskGroup` over user-provided list | DoS yourself — use Semaphore + cap |
|
|
198
|
+
| Mixing `asyncio` and `trio` directly | Use `anyio` to bridge or pick one |
|
|
199
|
+
| `asyncio.run` inside an existing loop | RuntimeError — use `asyncio.get_running_loop()` and `await` |
|
|
200
|
+
| Banking on free-threading for an I/O-bound API | asyncio is faster; free-threading buys CPU parallelism only |
|
|
201
|
+
|
|
202
|
+
## See Also
|
|
203
|
+
|
|
204
|
+
- `python-patterns` — async vs sync decision; free-threading rules
|
|
205
|
+
- `fastapi-patterns` — lifespan-managed http client, timeouts in routes
|
|
206
|
+
- `python-performance` — when to actually reach for `python3.14t`
|
|
207
|
+
- `pytest-testing` — testing async code (pytest-asyncio 1.0 + AnyIO)
|
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: django-patterns
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "Django 5.2 LTS (April 2025, supported through April 2028) patterns for full-stack Python. Covers Composite Primary Keys (new in 5.2), `select_related`/`prefetch_related` for N+1 prevention, fat-model/thin-view discipline with custom managers, Django REST Framework viewsets/serializers/permissions, **async views** (5.0+) plus the honest caveat that the ORM remains synchronous — `sync_to_async()` is required when calling ORM from `async def`. Includes `pyproject.toml` + uv setup, migration discipline, and the upgrade timeline (Django 4.2 LTS support ends April 2026 — projects must be on 5.2)."
|
|
4
5
|
---
|
|
5
6
|
|
|
6
|
-
# Django Patterns —
|
|
7
|
+
# Django Patterns — Django 5.2 LTS (2025–2028)
|
|
7
8
|
|
|
8
9
|
**ALWAYS invoke when writing Django models, views, or serializers.**
|
|
9
10
|
|
|
11
|
+
## Version Policy (2026)
|
|
12
|
+
|
|
13
|
+
| Branch | Released | Support ends | Status |
|
|
14
|
+
|---|---|---|---|
|
|
15
|
+
| **5.2 LTS** | April 2025 | **April 2028** | ✅ Use this for new projects |
|
|
16
|
+
| 5.1 | Aug 2024 | Apr 2025 | EOL |
|
|
17
|
+
| 4.2 LTS | Apr 2023 | **April 2026** | ⚠️ Plan upgrade NOW if still on it |
|
|
18
|
+
|
|
19
|
+
Django 5.2 brings **Composite Primary Keys**, automatic model imports in `manage.py shell`, and simplified `BoundField` overrides. It's the LTS to standardise on for the next 2 years.
|
|
20
|
+
|
|
21
|
+
## Setup with uv
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv init my-django-app && cd my-django-app
|
|
25
|
+
uv add django>=5.2 djangorestframework psycopg[binary] django-environ
|
|
26
|
+
uv add --dev pytest pytest-django ruff mypy django-stubs
|
|
27
|
+
|
|
28
|
+
uv run django-admin startproject config .
|
|
29
|
+
uv run python manage.py startapp users
|
|
30
|
+
```
|
|
31
|
+
|
|
10
32
|
## Model Design (Fat Models, Thin Views)
|
|
11
33
|
|
|
12
34
|
```python
|
|
@@ -77,16 +99,43 @@ class UserViewSet(viewsets.ModelViewSet):
|
|
|
77
99
|
pagination_class = PageNumberPagination
|
|
78
100
|
```
|
|
79
101
|
|
|
80
|
-
##
|
|
102
|
+
## Composite Primary Keys (new in 5.2)
|
|
81
103
|
|
|
82
104
|
```python
|
|
83
|
-
|
|
105
|
+
from django.db.models import CompositePrimaryKey
|
|
106
|
+
|
|
107
|
+
class OrderLine(models.Model):
|
|
108
|
+
pk = CompositePrimaryKey("order", "product")
|
|
109
|
+
order = models.ForeignKey(Order, on_delete=models.CASCADE)
|
|
110
|
+
product = models.ForeignKey(Product, on_delete=models.PROTECT)
|
|
111
|
+
qty = models.PositiveIntegerField()
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Use composite PKs for join tables that have no business identity of their own — saves an autoincrement column and an index.
|
|
115
|
+
|
|
116
|
+
## Async Views (5.0+) — and the ORM caveat
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
import httpx
|
|
120
|
+
from django.http import JsonResponse
|
|
121
|
+
from asgiref.sync import sync_to_async
|
|
122
|
+
|
|
123
|
+
# OK — purely external I/O, no ORM
|
|
84
124
|
async def fetch_external_data(request):
|
|
85
|
-
async with httpx.AsyncClient() as client:
|
|
86
|
-
response = await client.get(
|
|
125
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
126
|
+
response = await client.get("https://api.example.com/data")
|
|
87
127
|
return JsonResponse(response.json())
|
|
128
|
+
|
|
129
|
+
# ORM is still SYNCHRONOUS — you MUST adapt it
|
|
130
|
+
async def list_users(request):
|
|
131
|
+
users = await sync_to_async(list, thread_sensitive=True)(
|
|
132
|
+
User.objects.filter(is_active=True)[:50]
|
|
133
|
+
)
|
|
134
|
+
return JsonResponse({"users": [u.email for u in users]})
|
|
88
135
|
```
|
|
89
136
|
|
|
137
|
+
Django itself notes: *"We're still working on async support for the ORM and other parts of Django."* Until that lands, calling ORM in `async def` without `sync_to_async()` will either deadlock or raise `SynchronousOnlyOperation`. **For DB-heavy apps in 2026, sync views remain the default**; reach for async only when most of the work is external I/O.
|
|
138
|
+
|
|
90
139
|
## Migrations Best Practices
|
|
91
140
|
|
|
92
141
|
```bash
|
|
@@ -97,10 +146,52 @@ python manage.py migrate
|
|
|
97
146
|
python manage.py makemigrations --check --dry-run
|
|
98
147
|
```
|
|
99
148
|
|
|
149
|
+
## Settings — env-driven, no surprises
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
# config/settings.py
|
|
153
|
+
import environ
|
|
154
|
+
from pathlib import Path
|
|
155
|
+
|
|
156
|
+
env = environ.Env(DEBUG=(bool, False))
|
|
157
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
158
|
+
environ.Env.read_env(BASE_DIR / ".env")
|
|
159
|
+
|
|
160
|
+
SECRET_KEY = env("SECRET_KEY")
|
|
161
|
+
DEBUG = env("DEBUG")
|
|
162
|
+
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[])
|
|
163
|
+
|
|
164
|
+
DATABASES = {"default": env.db()} # parses DATABASE_URL
|
|
165
|
+
CACHES = {"default": env.cache()} # parses CACHE_URL
|
|
166
|
+
|
|
167
|
+
# Security defaults — see api-security-python
|
|
168
|
+
SECURE_HSTS_SECONDS = 31_536_000
|
|
169
|
+
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
|
170
|
+
SECURE_HSTS_PRELOAD = True
|
|
171
|
+
SESSION_COOKIE_SECURE = True
|
|
172
|
+
SESSION_COOKIE_HTTPONLY = True
|
|
173
|
+
CSRF_COOKIE_SECURE = True
|
|
174
|
+
```
|
|
175
|
+
|
|
100
176
|
## FORBIDDEN
|
|
101
177
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
178
|
+
| Anti-pattern | Reason |
|
|
179
|
+
|---|---|
|
|
180
|
+
| Logic in views | Fat models, thin views — keeps logic testable & reusable |
|
|
181
|
+
| N+1 queries | Always `select_related` (FK) / `prefetch_related` (M2M) |
|
|
182
|
+
| `Model.objects.all()` without `.only()`/pagination | Loads whole table into memory |
|
|
183
|
+
| Raw SQL with f-strings | SQL injection — use ORM or `params=[...]` |
|
|
184
|
+
| Skipping migrations | `makemigrations --check --dry-run` in CI |
|
|
185
|
+
| ORM in `async def` without `sync_to_async()` | `SynchronousOnlyOperation` / deadlock |
|
|
186
|
+
| Disabling `CsrfViewMiddleware` globally | Wide-open CSRF |
|
|
187
|
+
| Plain `pip` for new projects | Use `uv` |
|
|
188
|
+
| Staying on Django 4.2 LTS past April 2026 | Out of security support |
|
|
189
|
+
| New project on Django < 5.2 | 5.2 is the current LTS — start here |
|
|
190
|
+
|
|
191
|
+
## See Also
|
|
192
|
+
|
|
193
|
+
- `api-security-python` — Django settings checklist (HSTS, CSRF, sessions, CORS)
|
|
194
|
+
- `pydantic-validation` — DRF-Spectacular + Pydantic for OpenAPI (when DRF serializers aren't enough)
|
|
195
|
+
- `pytest-testing` — `pytest-django` setup
|
|
196
|
+
- `_shared/skills/postgres-patterns` — `uuidv7()`, virtual generated columns (PG18) usable from Django
|
|
197
|
+
- `_shared/skills/observability` — Django + structlog + OpenTelemetry
|