Languages
Python
Patterns for operational scripts: subprocess, paths, logging, HTTP, configuration, concurrency choices and packaging.
Cheatsheet #
| Task | Snippet |
|---|---|
| Run a command, fail loudly | subprocess.run(cmd, check=True, capture_output=True, text=True) |
| Path handling | from pathlib import Path; Path("/etc")/"app.conf" |
| Atomic write | write to tmp, then os.replace(tmp, target) |
| Structured log | logging.info("done", extra={"took_ms": n}) |
| HTTP with a timeout | httpx.get(url, timeout=10) |
| Retry | tenacity.retry(wait=wait_exponential(), stop=stop_after_attempt(5)) |
| Typed config | pydantic_settings.BaseSettings |
| Temp directory | with tempfile.TemporaryDirectory() as d: |
| Parse CLI args | argparse.ArgumentParser |
| Time a block | t = time.perf_counter(); ...; time.perf_counter() - t |
| Run tests | pytest -x -q |
| Format and lint | ruff format . && ruff check --fix . |
| Type-check | mypy --strict src/ |
| Isolated tool run | uvx ruff check . |
| Reproducible install | uv sync --frozen |
A script worth keeping #
#!/usr/bin/env python3
"""Reconcile inventory against the API."""
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
log = logging.getLogger("reconcile")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("inventory", type=Path)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("-v", "--verbose", action="count", default=0)
args = ap.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
stream=sys.stderr,
)
if not args.inventory.is_file():
log.error("inventory not found: %s", args.inventory)
return 2
log.info("reconciling %s dry_run=%s", args.inventory, args.dry_run)
return 0
if __name__ == "__main__":
raise SystemExit(main())Return an exit code from main, log to stderr, keep stdout for data. That is what makes a script composable in a pipeline and diagnosable in CI.
Running commands #
import shlex, subprocess
res = subprocess.run(
["kubectl", "get", "pods", "-o", "json"],
check=True, capture_output=True, text=True, timeout=30,
)
pods = json.loads(res.stdout)| Argument | Why |
|---|---|
| list, not a string | No shell, so no quoting or injection problem |
check=True | Raises CalledProcessError instead of continuing on failure |
capture_output=True, text=True | stdout/stderr as strings |
timeout= | A hung command fails instead of hanging the job |
cwd=, env= | Explicit context beats os.chdir |
shell=True is only acceptable with a literal string you wrote. With any interpolated value it is a vulnerability — and shlex.quote is the fix if a shell is genuinely required.
try:
subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=60)
except subprocess.CalledProcessError as e:
log.error("command failed rc=%s stderr=%s", e.returncode, e.stderr.strip())
raise
except subprocess.TimeoutExpired:
log.error("command timed out: %s", shlex.join(cmd))
raisePaths and files #
from pathlib import Path
base = Path("/srv/app")
cfg = base / "conf" / "app.yaml"
cfg.exists(); cfg.stat().st_size; cfg.read_text(encoding="utf-8")
list(base.rglob("*.log"))
base.mkdir(parents=True, exist_ok=True)Write atomically so a crash cannot leave a half-written file where a whole one is expected:
import os, tempfile
def write_atomic(path: Path, data: str) -> None:
fd, tmp = tempfile.mkstemp(dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path) # atomic on the same filesystem
except BaseException:
os.unlink(tmp)
raiseLogging #
import json, logging
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
payload.update(getattr(record, "extra_fields", {}))
return json.dumps(payload)
log.info("deployed", extra={"extra_fields": {"service": "api", "version": "1.4.2"}})Use log.exception() inside an except block: it records the traceback without you passing it. Never log secrets, tokens or full request bodies — logs travel further than the systems they describe.
HTTP #
import httpx
with httpx.Client(timeout=10.0, headers={"user-agent": "reconcile/1.0"}) as client:
r = client.get("https://api.example.com/items", params={"limit": 100})
r.raise_for_status()
items = r.json()from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=0.5, max=10),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.HTTPStatusError)),
reraise=True,
)
def fetch(client: httpx.Client, url: str) -> dict:
r = client.get(url)
r.raise_for_status()
return r.json()No timeout means no bound: requests and httpx both wait indefinitely by default in at least one configuration. Retry idempotent requests only — a retried POST can create two records.
Configuration and secrets #
from pydantic import Field
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
api_url: str
api_token: str = Field(repr=False) # kept out of repr and logs
timeout: float = 10.0
dry_run: bool = False
model_config = {"env_prefix": "APP_", "env_file": ".env"}
settings = Settings() # raises at start-up if anything is missingValidate configuration once, at start-up, and fail immediately. A missing variable discovered three hours into a batch job is a worse outcome than a crash on line one.
Data handling #
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Host:
name: str
ip: str
tags: tuple[str, ...] = ()
from collections import Counter, defaultdict
counts = Counter(h.tags[0] for h in hosts if h.tags)
by_zone: dict[str, list[Host]] = defaultdict(list)
for h in hosts:
by_zone[h.zone].append(h)
import csv
with open("hosts.csv", newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))frozen=True gives hashability and stops accidental mutation; slots=True removes the per-instance dict, which matters once there are hundreds of thousands of objects.
Generators keep memory flat for large inputs:
def read_events(path: Path):
with path.open(encoding="utf-8") as f:
for line in f: # one line at a time, not the whole file
yield json.loads(line)Concurrency #
| Workload | Tool |
|---|---|
| Network I/O, dozens of calls | ThreadPoolExecutor |
| Network I/O, thousands of calls | asyncio with httpx.AsyncClient |
| CPU-bound work | ProcessPoolExecutor |
| Shelling out to other programs | Threads — the GIL is released while waiting |
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=8) as pool:
futures = {pool.submit(check_host, h): h for h in hosts}
for fut in as_completed(futures):
host = futures[fut]
try:
result = fut.result()
except Exception:
log.exception("check failed host=%s", host.name)import asyncio, httpx
async def main(urls: list[str]) -> list[dict]:
limits = asyncio.Semaphore(20)
async with httpx.AsyncClient(timeout=10) as client:
async def one(u: str) -> dict:
async with limits:
r = await client.get(u)
r.raise_for_status()
return r.json()
return await asyncio.gather(*(one(u) for u in urls))Free-threaded builds exist from 3.13, but assume the GIL unless you have verified otherwise on the interpreter you actually ship.
Packaging #
[project]
name = "reconcile"
version = "1.4.2"
requires-python = ">=3.12"
dependencies = ["httpx>=0.27", "pydantic-settings>=2"]
[project.scripts]
reconcile = "reconcile.cli:main"
[tool.ruff]
line-length = 100
[tool.mypy]
strict = true
[tool.pytest.ini_options]
addopts = "-q --strict-markers"uv venv && uv sync # resolve and install from the lock file
uv run pytest
uv add httpx # updates pyproject.toml and the lock
uv sync --frozen # CI: fail if the lock is out of date
pipx install ./dist/*.whl # install a tool without polluting the environmentCommit the lock file for applications. A pinned set is what makes “it worked yesterday” a fact rather than a hope.
Oneliners #
# HTTP server for the current directory
python3 -m http.server 8000 --bind 127.0.0.1
# Pretty-print JSON
python3 -m json.tool < data.json
# Time a snippet properly
python3 -m timeit -s 'import json' 'json.dumps({"a":1})'
# What is slow
python3 -m cProfile -s cumtime script.py | head -25
# Inspect a module's location and version
python3 -c 'import httpx, inspect; print(httpx.__version__, inspect.getfile(httpx))'
# Base64 and URL encoding without leaving the shell
python3 -c 'import base64,sys; print(base64.b64encode(sys.stdin.buffer.read()).decode())'
python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))' 'a b&c'
# Epoch to ISO
python3 -c 'import sys,datetime as d; print(d.datetime.fromtimestamp(int(sys.argv[1]), d.UTC).isoformat())' 1700000000
# Validate YAML or JSON in CI
python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1]))' config.yaml
# Which package owns an import
python3 -c 'import importlib.metadata as m; print(m.packages_distributions()["yaml"])'
# Show effective settings from environment
python3 -c 'from app.settings import Settings; print(Settings().model_dump(exclude={"api_token"}))'
# Find the slowest tests
pytest --durations=10 -q
# Check a wheel before publishing
python3 -m twine check dist/*