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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-vibing-stacks",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "AI-powered multi-stack dev workflow for Claude Code. Supports PHP, Node.js, Python and more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  name: api-security-python
3
- version: 1.0.0
4
- description: Production-grade API hardening for Python (FastAPI, Django, Flask). Rate limit, CORS, JWT, secure cookies, CSRF, OAuth2.
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
- > Pair this with `security-baseline` for OWASP Top 10. This skill is stack-specific hardening.
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(token, SECRET, algorithms=[ALG]) # pin algorithm
164
- except JWTError:
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: ≤ 15 min. Refresh tokens: rotate on use, store hash in DB, revocable.
174
- - Pin `algorithms=[ALG]`. Never accept `alg: none`.
175
- - Include `jti` for revocation lists.
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` — env vars, rotation
311
- - `pydantic-validation` — schema patterns
312
- - `observability` — structured logs without PII
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: 1.0.0
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 Python Patterns — Concurrency & Performance
7
+ # Async Patterns — Python 3.13/3.14
7
8
 
8
- **ALWAYS invoke when writing async/await code or concurrent operations.**
9
+ **ALWAYS invoke when writing async/await code, fan-out flows, or rate-limited callers.**
9
10
 
10
- ## Core: asyncio.gather vs TaskGroup
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
- t1 = tg.create_task(fetch_users())
28
- t2 = tg.create_task(fetch_products())
29
- return t1.result(), t2.result()
30
- # If any task fails, ALL are cancelled
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
- ## HTTP Client (httpx)
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 client (connection pooling)
39
- async def fetch_data():
40
- async with httpx.AsyncClient(timeout=10.0) as client:
41
- response = await client.get("https://api.example.com/data")
42
- response.raise_for_status()
43
- return response.json()
44
-
45
- # Concurrent requests
46
- async def fetch_many(urls: list[str]) -> list[dict]:
47
- async with httpx.AsyncClient() as client:
48
- tasks = [client.get(url) for url in urls]
49
- responses = await asyncio.gather(*tasks)
50
- return [r.json() for r in responses]
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
- ## Semaphore (rate limiting)
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
- # Limit concurrent operations
57
- sem = asyncio.Semaphore(10) # Max 10 concurrent
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 fetch_with_limit(url: str):
60
- async with sem:
61
- async with httpx.AsyncClient() as client:
62
- return await client.get(url)
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
- ## Queue Pattern (producer/consumer)
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 data:
70
- await queue.put(item)
71
- await queue.put(None) # Sentinel
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
- if item is None:
77
- break
78
- await process(item)
79
- queue.task_done()
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
- queue = asyncio.Queue(maxsize=100) # Backpressure
83
- await asyncio.gather(producer(queue), consumer(queue))
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
- ## Timeouts
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
- # Always add timeouts to external calls
90
- try:
91
- result = await asyncio.wait_for(fetch_data(), timeout=5.0)
92
- except asyncio.TimeoutError:
93
- logger.warning("External API timed out")
94
- result = default_value
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
- 1. **`time.sleep()` in async code** — use `await asyncio.sleep()`
100
- 2. **Sync HTTP in async context** — use `httpx.AsyncClient`, not `requests`
101
- 3. **No timeout on external calls**always `asyncio.wait_for()` or `httpx.Timeout`
102
- 4. **Creating new client per request**reuse with `async with`
103
- 5. **Bare `asyncio.gather` without error handling** use `return_exceptions=True` or TaskGroup
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: 1.0.0
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 — Full-Stack Python (Django 5+)
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
- ## Async Views (Django 5.0+)
102
+ ## Composite Primary Keys (new in 5.2)
81
103
 
82
104
  ```python
83
- # Use async when calling external APIs or heavy I/O
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('https://api.example.com/data')
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
- 1. **Logic in views** — fat models, thin views
103
- 2. **N+1 queries** — always `select_related`/`prefetch_related`
104
- 3. **`objects.all()` without limit**paginate or `.only()`
105
- 4. **Raw SQL without parameterization** use ORM or `params=[]`
106
- 5. **Skipping migrations** always `makemigrations` after model changes
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