start-vibing-stacks 2.17.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/stacks/python/skills/api-security-python/SKILL.md +118 -15
- package/stacks/python/skills/async-patterns/SKILL.md +166 -62
- package/stacks/python/skills/django-patterns/SKILL.md +102 -11
- package/stacks/python/skills/fastapi-patterns/SKILL.md +277 -62
- package/stacks/python/skills/pydantic-validation/SKILL.md +106 -11
- package/stacks/python/skills/pytest-testing/SKILL.md +172 -54
- package/stacks/python/skills/python-patterns/SKILL.md +49 -7
- package/stacks/python/skills/python-performance/SKILL.md +183 -3
- package/stacks/python/skills/scripting-automation/SKILL.md +205 -119
|
@@ -1,111 +1,199 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: scripting-automation
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "Local Python scripts and automation tooling for Python 3.13/3.14 with uv as the default package + project manager (uv init / uv add / uv run / uv tool — Astral, acquired by OpenAI Mar 2026, ~75M monthly downloads vs Poetry 66M, 10–100× faster than pip). Covers project layout for ETL/CLI/cron jobs, Pydantic Settings (.env), reusable httpx + tenacity client, WordPress REST integration, MariaDB/Postgres direct access (no ORM), structured CLI with argparse + match/case + rich, structured logging, and uv single-file scripts via PEP 723 inline metadata. Use for WordPress, ad-platform automation, ETL, scrapers, batch jobs."
|
|
4
5
|
---
|
|
5
6
|
|
|
6
|
-
# Local Scripts & Automation — Python 3.
|
|
7
|
+
# Local Scripts & Automation — Python 3.13/3.14 with uv
|
|
7
8
|
|
|
8
|
-
**ALWAYS invoke when building local scripts, CLI tools,
|
|
9
|
+
**ALWAYS invoke when building local scripts, CLI tools, scrapers, ad-platform automation, ETL, or cron-style jobs.**
|
|
9
10
|
|
|
10
|
-
## When to
|
|
11
|
+
## When to use
|
|
11
12
|
|
|
12
|
-
- WordPress
|
|
13
|
-
- Ad
|
|
14
|
-
-
|
|
15
|
-
- Web scraping
|
|
16
|
-
-
|
|
17
|
-
- Scheduled tasks
|
|
18
|
-
- API integrations without a web framework
|
|
13
|
+
- WordPress REST automation (posts, media, taxonomies)
|
|
14
|
+
- Ad-platform automation (Google Ads, Meta, TikTok, LinkedIn APIs)
|
|
15
|
+
- ETL / CSV / Excel pipelines
|
|
16
|
+
- Web scraping & data extraction
|
|
17
|
+
- Database sync, batch processing
|
|
18
|
+
- Scheduled tasks (cron, GitHub Actions cron, Vercel cron, systemd timers)
|
|
19
|
+
- "API integrations without a web framework"
|
|
19
20
|
|
|
20
|
-
##
|
|
21
|
+
## Toolchain (2026)
|
|
22
|
+
|
|
23
|
+
| Tool | Why |
|
|
24
|
+
|---|---|
|
|
25
|
+
| **`uv`** (Astral) | Project + package + Python-version manager. 10–100× faster than pip; surpassed Poetry in monthly downloads in early 2026. Astral was acquired by OpenAI in March 2026 with public commitment to keep `uv` open source. |
|
|
26
|
+
| `ruff` | Lint + format in one Rust binary |
|
|
27
|
+
| `pyright` | Type checking (correctness); `ty` (Astral) when speed matters |
|
|
28
|
+
| `httpx` + `tenacity` | HTTP with retries |
|
|
29
|
+
| `pydantic-settings` | Typed env config |
|
|
30
|
+
| `rich` | Terminal output, tables, progress bars, tracebacks |
|
|
31
|
+
| `tomllib` (3.11+ stdlib) | TOML reading — drop `tomli`/`toml` from deps |
|
|
32
|
+
|
|
33
|
+
## Project Layout
|
|
21
34
|
|
|
22
35
|
```
|
|
23
36
|
project/
|
|
24
|
-
├── pyproject.toml
|
|
25
|
-
├── .
|
|
26
|
-
├── .env
|
|
37
|
+
├── pyproject.toml # Project + tool config (uv, ruff, pyright, pytest)
|
|
38
|
+
├── uv.lock # Universal lockfile — commit this
|
|
39
|
+
├── .env # Secrets — NEVER commit
|
|
40
|
+
├── .env.example # Safe template
|
|
41
|
+
├── README.md
|
|
42
|
+
├── main.py # CLI entry
|
|
27
43
|
├── scripts/
|
|
28
44
|
│ ├── __init__.py
|
|
29
|
-
│ ├── wordpress.py
|
|
30
|
-
│ ├── ads_manager.py
|
|
31
|
-
│ └── data_sync.py
|
|
45
|
+
│ ├── wordpress.py # WordPress automation
|
|
46
|
+
│ ├── ads_manager.py # Ad campaigns
|
|
47
|
+
│ └── data_sync.py # DB sync
|
|
32
48
|
├── lib/
|
|
33
49
|
│ ├── __init__.py
|
|
34
|
-
│ ├── http_client.py
|
|
35
|
-
│ ├── config.py
|
|
36
|
-
│ ├── logger.py
|
|
37
|
-
│ └── retry.py
|
|
38
|
-
├── data/
|
|
39
|
-
├── logs/
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
50
|
+
│ ├── http_client.py # Reusable httpx client
|
|
51
|
+
│ ├── config.py # Pydantic Settings
|
|
52
|
+
│ ├── logger.py # rich/structlog
|
|
53
|
+
│ └── retry.py # Retry helpers
|
|
54
|
+
├── data/ # I/O files
|
|
55
|
+
├── logs/
|
|
56
|
+
└── tests/
|
|
57
|
+
└── test_scripts.py
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## uv quickstart
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Install once (mac/linux)
|
|
64
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
65
|
+
|
|
66
|
+
# Bootstrap a project
|
|
67
|
+
uv init my-script && cd my-script
|
|
68
|
+
uv python pin 3.13 # writes .python-version
|
|
69
|
+
uv add httpx pydantic-settings tenacity rich
|
|
70
|
+
uv add --dev pytest ruff pyright
|
|
71
|
+
|
|
72
|
+
# Run anything inside the project venv
|
|
73
|
+
uv run python main.py
|
|
74
|
+
uv run pytest
|
|
75
|
+
|
|
76
|
+
# Install a global CLI tool, isolated
|
|
77
|
+
uv tool install ruff # works like pipx, just faster
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`uv` reads/writes `pyproject.toml` and produces a single universal `uv.lock` that resolves consistently across macOS/Linux/Windows. Commit the lockfile.
|
|
81
|
+
|
|
82
|
+
## `pyproject.toml` (uv-native)
|
|
83
|
+
|
|
84
|
+
```toml
|
|
85
|
+
[project]
|
|
86
|
+
name = "my-script"
|
|
87
|
+
version = "0.1.0"
|
|
88
|
+
requires-python = ">=3.13"
|
|
89
|
+
dependencies = [
|
|
90
|
+
"httpx>=0.27",
|
|
91
|
+
"pydantic-settings>=2.5",
|
|
92
|
+
"tenacity>=9.0",
|
|
93
|
+
"rich>=13.7",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
[dependency-groups]
|
|
97
|
+
dev = ["pytest>=9.0", "ruff>=0.5", "pyright>=1.1"]
|
|
98
|
+
ads = ["google-ads>=24.0", "facebook-business>=19.0"]
|
|
99
|
+
|
|
100
|
+
[tool.ruff]
|
|
101
|
+
line-length = 100
|
|
102
|
+
target-version = "py313"
|
|
103
|
+
[tool.ruff.lint]
|
|
104
|
+
select = ["E", "F", "W", "I", "B", "UP", "N", "S", "RUF"]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Single-file scripts — PEP 723 inline metadata
|
|
108
|
+
|
|
109
|
+
For one-off scripts you want runnable without `pip install`:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
#!/usr/bin/env -S uv run --script
|
|
113
|
+
# /// script
|
|
114
|
+
# requires-python = ">=3.13"
|
|
115
|
+
# dependencies = ["httpx>=0.27", "rich>=13.7"]
|
|
116
|
+
# ///
|
|
117
|
+
|
|
118
|
+
import httpx
|
|
119
|
+
from rich import print
|
|
120
|
+
|
|
121
|
+
r = httpx.get("https://api.example.com/status", timeout=5)
|
|
122
|
+
print(r.json())
|
|
43
123
|
```
|
|
44
124
|
|
|
125
|
+
`uv run script.py` reads the inline metadata, builds an isolated venv, runs the script. Killer for cron jobs, GitHub Actions one-liners, throwaway helpers.
|
|
126
|
+
|
|
45
127
|
## Configuration (Pydantic Settings)
|
|
46
128
|
|
|
47
129
|
```python
|
|
48
|
-
from
|
|
130
|
+
from pydantic import Field
|
|
131
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
49
132
|
|
|
50
133
|
class Settings(BaseSettings):
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
134
|
+
model_config = SettingsConfigDict(env_file=".env", extra="forbid")
|
|
135
|
+
|
|
136
|
+
WP_URL: str
|
|
137
|
+
WP_USER: str
|
|
138
|
+
WP_APP_PASSWORD: str = Field(min_length=10)
|
|
54
139
|
|
|
55
140
|
GOOGLE_ADS_DEVELOPER_TOKEN: str = ""
|
|
56
|
-
FACEBOOK_ACCESS_TOKEN:
|
|
57
|
-
TIKTOK_ACCESS_TOKEN:
|
|
141
|
+
FACEBOOK_ACCESS_TOKEN: str = ""
|
|
142
|
+
TIKTOK_ACCESS_TOKEN: str = ""
|
|
58
143
|
|
|
59
|
-
DB_HOST:
|
|
60
|
-
DB_PORT:
|
|
61
|
-
DB_NAME:
|
|
62
|
-
DB_USER:
|
|
144
|
+
DB_HOST: str = "localhost"
|
|
145
|
+
DB_PORT: int = 3306
|
|
146
|
+
DB_NAME: str
|
|
147
|
+
DB_USER: str
|
|
63
148
|
DB_PASSWORD: str
|
|
64
149
|
|
|
65
|
-
LOG_LEVEL: str
|
|
66
|
-
DRY_RUN:
|
|
150
|
+
LOG_LEVEL: str = "INFO"
|
|
151
|
+
DRY_RUN: bool = False
|
|
67
152
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
settings = Settings()
|
|
153
|
+
settings = Settings() # validated at import
|
|
71
154
|
```
|
|
72
155
|
|
|
73
|
-
## HTTP Client (reusable)
|
|
156
|
+
## HTTP Client (reusable, retried)
|
|
74
157
|
|
|
75
158
|
```python
|
|
76
159
|
import httpx
|
|
77
|
-
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
160
|
+
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
|
78
161
|
|
|
79
162
|
class ApiClient:
|
|
80
163
|
def __init__(self, base_url: str, auth: tuple[str, str] | None = None):
|
|
81
164
|
self.client = httpx.Client(
|
|
82
165
|
base_url=base_url,
|
|
83
166
|
auth=auth,
|
|
84
|
-
timeout=
|
|
167
|
+
timeout=httpx.Timeout(connect=5, read=30, write=30, pool=5),
|
|
85
168
|
headers={"User-Agent": "AutomationScript/1.0"},
|
|
86
169
|
)
|
|
87
170
|
|
|
88
|
-
@retry(
|
|
89
|
-
|
|
90
|
-
|
|
171
|
+
@retry(
|
|
172
|
+
stop=stop_after_attempt(3),
|
|
173
|
+
wait=wait_exponential(min=1, max=10),
|
|
174
|
+
retry=retry_if_exception_type((httpx.TransportError, httpx.HTTPStatusError)),
|
|
175
|
+
reraise=True,
|
|
176
|
+
)
|
|
177
|
+
def get(self, path: str, **kw) -> dict:
|
|
178
|
+
r = self.client.get(path, **kw)
|
|
91
179
|
r.raise_for_status()
|
|
92
180
|
return r.json()
|
|
93
181
|
|
|
94
|
-
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
|
|
95
|
-
def post(self, path: str, **
|
|
96
|
-
r = self.client.post(path, **
|
|
182
|
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10), reraise=True)
|
|
183
|
+
def post(self, path: str, **kw) -> dict:
|
|
184
|
+
r = self.client.post(path, **kw)
|
|
97
185
|
r.raise_for_status()
|
|
98
186
|
return r.json()
|
|
99
187
|
|
|
100
|
-
def close(self):
|
|
188
|
+
def close(self) -> None:
|
|
101
189
|
self.client.close()
|
|
102
190
|
```
|
|
103
191
|
|
|
104
|
-
## WordPress REST API
|
|
192
|
+
## WordPress REST API pattern
|
|
105
193
|
|
|
106
194
|
```python
|
|
107
195
|
from lib.http_client import ApiClient
|
|
108
|
-
from lib.config
|
|
196
|
+
from lib.config import settings
|
|
109
197
|
|
|
110
198
|
wp = ApiClient(
|
|
111
199
|
base_url=f"{settings.WP_URL}/wp-json/wp/v2",
|
|
@@ -113,26 +201,13 @@ wp = ApiClient(
|
|
|
113
201
|
)
|
|
114
202
|
|
|
115
203
|
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
|
-
})
|
|
204
|
+
return wp.post("/posts", json={"title": title, "content": content, "status": status})
|
|
121
205
|
|
|
122
206
|
def update_post(post_id: int, **fields) -> dict:
|
|
123
207
|
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
208
|
```
|
|
134
209
|
|
|
135
|
-
## Database
|
|
210
|
+
## Database access — direct, no ORM
|
|
136
211
|
|
|
137
212
|
```python
|
|
138
213
|
import mariadb
|
|
@@ -155,47 +230,48 @@ def get_connection():
|
|
|
155
230
|
|
|
156
231
|
def fetch_all(query: str, params: tuple = ()) -> list[dict]:
|
|
157
232
|
with get_connection() as conn:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return
|
|
233
|
+
cur = conn.cursor(dictionary=True)
|
|
234
|
+
cur.execute(query, params) # parameterised — never f-string
|
|
235
|
+
return cur.fetchall()
|
|
161
236
|
|
|
162
237
|
def execute(query: str, params: tuple = ()) -> int:
|
|
163
238
|
with get_connection() as conn:
|
|
164
|
-
|
|
165
|
-
|
|
239
|
+
cur = conn.cursor()
|
|
240
|
+
cur.execute(query, params)
|
|
166
241
|
conn.commit()
|
|
167
|
-
return
|
|
242
|
+
return cur.rowcount
|
|
168
243
|
```
|
|
169
244
|
|
|
170
|
-
|
|
245
|
+
For PostgreSQL prefer `psycopg[binary]` (psycopg 3) over psycopg2.
|
|
246
|
+
|
|
247
|
+
## CLI Entry — argparse + match/case
|
|
171
248
|
|
|
172
249
|
```python
|
|
173
250
|
import argparse
|
|
174
251
|
import logging
|
|
252
|
+
from rich.logging import RichHandler
|
|
253
|
+
|
|
175
254
|
from lib.config import settings
|
|
176
255
|
|
|
177
256
|
logging.basicConfig(
|
|
178
257
|
level=getattr(logging, settings.LOG_LEVEL),
|
|
179
|
-
format="%(
|
|
180
|
-
handlers=[
|
|
181
|
-
logging.StreamHandler(),
|
|
182
|
-
logging.FileHandler("logs/script.log"),
|
|
183
|
-
],
|
|
258
|
+
format="%(message)s",
|
|
259
|
+
handlers=[RichHandler(rich_tracebacks=True)],
|
|
184
260
|
)
|
|
185
261
|
logger = logging.getLogger(__name__)
|
|
186
262
|
|
|
187
|
-
def main():
|
|
263
|
+
def main() -> int:
|
|
188
264
|
parser = argparse.ArgumentParser(description="Automation Scripts")
|
|
189
|
-
sub = parser.add_subparsers(dest="command")
|
|
265
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
190
266
|
|
|
191
|
-
sub.add_parser("wp-sync",
|
|
267
|
+
sub.add_parser("wp-sync", help="Sync WordPress posts")
|
|
192
268
|
sub.add_parser("ads-report", help="Generate ads performance report")
|
|
193
269
|
sub.add_parser("db-migrate", help="Run data migration")
|
|
194
270
|
|
|
195
271
|
args = parser.parse_args()
|
|
196
272
|
|
|
197
273
|
if settings.DRY_RUN:
|
|
198
|
-
logger.warning("DRY RUN
|
|
274
|
+
logger.warning("DRY RUN — no changes will be persisted")
|
|
199
275
|
|
|
200
276
|
match args.command:
|
|
201
277
|
case "wp-sync":
|
|
@@ -209,52 +285,62 @@ def main():
|
|
|
209
285
|
run_migration()
|
|
210
286
|
case _:
|
|
211
287
|
parser.print_help()
|
|
288
|
+
return 2
|
|
289
|
+
return 0
|
|
212
290
|
|
|
213
291
|
if __name__ == "__main__":
|
|
214
|
-
main()
|
|
292
|
+
raise SystemExit(main())
|
|
215
293
|
```
|
|
216
294
|
|
|
217
|
-
##
|
|
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)
|
|
295
|
+
## Logging (structured, prod-ready)
|
|
237
296
|
|
|
238
297
|
```python
|
|
239
|
-
from rich.console import Console
|
|
240
|
-
from rich.logging import RichHandler
|
|
241
298
|
import logging
|
|
299
|
+
from rich.logging import RichHandler
|
|
242
300
|
|
|
243
|
-
console = Console()
|
|
244
301
|
logging.basicConfig(
|
|
245
302
|
level="INFO",
|
|
246
|
-
format="%(message)s",
|
|
247
|
-
handlers=[
|
|
303
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
304
|
+
handlers=[
|
|
305
|
+
RichHandler(rich_tracebacks=True, show_time=False),
|
|
306
|
+
logging.FileHandler("logs/script.log"),
|
|
307
|
+
],
|
|
248
308
|
)
|
|
309
|
+
|
|
310
|
+
# For real prod observability use structlog + JSON formatter — see _shared/observability
|
|
249
311
|
```
|
|
250
312
|
|
|
313
|
+
## Reading TOML/JSON configs
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
# 3.11+ — stdlib, no extra dep
|
|
317
|
+
import tomllib
|
|
318
|
+
with open("pyproject.toml", "rb") as f:
|
|
319
|
+
data = tomllib.load(f)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Drop `tomli` / `toml` from `dependencies` for Python 3.11+ projects.
|
|
323
|
+
|
|
251
324
|
## FORBIDDEN
|
|
252
325
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
326
|
+
| Anti-pattern | Reason |
|
|
327
|
+
|---|---|
|
|
328
|
+
| Hardcoded credentials | Use `.env` + `BaseSettings(extra="forbid")` |
|
|
329
|
+
| No retry on flaky API | Use `tenacity` with exponential backoff + jitter |
|
|
330
|
+
| `print()` for output | Use `logging` + `rich` (machine-readable + human-readable) |
|
|
331
|
+
| `requests` library | Use `httpx` (sync + async, http/2, modern API) |
|
|
332
|
+
| `pip install` in new projects | Use `uv add` (Astral's `uv` is the 2026 default) |
|
|
333
|
+
| Missing `uv.lock` in repo | Builds become non-reproducible |
|
|
334
|
+
| f-string interpolation in SQL | SQL injection — always parameterise |
|
|
335
|
+
| No `--dry-run` on destructive scripts | Always provide a safe preview mode |
|
|
336
|
+
| No `.env.example` | Onboarding nightmare |
|
|
337
|
+
| Per-tool config files (`.flake8`, `.isort.cfg`, `pyproject` for black + isort + ruff) | Consolidate under `[tool.ruff]` + `[tool.pyright]` in `pyproject.toml` |
|
|
338
|
+
| `tomli` / `toml` deps for 3.11+ | Use stdlib `tomllib` |
|
|
339
|
+
|
|
340
|
+
## See Also
|
|
341
|
+
|
|
342
|
+
- `python-patterns` — version policy, framework selection
|
|
343
|
+
- `async-patterns` — when to switch from `httpx.Client` to `AsyncClient`
|
|
344
|
+
- `pydantic-validation` — `BaseSettings(extra="forbid")` pattern
|
|
345
|
+
- `_shared/skills/secrets-management` — `.env` discipline + 3-layer gitleaks
|
|
346
|
+
- `_shared/skills/observability` — structlog + OpenTelemetry for prod scripts
|