start-vibing-stacks 2.5.1 → 2.7.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/dist/detector.js +5 -2
- package/dist/index.js +16 -2
- package/dist/migrate.d.ts +27 -0
- package/dist/migrate.js +217 -0
- package/dist/scanner.js +91 -0
- package/dist/setup.js +10 -0
- package/package.json +1 -1
- package/stacks/_shared/agents/claude-md-compactor.md +1 -0
- package/stacks/_shared/agents/commit-manager.md +1 -0
- package/stacks/_shared/agents/documenter.md +1 -0
- package/stacks/_shared/agents/domain-updater.md +1 -0
- package/stacks/_shared/agents/research-web.md +1 -0
- package/stacks/_shared/agents/security-auditor.md +168 -0
- package/stacks/_shared/agents/tester.md +1 -0
- package/stacks/_shared/hooks/final-check.ts +205 -0
- package/stacks/_shared/hooks/stop-validator.ts +77 -1
- package/stacks/_shared/skills/accessibility-wcag22/SKILL.md +284 -0
- package/stacks/_shared/skills/ci-pipelines/SKILL.md +166 -0
- package/stacks/_shared/skills/codebase-knowledge/SKILL.md +5 -0
- package/stacks/_shared/skills/database-migrations/SKILL.md +256 -0
- package/stacks/_shared/skills/debugging-patterns/SKILL.md +5 -0
- package/stacks/_shared/skills/docker-patterns/SKILL.md +5 -0
- package/stacks/_shared/skills/docs-tracker/SKILL.md +5 -0
- package/stacks/_shared/skills/error-handling/SKILL.md +335 -0
- package/stacks/_shared/skills/final-check/SKILL.md +74 -37
- package/stacks/_shared/skills/git-workflow/SKILL.md +5 -0
- package/stacks/_shared/skills/hook-development/SKILL.md +5 -0
- package/stacks/_shared/skills/observability/SKILL.md +351 -0
- package/stacks/_shared/skills/performance-patterns/SKILL.md +5 -0
- package/stacks/_shared/skills/playwright-automation/SKILL.md +5 -0
- package/stacks/_shared/skills/quality-gate/SKILL.md +5 -0
- package/stacks/_shared/skills/research-cache/SKILL.md +5 -0
- package/stacks/_shared/skills/secrets-management/SKILL.md +245 -0
- package/stacks/_shared/skills/security-baseline/SKILL.md +202 -0
- package/stacks/_shared/skills/test-coverage/SKILL.md +5 -0
- package/stacks/_shared/skills/ui-ux-audit/SKILL.md +5 -0
- package/stacks/frontend/react/skills/preline-ui/SKILL.md +5 -0
- package/stacks/frontend/react/skills/react-patterns/SKILL.md +5 -0
- package/stacks/frontend/react/skills/react-standards/SKILL.md +5 -0
- package/stacks/frontend/react/skills/react-ui-patterns/SKILL.md +5 -0
- package/stacks/frontend/react/skills/shadcn-ui/SKILL.md +5 -0
- package/stacks/frontend/react/skills/tailwind-patterns/SKILL.md +5 -0
- package/stacks/frontend/react/skills/zod-validation/SKILL.md +5 -0
- package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +5 -0
- package/stacks/frontend/react-inertia/skills/react-standards/SKILL.md +5 -0
- package/stacks/nodejs/skills/api-security-node/SKILL.md +275 -0
- package/stacks/nodejs/skills/bun-runtime/SKILL.md +5 -0
- package/stacks/nodejs/skills/mongoose-patterns/SKILL.md +5 -0
- package/stacks/nodejs/skills/nextjs-app-router/SKILL.md +5 -0
- package/stacks/nodejs/skills/trpc-api/SKILL.md +5 -0
- package/stacks/nodejs/skills/typescript-strict/SKILL.md +5 -0
- package/stacks/nodejs/stack.json +2 -1
- package/stacks/nodejs/workflows/ci.yml +90 -0
- package/stacks/nodejs/workflows/security.yml +45 -0
- package/stacks/php/skills/api-design/SKILL.md +5 -0
- package/stacks/php/skills/api-security/SKILL.md +5 -0
- package/stacks/php/skills/composer-workflow/SKILL.md +5 -0
- package/stacks/php/skills/external-api-patterns/SKILL.md +5 -0
- package/stacks/php/skills/inertia-react/SKILL.md +5 -0
- package/stacks/php/skills/laravel-inertia-i18n/SKILL.md +5 -0
- package/stacks/php/skills/laravel-octane/SKILL.md +5 -0
- package/stacks/php/skills/laravel-patterns/SKILL.md +5 -0
- package/stacks/php/skills/mariadb-octane/SKILL.md +5 -0
- package/stacks/php/skills/php-patterns/SKILL.md +5 -0
- package/stacks/php/skills/phpstan-analysis/SKILL.md +5 -0
- package/stacks/php/skills/phpunit-testing/SKILL.md +5 -0
- package/stacks/php/skills/security-scan-php/SKILL.md +5 -0
- package/stacks/php/workflows/ci.yml +106 -0
- package/stacks/php/workflows/security.yml +36 -0
- package/stacks/python/skills/api-security-python/SKILL.md +312 -0
- package/stacks/python/skills/async-patterns/SKILL.md +5 -0
- package/stacks/python/skills/django-patterns/SKILL.md +5 -0
- package/stacks/python/skills/fastapi-patterns/SKILL.md +5 -0
- package/stacks/python/skills/pydantic-validation/SKILL.md +5 -0
- package/stacks/python/skills/pytest-testing/SKILL.md +5 -0
- package/stacks/python/skills/python-patterns/SKILL.md +26 -5
- package/stacks/python/skills/python-performance/SKILL.md +5 -0
- package/stacks/python/skills/scripting-automation/SKILL.md +260 -0
- package/stacks/python/stack.json +70 -35
- package/stacks/python/workflows/ci.yml +76 -0
- package/stacks/python/workflows/security.yml +56 -0
- package/templates/CLAUDE-python.md +315 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
---
|
|
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.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# API Security — Python
|
|
8
|
+
|
|
9
|
+
**ALWAYS invoke when building API endpoints, auth flows, or admin actions.**
|
|
10
|
+
|
|
11
|
+
> Pair this with `security-baseline` for OWASP Top 10. This skill is stack-specific hardening.
|
|
12
|
+
|
|
13
|
+
## Layered Defense
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Edge (CDN/WAF) → Rate Limit → CORS → Headers → Auth → Authz → Validate → Logic → Encode → Audit
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 1. Security Headers
|
|
22
|
+
|
|
23
|
+
### FastAPI — middleware
|
|
24
|
+
```python
|
|
25
|
+
from fastapi import FastAPI
|
|
26
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
27
|
+
|
|
28
|
+
class SecurityHeaders(BaseHTTPMiddleware):
|
|
29
|
+
async def dispatch(self, request, call_next):
|
|
30
|
+
response = await call_next(request)
|
|
31
|
+
response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains; preload"
|
|
32
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
33
|
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
34
|
+
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
|
35
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
36
|
+
response.headers["Content-Security-Policy"] = (
|
|
37
|
+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
|
38
|
+
"img-src 'self' data: https:; frame-ancestors 'none'"
|
|
39
|
+
)
|
|
40
|
+
return response
|
|
41
|
+
|
|
42
|
+
app = FastAPI()
|
|
43
|
+
app.add_middleware(SecurityHeaders)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Django — `settings.py`
|
|
47
|
+
```python
|
|
48
|
+
SECURE_HSTS_SECONDS = 63072000
|
|
49
|
+
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
|
50
|
+
SECURE_HSTS_PRELOAD = True
|
|
51
|
+
SECURE_CONTENT_TYPE_NOSNIFF = True
|
|
52
|
+
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
|
53
|
+
SECURE_SSL_REDIRECT = True
|
|
54
|
+
SESSION_COOKIE_SECURE = True
|
|
55
|
+
SESSION_COOKIE_HTTPONLY = True
|
|
56
|
+
SESSION_COOKIE_SAMESITE = "Lax"
|
|
57
|
+
CSRF_COOKIE_SECURE = True
|
|
58
|
+
CSRF_COOKIE_HTTPONLY = True
|
|
59
|
+
X_FRAME_OPTIONS = "DENY"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 2. CORS — Strict Allowlist
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
68
|
+
import os
|
|
69
|
+
|
|
70
|
+
ALLOW = [o.strip() for o in os.getenv("CORS_ORIGINS", "").split(",") if o]
|
|
71
|
+
|
|
72
|
+
app.add_middleware(
|
|
73
|
+
CORSMiddleware,
|
|
74
|
+
allow_origins=ALLOW, # explicit list, NEVER ["*"] with credentials
|
|
75
|
+
allow_credentials=True,
|
|
76
|
+
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
|
77
|
+
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token"],
|
|
78
|
+
max_age=600,
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Never** `allow_origins=["*"]` with `allow_credentials=True` — browsers reject and auth silently breaks.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 3. Rate Limiting
|
|
87
|
+
|
|
88
|
+
### FastAPI — `slowapi`
|
|
89
|
+
```python
|
|
90
|
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
|
91
|
+
from slowapi.util import get_remote_address
|
|
92
|
+
from slowapi.errors import RateLimitExceeded
|
|
93
|
+
|
|
94
|
+
limiter = Limiter(key_func=get_remote_address, storage_uri="redis://localhost:6379")
|
|
95
|
+
app.state.limiter = limiter
|
|
96
|
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
97
|
+
|
|
98
|
+
@app.post("/auth/login")
|
|
99
|
+
@limiter.limit("5/15minutes")
|
|
100
|
+
async def login(request: Request, body: LoginIn):
|
|
101
|
+
...
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Django — `django-ratelimit`
|
|
105
|
+
```python
|
|
106
|
+
from django_ratelimit.decorators import ratelimit
|
|
107
|
+
|
|
108
|
+
@ratelimit(key='ip', rate='5/15m', block=True)
|
|
109
|
+
def login_view(request):
|
|
110
|
+
...
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Limits to set:** auth (5/15min), password reset (3/hour), signup (3/hour/IP), generic write (60/min/user).
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## 4. Cookies
|
|
118
|
+
|
|
119
|
+
### FastAPI
|
|
120
|
+
```python
|
|
121
|
+
from fastapi import Response
|
|
122
|
+
|
|
123
|
+
response.set_cookie(
|
|
124
|
+
key="session",
|
|
125
|
+
value=token,
|
|
126
|
+
httponly=True, # JS cannot read
|
|
127
|
+
secure=True, # HTTPS only
|
|
128
|
+
samesite="lax", # 'strict' if no cross-site flows
|
|
129
|
+
max_age=60 * 60 * 24 * 7, # 7d
|
|
130
|
+
path="/",
|
|
131
|
+
domain=os.getenv("COOKIE_DOMAIN"),
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## 5. JWT / OAuth2 — FastAPI
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from datetime import datetime, timedelta, timezone
|
|
141
|
+
from jose import jwt, JWTError
|
|
142
|
+
import os, uuid
|
|
143
|
+
|
|
144
|
+
SECRET = os.environ["JWT_SECRET"]
|
|
145
|
+
ALG = "HS256"
|
|
146
|
+
|
|
147
|
+
def issue_access_token(user_id: str, role: str) -> str:
|
|
148
|
+
now = datetime.now(timezone.utc)
|
|
149
|
+
return jwt.encode(
|
|
150
|
+
{
|
|
151
|
+
"sub": user_id,
|
|
152
|
+
"role": role,
|
|
153
|
+
"iat": now,
|
|
154
|
+
"exp": now + timedelta(minutes=15),
|
|
155
|
+
"jti": str(uuid.uuid4()),
|
|
156
|
+
},
|
|
157
|
+
SECRET,
|
|
158
|
+
algorithm=ALG,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def current_user(token: str = Depends(oauth2_scheme)) -> User:
|
|
162
|
+
try:
|
|
163
|
+
payload = jwt.decode(token, SECRET, algorithms=[ALG]) # pin algorithm
|
|
164
|
+
except JWTError:
|
|
165
|
+
raise HTTPException(401, "Invalid token")
|
|
166
|
+
user = await User.get_or_none(id=payload["sub"])
|
|
167
|
+
if not user:
|
|
168
|
+
raise HTTPException(401, "User not found")
|
|
169
|
+
return user
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
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.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## 6. CSRF
|
|
180
|
+
|
|
181
|
+
### Django
|
|
182
|
+
Built-in: `django.middleware.csrf.CsrfViewMiddleware`. Always enabled — never disable globally.
|
|
183
|
+
For DRF + SessionAuth, use `@ensure_csrf_cookie` on the view that bootstraps the SPA.
|
|
184
|
+
|
|
185
|
+
### FastAPI — double-submit cookie
|
|
186
|
+
```python
|
|
187
|
+
import secrets
|
|
188
|
+
from fastapi import Request, HTTPException
|
|
189
|
+
|
|
190
|
+
CSRF_COOKIE = "csrf-token"
|
|
191
|
+
CSRF_HEADER = "x-csrf-token"
|
|
192
|
+
UNSAFE = {"POST", "PUT", "PATCH", "DELETE"}
|
|
193
|
+
|
|
194
|
+
@app.middleware("http")
|
|
195
|
+
async def csrf(request: Request, call_next):
|
|
196
|
+
if request.method in UNSAFE:
|
|
197
|
+
cookie = request.cookies.get(CSRF_COOKIE)
|
|
198
|
+
header = request.headers.get(CSRF_HEADER)
|
|
199
|
+
if not cookie or cookie != header:
|
|
200
|
+
raise HTTPException(403, "CSRF")
|
|
201
|
+
response = await call_next(request)
|
|
202
|
+
if not request.cookies.get(CSRF_COOKIE):
|
|
203
|
+
response.set_cookie(CSRF_COOKIE, secrets.token_urlsafe(32),
|
|
204
|
+
secure=True, samesite="lax", path="/")
|
|
205
|
+
return response
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## 7. Input Validation Boundary (Pydantic)
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
|
214
|
+
|
|
215
|
+
class CreateUser(BaseModel):
|
|
216
|
+
model_config = ConfigDict(extra="forbid") # rejects unknown keys → blocks mass assignment
|
|
217
|
+
email: EmailStr = Field(max_length=254)
|
|
218
|
+
age: int = Field(ge=13, le=120)
|
|
219
|
+
|
|
220
|
+
@app.post("/users")
|
|
221
|
+
async def create(body: CreateUser): # FastAPI validates automatically; 422 on failure
|
|
222
|
+
...
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 8. File Upload
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
import magic # python-magic — reads magic bytes
|
|
231
|
+
ALLOWED = {"image/jpeg", "image/png", "image/webp"}
|
|
232
|
+
|
|
233
|
+
async def upload(file: UploadFile = File(...)):
|
|
234
|
+
if file.size and file.size > 10 * 1024 * 1024:
|
|
235
|
+
raise HTTPException(413, "Too large")
|
|
236
|
+
head = await file.read(2048)
|
|
237
|
+
mime = magic.from_buffer(head, mime=True)
|
|
238
|
+
if mime not in ALLOWED:
|
|
239
|
+
raise HTTPException(415, "Unsupported type")
|
|
240
|
+
await file.seek(0)
|
|
241
|
+
# Save with UUID name, outside webroot
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## 9. Password Hashing
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
from argon2 import PasswordHasher
|
|
250
|
+
ph = PasswordHasher(memory_cost=19_456, time_cost=2, parallelism=1)
|
|
251
|
+
|
|
252
|
+
hashed = ph.hash(password)
|
|
253
|
+
try:
|
|
254
|
+
ph.verify(hashed, attempt)
|
|
255
|
+
except argon2.exceptions.VerifyMismatchError:
|
|
256
|
+
raise HTTPException(401, "Invalid credentials")
|
|
257
|
+
|
|
258
|
+
if ph.check_needs_rehash(hashed): # transparent upgrade
|
|
259
|
+
user.password = ph.hash(attempt)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Argon2id is the modern default. Avoid bare `hashlib`. For Django, the framework handles this — don't reinvent it.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## 10. SQL Injection — Use ORM Bindings
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
# WRONG — string interpolation
|
|
270
|
+
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
|
|
271
|
+
|
|
272
|
+
# CORRECT — parameterized
|
|
273
|
+
cursor.execute("SELECT * FROM users WHERE id = %s", [user_id])
|
|
274
|
+
|
|
275
|
+
# CORRECT — ORM
|
|
276
|
+
user = await User.get(id=user_id) # Tortoise / SQLAlchemy / Django ORM
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`SQLAlchemy.text()` with `:param` bindings is also safe. Raw f-strings are not.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Endpoint Checklist
|
|
284
|
+
|
|
285
|
+
- [ ] `Depends(current_user)` for protected routes
|
|
286
|
+
- [ ] User ID from `current_user`, **never** from body
|
|
287
|
+
- [ ] Pydantic model with `extra="forbid"`
|
|
288
|
+
- [ ] Authz check on the resource (object-level)
|
|
289
|
+
- [ ] Rate limit applied to auth + writes
|
|
290
|
+
- [ ] No PII in logs
|
|
291
|
+
- [ ] Errors: `HTTPException` with generic detail; full info to logs only
|
|
292
|
+
|
|
293
|
+
## FORBIDDEN Patterns
|
|
294
|
+
|
|
295
|
+
| Anti-pattern | Reason |
|
|
296
|
+
|---|---|
|
|
297
|
+
| `allow_origins=["*"]` with credentials | Browsers reject; auth breaks |
|
|
298
|
+
| Calling `jwt.decode` without `algorithms=` | `alg: none` and confusion attacks |
|
|
299
|
+
| Storing JWT in `localStorage` | XSS exfiltration |
|
|
300
|
+
| Disabling Django `CsrfViewMiddleware` globally | CSRF wide open |
|
|
301
|
+
| f-string interpolation in SQL | SQL injection |
|
|
302
|
+
| Deserializing untrusted bytes (pickle, marshal, shelve) | RCE via gadget chains — use JSON |
|
|
303
|
+
| `eval` / `exec` on user input | RCE |
|
|
304
|
+
| Logging request body or headers raw | Leaks passwords, cookies, tokens |
|
|
305
|
+
| Shell-mode subprocess with user input | Command injection — use list args, no shell |
|
|
306
|
+
|
|
307
|
+
## See Also
|
|
308
|
+
|
|
309
|
+
- `security-baseline` — OWASP Top 10
|
|
310
|
+
- `secrets-management` — env vars, rotation
|
|
311
|
+
- `pydantic-validation` — schema patterns
|
|
312
|
+
- `observability` — structured logs without PII
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: python-patterns
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# Python Patterns — Architecture & Decision-Making
|
|
2
7
|
|
|
3
8
|
**ALWAYS invoke when making Python architecture decisions.**
|
|
@@ -6,11 +11,13 @@
|
|
|
6
11
|
|
|
7
12
|
```
|
|
8
13
|
What are you building?
|
|
9
|
-
├── API / Microservices
|
|
10
|
-
├── Full-stack / CMS / Admin
|
|
11
|
-
├──
|
|
12
|
-
├── AI/ML API serving
|
|
13
|
-
|
|
14
|
+
├── API / Microservices → FastAPI (async, Pydantic, fast)
|
|
15
|
+
├── Full-stack / CMS / Admin → Django (batteries-included)
|
|
16
|
+
├── Lightweight web app → Flask (minimal)
|
|
17
|
+
├── AI/ML API serving → FastAPI (Pydantic, uvicorn)
|
|
18
|
+
├── Local scripts / automation → Scripts (httpx, argparse, rich)
|
|
19
|
+
├── WordPress / Ads / ETL → Scripts (no framework needed)
|
|
20
|
+
└── Background workers → Celery + any framework
|
|
14
21
|
```
|
|
15
22
|
|
|
16
23
|
## Async vs Sync
|
|
@@ -74,6 +81,20 @@ myproject/
|
|
|
74
81
|
└── tests/
|
|
75
82
|
```
|
|
76
83
|
|
|
84
|
+
### Local Scripts / Automation
|
|
85
|
+
```
|
|
86
|
+
project/
|
|
87
|
+
├── main.py # CLI entry (argparse + match/case)
|
|
88
|
+
├── scripts/ # One module per task
|
|
89
|
+
├── lib/
|
|
90
|
+
│ ├── config.py # Pydantic Settings (.env)
|
|
91
|
+
│ ├── http_client.py # httpx + tenacity retry
|
|
92
|
+
│ └── logger.py # rich logging
|
|
93
|
+
├── data/ # Input/output files
|
|
94
|
+
├── logs/
|
|
95
|
+
└── tests/
|
|
96
|
+
```
|
|
97
|
+
|
|
77
98
|
## Error Handling
|
|
78
99
|
|
|
79
100
|
```python
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: scripting-automation
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Local Scripts & Automation — Python 3.12+
|
|
7
|
+
|
|
8
|
+
**ALWAYS invoke when building local scripts, CLI tools, or automation tasks.**
|
|
9
|
+
|
|
10
|
+
## When to Use
|
|
11
|
+
|
|
12
|
+
- WordPress API automation (create/update posts, manage media)
|
|
13
|
+
- Ad campaign management (Google Ads, Facebook Ads, TikTok Ads API)
|
|
14
|
+
- Data pipelines (CSV/Excel processing, database sync)
|
|
15
|
+
- Web scraping and data extraction
|
|
16
|
+
- File system operations and batch processing
|
|
17
|
+
- Scheduled tasks and cron-like automation
|
|
18
|
+
- API integrations without a web framework
|
|
19
|
+
|
|
20
|
+
## Project Structure
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
project/
|
|
24
|
+
├── pyproject.toml # Dependencies + project metadata
|
|
25
|
+
├── .env # API keys, credentials (NEVER commit)
|
|
26
|
+
├── .env.example # Template without real values
|
|
27
|
+
├── scripts/
|
|
28
|
+
│ ├── __init__.py
|
|
29
|
+
│ ├── wordpress.py # WordPress automation
|
|
30
|
+
│ ├── ads_manager.py # Ad campaigns
|
|
31
|
+
│ └── data_sync.py # Database sync
|
|
32
|
+
├── lib/
|
|
33
|
+
│ ├── __init__.py
|
|
34
|
+
│ ├── http_client.py # Reusable httpx client
|
|
35
|
+
│ ├── config.py # Pydantic Settings
|
|
36
|
+
│ ├── logger.py # Structured logging
|
|
37
|
+
│ └── retry.py # Retry with backoff
|
|
38
|
+
├── data/ # Input/output data files
|
|
39
|
+
├── logs/ # Log files
|
|
40
|
+
├── tests/
|
|
41
|
+
│ └── test_scripts.py
|
|
42
|
+
└── main.py # Entry point / CLI
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Configuration (Pydantic Settings)
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from pydantic_settings import BaseSettings
|
|
49
|
+
|
|
50
|
+
class Settings(BaseSettings):
|
|
51
|
+
WP_URL: str
|
|
52
|
+
WP_USER: str
|
|
53
|
+
WP_APP_PASSWORD: str
|
|
54
|
+
|
|
55
|
+
GOOGLE_ADS_DEVELOPER_TOKEN: str = ""
|
|
56
|
+
FACEBOOK_ACCESS_TOKEN: str = ""
|
|
57
|
+
TIKTOK_ACCESS_TOKEN: str = ""
|
|
58
|
+
|
|
59
|
+
DB_HOST: str = "localhost"
|
|
60
|
+
DB_PORT: int = 3306
|
|
61
|
+
DB_NAME: str
|
|
62
|
+
DB_USER: str
|
|
63
|
+
DB_PASSWORD: str
|
|
64
|
+
|
|
65
|
+
LOG_LEVEL: str = "INFO"
|
|
66
|
+
DRY_RUN: bool = False
|
|
67
|
+
|
|
68
|
+
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
|
69
|
+
|
|
70
|
+
settings = Settings()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## HTTP Client (reusable)
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import httpx
|
|
77
|
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
78
|
+
|
|
79
|
+
class ApiClient:
|
|
80
|
+
def __init__(self, base_url: str, auth: tuple[str, str] | None = None):
|
|
81
|
+
self.client = httpx.Client(
|
|
82
|
+
base_url=base_url,
|
|
83
|
+
auth=auth,
|
|
84
|
+
timeout=30.0,
|
|
85
|
+
headers={"User-Agent": "AutomationScript/1.0"},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
|
|
89
|
+
def get(self, path: str, **kwargs) -> dict:
|
|
90
|
+
r = self.client.get(path, **kwargs)
|
|
91
|
+
r.raise_for_status()
|
|
92
|
+
return r.json()
|
|
93
|
+
|
|
94
|
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
|
|
95
|
+
def post(self, path: str, **kwargs) -> dict:
|
|
96
|
+
r = self.client.post(path, **kwargs)
|
|
97
|
+
r.raise_for_status()
|
|
98
|
+
return r.json()
|
|
99
|
+
|
|
100
|
+
def close(self):
|
|
101
|
+
self.client.close()
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## WordPress REST API Pattern
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from lib.http_client import ApiClient
|
|
108
|
+
from lib.config import settings
|
|
109
|
+
|
|
110
|
+
wp = ApiClient(
|
|
111
|
+
base_url=f"{settings.WP_URL}/wp-json/wp/v2",
|
|
112
|
+
auth=(settings.WP_USER, settings.WP_APP_PASSWORD),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def create_post(title: str, content: str, status: str = "draft") -> dict:
|
|
116
|
+
return wp.post("/posts", json={
|
|
117
|
+
"title": title,
|
|
118
|
+
"content": content,
|
|
119
|
+
"status": status,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
def update_post(post_id: int, **fields) -> dict:
|
|
123
|
+
return wp.post(f"/posts/{post_id}", json=fields)
|
|
124
|
+
|
|
125
|
+
def bulk_update_posts(posts: list[dict]) -> list[dict]:
|
|
126
|
+
results = []
|
|
127
|
+
for post in posts:
|
|
128
|
+
pid = post.pop("id")
|
|
129
|
+
result = update_post(pid, **post)
|
|
130
|
+
results.append(result)
|
|
131
|
+
logger.info(f"Updated post {pid}: {result['title']['rendered']}")
|
|
132
|
+
return results
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Database Access (direct, no ORM)
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
import mariadb
|
|
139
|
+
from contextlib import contextmanager
|
|
140
|
+
from lib.config import settings
|
|
141
|
+
|
|
142
|
+
@contextmanager
|
|
143
|
+
def get_connection():
|
|
144
|
+
conn = mariadb.connect(
|
|
145
|
+
host=settings.DB_HOST,
|
|
146
|
+
port=settings.DB_PORT,
|
|
147
|
+
user=settings.DB_USER,
|
|
148
|
+
password=settings.DB_PASSWORD,
|
|
149
|
+
database=settings.DB_NAME,
|
|
150
|
+
)
|
|
151
|
+
try:
|
|
152
|
+
yield conn
|
|
153
|
+
finally:
|
|
154
|
+
conn.close()
|
|
155
|
+
|
|
156
|
+
def fetch_all(query: str, params: tuple = ()) -> list[dict]:
|
|
157
|
+
with get_connection() as conn:
|
|
158
|
+
cursor = conn.cursor(dictionary=True)
|
|
159
|
+
cursor.execute(query, params)
|
|
160
|
+
return cursor.fetchall()
|
|
161
|
+
|
|
162
|
+
def execute(query: str, params: tuple = ()) -> int:
|
|
163
|
+
with get_connection() as conn:
|
|
164
|
+
cursor = conn.cursor()
|
|
165
|
+
cursor.execute(query, params)
|
|
166
|
+
conn.commit()
|
|
167
|
+
return cursor.rowcount
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## CLI Entry Point
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
import argparse
|
|
174
|
+
import logging
|
|
175
|
+
from lib.config import settings
|
|
176
|
+
|
|
177
|
+
logging.basicConfig(
|
|
178
|
+
level=getattr(logging, settings.LOG_LEVEL),
|
|
179
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
180
|
+
handlers=[
|
|
181
|
+
logging.StreamHandler(),
|
|
182
|
+
logging.FileHandler("logs/script.log"),
|
|
183
|
+
],
|
|
184
|
+
)
|
|
185
|
+
logger = logging.getLogger(__name__)
|
|
186
|
+
|
|
187
|
+
def main():
|
|
188
|
+
parser = argparse.ArgumentParser(description="Automation Scripts")
|
|
189
|
+
sub = parser.add_subparsers(dest="command")
|
|
190
|
+
|
|
191
|
+
sub.add_parser("wp-sync", help="Sync WordPress posts")
|
|
192
|
+
sub.add_parser("ads-report", help="Generate ads performance report")
|
|
193
|
+
sub.add_parser("db-migrate", help="Run data migration")
|
|
194
|
+
|
|
195
|
+
args = parser.parse_args()
|
|
196
|
+
|
|
197
|
+
if settings.DRY_RUN:
|
|
198
|
+
logger.warning("DRY RUN mode — no changes will be saved")
|
|
199
|
+
|
|
200
|
+
match args.command:
|
|
201
|
+
case "wp-sync":
|
|
202
|
+
from scripts.wordpress import sync_posts
|
|
203
|
+
sync_posts()
|
|
204
|
+
case "ads-report":
|
|
205
|
+
from scripts.ads_manager import generate_report
|
|
206
|
+
generate_report()
|
|
207
|
+
case "db-migrate":
|
|
208
|
+
from scripts.data_sync import run_migration
|
|
209
|
+
run_migration()
|
|
210
|
+
case _:
|
|
211
|
+
parser.print_help()
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
main()
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Essential Libraries
|
|
218
|
+
|
|
219
|
+
```toml
|
|
220
|
+
# pyproject.toml
|
|
221
|
+
[project]
|
|
222
|
+
dependencies = [
|
|
223
|
+
"httpx>=0.27",
|
|
224
|
+
"pydantic-settings>=2.0",
|
|
225
|
+
"tenacity>=8.0",
|
|
226
|
+
"python-dotenv>=1.0",
|
|
227
|
+
"mariadb>=1.1",
|
|
228
|
+
"rich>=13.0",
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
[project.optional-dependencies]
|
|
232
|
+
dev = ["pytest>=8.0", "mypy>=1.8", "ruff>=0.3"]
|
|
233
|
+
ads = ["google-ads>=24.0", "facebook-business>=19.0"]
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Logging (structured)
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
from rich.console import Console
|
|
240
|
+
from rich.logging import RichHandler
|
|
241
|
+
import logging
|
|
242
|
+
|
|
243
|
+
console = Console()
|
|
244
|
+
logging.basicConfig(
|
|
245
|
+
level="INFO",
|
|
246
|
+
format="%(message)s",
|
|
247
|
+
handlers=[RichHandler(console=console, rich_tracebacks=True)],
|
|
248
|
+
)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## FORBIDDEN
|
|
252
|
+
|
|
253
|
+
1. **Hardcoded credentials** — use `.env` + Pydantic Settings
|
|
254
|
+
2. **No error handling on API calls** — always try/except + retry
|
|
255
|
+
3. **No logging** — every script must log actions and errors
|
|
256
|
+
4. **`requests` library** — use `httpx` (modern, sync+async)
|
|
257
|
+
5. **Print statements for output** — use `logging` or `rich`
|
|
258
|
+
6. **No `--dry-run` flag** — destructive scripts must support dry run
|
|
259
|
+
7. **SQL without parameterization** — always use `?` placeholders
|
|
260
|
+
8. **No `.env.example`** — always provide template for credentials
|