writing-skills
writing-skills
Use when creating new skills, editing existing skills, or verifying skills work before deployment
python-devopsPython reference for ops teams — shelling out, file manipulation, structured data parsing, CLI tools, testing, cloud SDKs, Kubernetes API, IaC and CI/CD patterns.
Independent GitHub snapshot · Automated classification · Original documentation governs
name: python-devops description: Python reference for ops teams — shelling out, file manipulation, structured data parsing, CLI tools, testing, cloud SDKs, Kubernetes API, IaC and CI/CD patterns. license: MIT compatibility: opencode metadata: version: "1.0.0" domain: devops triggers: python devops, ops automation, infrastructure as code, iac, ci cd, kubernetes api, how do i write ops python scripts, subprocess role: reference scope: infrastructure output-format: code related-skills: bash-quick-reference,text-processing-quick-reference archetypes: educational anti_triggers: implement from scratch, architect a system, build infrastructure, design patterns response_profile: verbosity: high directive_strength: low abstraction_level: operational
Reference for using Python the way ops teams use it: shelling out, manipulating files, parsing structured data, building CLI tools, writing tests, talking to clouds and Kubernetes.
Treat ops Python like any other Python: virtualenvs, packaging, tests. The content is different (you call out to subprocesses, you parse YAML, you talk to clouds) but the discipline that keeps Python maintainable applies the same.
Examples assume Python 3.10+.
A small but proper layout that scales:
myops/
├── pyproject.toml
├── src/myops/
│ ├── __init__.py
│ ├── cli.py
│ └── tasks/
│ ├── __init__.py
│ └── deploy.py
├── tests/
│ ├── __init__.py
│ └── test_deploy.py
└── README.md
pyproject.toml (PEP 621):
[project]
name = "myops"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"click>=8",
"pyyaml",
"requests",
]
[project.scripts]
myops = "myops.cli:main"
[project.optional-dependencies]
dev = ["pytest", "pytest-cov", "mypy", "ruff"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
python -m venv .venv # create
source .venv/bin/activate # *nix
.\.venv\Scripts\Activate.ps1 # Windows PowerShell
deactivate # leave
python -m pip install -U pip wheel
python -m pip install -e '.[dev]' # editable install + dev extras
python -m pip install -r requirements.txt
python -m pip freeze > requirements.txt
python -m pip list --outdated
Modern alternatives:
uv venv, uv pip install, uv run.poetry add, poetry install,
poetry run.For binary-distributable tools, build wheels (python -m build) or
freeze with pyinstaller / shiv.
subprocess.run is the right answer 95% of the time.
import subprocess
# 1) capture output, raise on non-zero
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True, text=True, check=True,
)
sha = result.stdout.strip()
# 2) just want exit code, stream output to terminal
subprocess.run(["pytest", "-q"], check=True)
# 3) feed stdin
subprocess.run(["sort"], input="b\na\nc\n", text=True, capture_output=True)
# 4) timeout
subprocess.run(["slow"], timeout=30)
# 5) environment override
subprocess.run(["printenv", "FOO"], env={**os.environ, "FOO": "bar"})
# 6) cwd
subprocess.run(["make", "test"], cwd="/path/to/proj", check=True)
Avoid shell=True unless you actually need shell features (globs,
pipes, redirection). When you do, pass a string and accept that you
have to think about quoting.
# DO NOT do this with untrusted input
subprocess.run(f"grep {pattern} *.log | head", shell=True)
# DO this
subprocess.run(["grep", pattern, *glob.glob("*.log")], stdout=open("h.txt","w"))
For long-running processes you want to interact with, use Popen:
proc = subprocess.Popen(
["tail", "-F", "/var/log/app.log"],
stdout=subprocess.PIPE, text=True, bufsize=1,
)
for line in proc.stdout:
if "ERROR" in line:
notify(line)
pathlib.Path is the modern way; os.path is legacy.
from pathlib import Path
p = Path.home() / "logs" / "app.log"
p.exists()
p.is_file()
p.suffix # '.log'
p.stem # 'app'
p.parent # Path('/home/ada/logs')
# read / write whole files
text = p.read_text(encoding="utf-8")
p.write_text("hello\n", encoding="utf-8")
data = p.read_bytes()
# create / remove
Path("a/b/c").mkdir(parents=True, exist_ok=True)
p.unlink(missing_ok=True)
# walk a tree
for log in Path("/var/log").rglob("*.log"):
print(log)
# atomic write
import os, tempfile
def atomic_write(path: Path, content: str) -> None:
tmp = tempfile.NamedTemporaryFile("w", delete=False,
dir=path.parent, encoding="utf-8")
try:
tmp.write(content); tmp.flush(); os.fsync(tmp.fileno())
finally:
tmp.close()
os.replace(tmp.name, path)
Globbing:
list(Path(".").glob("*.py")) # one level
list(Path(".").rglob("*.py")) # recursive
import re
# match
m = re.match(r"^(\w+)@(\S+)quot;, "ada@example.com")
m.group(1), m.group(2) # 'ada', 'example.com'
# search / findall / finditer
re.search(r"\d{4}", "v2025-Q1")
re.findall(r"\b\w+\b", "two words")
for m in re.finditer(r"[A-Z]\w+", text):
print(m.start(), m.group())
# substitute
re.sub(r"\s+", " ", text).strip()
# pre-compile for hot loops
PAT = re.compile(r"^ERROR\s+(\d+)\s+(.*)quot;, re.M)
for m in PAT.finditer(log):
code, msg = m.groups()
String methods you'll use a lot:
"Hello".lower()
" x ".strip()
"a,b,c".split(",")
",".join(["a","b","c"])
f"{name=} {count=}" # debug-style f-string
import json, yaml, csv, tomllib # Python 3.11+
# JSON
data = json.loads(text)
text = json.dumps(data, indent=2, sort_keys=True)
data = json.load(open("f.json"))
json.dump(data, open("f.json","w"), indent=2)
# YAML (pip install pyyaml)
data = yaml.safe_load(open("f.yaml")) # ALWAYS safe_load, never load
yaml.safe_dump(data, open("f.yaml","w"), sort_keys=False)
# TOML (read-only stdlib in 3.11+; write with tomli-w)
with open("pyproject.toml","rb") as f:
cfg = tomllib.load(f)
# CSV
with open("data.csv", newline="") as f:
reader = csv.DictReader(f)
rows = [r for r in reader if r["status"] == "OK"]
with open("out.csv","w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["id","name","status"])
w.writeheader()
w.writerows(rows)
For dataframes (analytics, large files), reach for pandas.
click is the friendlier choice; argparse is the stdlib option.
# click
import click
@click.command()
@click.argument("path", type=click.Path(exists=True, dir_okay=False))
@click.option("--n", default=10, show_default=True, type=int)
@click.option("-v", "--verbose", is_flag=True)
def head(path, n, verbose):
"""Print the first N lines of PATH."""
if verbose: click.echo(f"reading {path}", err=True)
with open(path) as f:
for i, line in enumerate(f):
if i >= n: break
click.echo(line.rstrip())
if __name__ == "__main__":
head()
# argparse
import argparse
def main():
p = argparse.ArgumentParser(description="show first N lines")
p.add_argument("path")
p.add_argument("-n", type=int, default=10)
p.add_argument("-v", "--verbose", action="store_true")
args = p.parse_args()
...
if __name__ == "__main__":
main()
For a git-style tool with subcommands, click.Group or
argparse.add_subparsers both work; click ends up much shorter for
deep trees.
Use the stdlib logging. Don't print().
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
log = logging.getLogger(__name__)
log.debug("started with %s", args)
log.info("connected to %s", host)
log.warning("retrying after %s", err)
log.error("failed: %s", err, exc_info=True)
Conventional levels: DEBUG (developers), INFO (operational), WARNING (unexpected but recoverable), ERROR (operation failed), CRITICAL (process can't continue).
For structured (JSON) logs in production, structlog is the popular
choice. For metrics, prometheus_client. For traces, opentelemetry.
# structlog quickstart
import structlog
log = structlog.get_logger()
log.info("deploy_started", service="web", version="1.4.2")
# tests/test_deploy.py
import pytest
from myops.tasks.deploy import compute_target
def test_compute_target_default():
assert compute_target("web") == "web-prod"
@pytest.mark.parametrize("env,expected", [
("prod", "web-prod"),
("staging", "web-stg"),
("dev", "web-dev"),
])
def test_compute_target_envs(env, expected):
assert compute_target("web", env=env) == expected
@pytest.fixture
def fake_config(tmp_path):
cfg = tmp_path / "config.yaml"
cfg.write_text("env: test\n")
return cfg
def test_loads_config(fake_config):
assert load_config(fake_config)["env"] == "test"
Run:
pytest -q # quiet
pytest -k deploy # name filter
pytest --maxfail=1 -x # stop on first failure
pytest --cov=myops # with pytest-cov
pytest -n auto # parallel with pytest-xdist
Mocking:
from unittest.mock import patch, MagicMock
@patch("myops.tasks.deploy.requests.post")
def test_calls_api(mock_post):
mock_post.return_value.status_code = 200
deploy("web", "1.0")
mock_post.assert_called_once()
pyfakefs (fake filesystem) and responses (HTTP mocking) are
worth knowing.
import requests
r = requests.get("https://api.example.com/v1/items", timeout=10,
headers={"Authorization": f"Bearer {token}"})
r.raise_for_status()
items = r.json()
r = requests.post("https://api/v1/items", json={"name":"x"}, timeout=10)
Use a Session for repeated calls (connection reuse, default headers,
retries):
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
s = requests.Session()
s.headers.update({"Authorization": f"Bearer {token}"})
retry = Retry(total=5, backoff_factor=0.5,
status_forcelist=[429,500,502,503,504],
allowed_methods=["GET","POST"])
s.mount("https://", HTTPAdapter(max_retries=retry))
httpx is a modern alternative with sync + async APIs. For high
throughput, async + httpx.AsyncClient or aiohttp.
os.environ.get("PORT", "8080")..env +
python-dotenv for local dev.pydantic-settings (or dynaconf) gives you a typed Settings object
populated from env vars, files and defaults.from pydantic_settings import BaseSettings
class Settings(BaseSettings):
api_url: str = "https://api.example.com"
api_token: str # required; from API_TOKEN env var
log_level: str = "INFO"
class Config:
env_file = ".env"
settings = Settings()
# docker SDK
import docker
client = docker.from_env()
client.containers.run("alpine", "echo hi", remove=True)
for c in client.containers.list():
print(c.short_id, c.status, c.image.tags)
# Kubernetes client
from kubernetes import client, config
config.load_kube_config() # ~/.kube/config
v1 = client.CoreV1Api()
for p in v1.list_namespaced_pod("default").items:
print(p.metadata.name, p.status.phase)
If you're already running inside a cluster, use
config.load_incluster_config() and the service account.
For higher-level cluster work, kr8s and pykube-ng are friendlier
than the auto-generated official client.
Calling kubectl via subprocess is also legitimate when you just need
to apply a YAML you already have:
subprocess.run(["kubectl","apply","-f","manifest.yaml"], check=True)
import boto3
s3 = boto3.client("s3")
s3.upload_file("local.txt", "my-bucket", "remote.txt")
for obj in s3.list_objects_v2(Bucket="my-bucket").get("Contents", []):
print(obj["Key"], obj["Size"])
ec2 = boto3.client("ec2")
ec2.start_instances(InstanceIds=["i-0abc..."])
from google.cloud import storage
client = storage.Client()
client.bucket("my-bucket").blob("remote.txt").upload_from_filename("local.txt")
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient
cred = DefaultAzureCredential()
svc = BlobServiceClient(account_url="https://x.blob.core.windows.net", credential=cred)
Authentication: rely on the SDK's default credential chain (instance metadata when on-cloud, env vars or local CLI cache otherwise). Don't hard-code keys.
python-terraform wrapper or just subprocess.run(["terraform","apply", …], check=True). CDKTF (CDK for Terraform) lets you author HCL from
Python.ansible-runner from Python code.# Pulumi snippet
import pulumi
import pulumi_aws as aws
bucket = aws.s3.Bucket("logs", versioning=aws.s3.BucketVersioningArgs(enabled=True))
pulumi.export("bucket_name", bucket.id)
For wrapping an existing Terraform project:
subprocess.run(["terraform","-chdir=infra","init"], check=True)
subprocess.run(["terraform","-chdir=infra","apply","-auto-approve"], check=True)
python -m pip install -e .[dev] then pytest and mypy..gitlab-ci.yml.Jenkinsfile with a python stage, or
pipenv/poetry/uv step.A representative GitHub Actions job for a Python tool:
# .github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: python -m pip install -U pip
- run: python -m pip install -e '.[dev]'
- run: ruff check .
- run: mypy src
- run: pytest --cov=src
For releases, tag-driven: build with python -m build, upload with
twine upload, or use python-semantic-release / release-please.
multiprocessing or concurrent.futures.ProcessPoolExecutor.
For I/O-bound work, threads or asyncio are fine.subprocess.run(..., shell=True, …) with f-strings is the most
common foot-gun in ops Python. Use a list and avoid shell=True.encoding=. Default differs
across platforms (UTF-8 on Linux, sometimes cp1252 on Windows).os.environ is a dict — but mutating it is process-wide. Use
subprocess.run(..., env=…) to scope changes to a child process.pip install without a virtualenv on a system Python is the road to
pain. Always virtualenv.| Symptom | Likely cause | Fix |
|---|---|---|
ModuleNotFoundError after install | wrong interpreter | python -m pip install … (use the python you'll run with) |
| Tests pass locally, fail in CI | env-dependent path / time / locale | pin LANG=C.UTF-8, freeze the timezone, mock time |
subprocess hangs | child waiting on stdin | pass stdin=subprocess.DEVNULL or input=… |
requests.get hangs forever | no timeout | always set timeout= |
YAML loading throws on !! tags | safe_load rejects custom tags | good — use safe_load and avoid them |
| Import works in REPL, not in script | cwd differs; package not on PYTHONPATH | install editable: pip install -e . |
The single most-used module in ops Python. Memorise these shapes and you'll handle 95% of real cases.
import subprocess
def run(cmd, **kw):
"""Run cmd as a list; capture stdout/stderr; raise on non-zero exit."""
return subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
**kw,
)
r = run(["git", "rev-parse", "HEAD"])
sha = r.stdout.strip()
check=True raises CalledProcessError on non-zero exit. text=True
returns str instead of bytes. capture_output=True collects both
streams; if you only need stdout, use stdout=subprocess.PIPE.
import subprocess
with subprocess.Popen(
["rsync", "-av", "/src/", "/dst/"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
) as p:
for line in p.stdout:
print(line.rstrip())
# log it, parse it, whatever
if p.returncode != 0:
raise RuntimeError(f"rsync failed: exit {p.returncode}")
bufsize=1 enables line buffering; without it, output is block-buffered
and you see nothing until the child exits. Merging stderr into stdout
with stderr=subprocess.STDOUT keeps the lines in original order.
shell=True)import subprocess
p1 = subprocess.Popen(["dpkg", "-l"], stdout=subprocess.PIPE)
p2 = subprocess.Popen(["grep", "linux-image"], stdin=p1.stdout,
stdout=subprocess.PIPE, text=True)
p1.stdout.close() # let p1 receive SIGPIPE if p2 exits early
out, _ = p2.communicate()
Or, the often-cleaner shape: capture the first command's output and
feed it as input= to the second:
listing = subprocess.run(["dpkg", "-l"], capture_output=True, text=True, check=True).stdout
matches = subprocess.run(["grep", "linux-image"], input=listing,
capture_output=True, text=True).stdout
import subprocess
try:
r = subprocess.run(
["slow-thing", "--input", path],
capture_output=True, text=True, timeout=30,
)
except subprocess.TimeoutExpired as e:
# e.stdout / e.stderr contain whatever was captured before the kill
raise RuntimeError(f"slow-thing exceeded 30s") from e
timeout= raises after that many seconds and kills the child process.
import os, subprocess
env = {
"PATH": "/usr/local/bin:/usr/bin:/bin",
"HOME": "/var/lib/myapp",
"LC_ALL": "C.UTF-8",
}
subprocess.run(
["./deploy.sh", "v42"],
env=env, # totally clean environment
cwd="/opt/myapp",
user="myapp", # 3.9+
group="myapp",
check=True,
)
Pass an explicit env= to avoid leaking secrets via inherited environment.
The user= / group= args (Python 3.9+) drop privileges before exec —
useful when the parent runs as root.
key = subprocess.run(
["openssl", "rand", "-base64", "32"],
capture_output=True, text=True, check=True,
).stdout.strip()
# pipe a value into another tool's stdin
subprocess.run(
["base64", "-d"],
input="aGVsbG8=\n",
text=True, check=True,
)
import subprocess
p = subprocess.Popen(
["./worker", "--config", "x.toml"],
stdout=open("/var/log/worker.log", "ab"),
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
start_new_session=True, # detach from parent's process group
)
print(f"started pid={p.pid}")
# parent can now exit; child keeps running
| Pattern | Problem | Fix |
|---|---|---|
subprocess.run(f"grep {pat} {file}", shell=True) | shell injection | use a list: ["grep", pat, file] |
subprocess.call(...) then check return code by hand | misses errors silently | use run(..., check=True) |
Forgetting text=True | get bytes, accidentally compare to str | always set text=True for ops scripts |
No timeout= on calls to remote / network tools | hangs forever | set a sensible deadline always |
Reading stdout after wait() | deadlocks on large outputs | use communicate() or stream as it runs |
pathlib.Path is the modern way to handle paths. It's worth re-learning
the patterns explicitly because os.path muscle memory is sticky.
from pathlib import Path
base = Path("/var/log")
# Direct children
for p in base.iterdir():
if p.is_file():
print(p.name, p.stat().st_size)
# Glob (top level only)
for p in base.glob("*.log"):
...
# Recursive glob
for p in base.rglob("*.gz"):
...
# Combined predicates
big = [p for p in base.rglob("*") if p.is_file() and p.stat().st_size > 100 * 1024 * 1024]
from pathlib import Path
p = Path("config.toml")
text = p.read_text(encoding="utf-8")
p.write_text(new_text, encoding="utf-8")
# Atomic write: write to a sibling temp file, fsync, rename
import os, tempfile
def atomic_write_text(path: Path, content: str, encoding="utf-8"):
path = Path(path)
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding=encoding) as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path) # atomic on POSIX and Windows
except Exception:
Path(tmp).unlink(missing_ok=True)
raise
from pathlib import Path
p = Path("/var/log/nginx/access.log.1.gz")
p.name # 'access.log.1.gz'
p.stem # 'access.log.1' — last dot only
p.suffix # '.gz'
p.suffixes # ['.log', '.1', '.gz']
p.parent # PosixPath('/var/log/nginx')
p.parents[1] # PosixPath('/var/log')
p.parts # ('/', 'var', 'log', 'nginx', 'access.log.1.gz')
# Join (use / operator, not concatenation)
out = p.parent / (p.stem + ".rotated.gz")
# Replace a suffix
p.with_suffix(".bak") # PosixPath('/var/log/nginx/access.log.1.bak')
# Test
p.exists(); p.is_file(); p.is_dir(); p.is_symlink()
# Resolve (absolute, follow symlinks; raises if missing on 3.11+ unless strict=False)
p.resolve(strict=False)
# Relative path
abs_log = Path("/var/log/app/foo.log")
abs_log.relative_to("/var/log") # PosixPath('app/foo.log')
from pathlib import Path
import os
# User home
Path.home()
# XDG_CONFIG_HOME with fallback (Linux/macOS); on Windows use APPDATA
def app_config_dir(name: str) -> Path:
if os.name == "nt":
return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / name
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return base / name
cfg_dir = app_config_dir("myapp")
cfg_dir.mkdir(parents=True, exist_ok=True)
from pathlib import Path
def walk(root: Path, predicate):
for p in Path(root).rglob("*"):
if predicate(p):
yield p
# All Python files modified in the last 7 days
import time
cutoff = time.time() - 7 * 86400
for p in walk(".", lambda p: p.is_file() and p.suffix == ".py" and p.stat().st_mtime > cutoff):
print(p)
requests is the de-facto sync HTTP client. httpx is a modern
alternative with the same API plus async. Patterns below are written
against requests; translate trivially to httpx.
import requests
r = requests.get("https://api.example.com/v1/things", timeout=(3.05, 30))
# (connect_timeout, read_timeout). NEVER call requests without timeout.
r.raise_for_status()
data = r.json()
A bare requests.get(url) hangs forever if the server hangs. Make a
team rule: every call has a timeout=.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def make_session(
retries=5,
backoff=0.5,
status_forcelist=(429, 500, 502, 503, 504),
pool=20,
):
s = requests.Session()
retry = Retry(
total=retries,
backoff_factor=backoff,
status_forcelist=status_forcelist,
allowed_methods=("GET", "PUT", "DELETE", "HEAD", "OPTIONS", "POST"),
respect_retry_after_header=True,
)
adapter = HTTPAdapter(max_retries=retry, pool_connections=pool, pool_maxsize=pool)
s.mount("http://", adapter)
s.mount("https://", adapter)
return s
session = make_session()
session.headers.update({
"User-Agent": "myapp/1.0 (+ops@example.com)",
"Authorization": f"Bearer {os.environ['API_TOKEN']}",
})
r = session.get("https://api.example.com/v1/things", timeout=(3, 30))
backoff_factor=0.5 produces sleeps of 0.5, 1, 2, 4, 8 seconds between
retries. respect_retry_after_header=True honours Retry-After headers
on 429/503 responses.
import requests
from pathlib import Path
def download(url: str, dst: Path, chunk=64 * 1024):
with requests.get(url, stream=True, timeout=(5, 60)) as r:
r.raise_for_status()
with open(dst, "wb") as f:
for c in r.iter_content(chunk_size=chunk):
f.write(c)
def paginate(url: str, params=None):
"""Yield records from a paginated API that returns Link headers."""
while url:
r = session.get(url, params=params, timeout=(3, 30))
r.raise_for_status()
yield from r.json()["items"]
url = r.links.get("next", {}).get("url")
params = None # only on first request
all_items = list(paginate("https://api.example.com/v1/items"))
import time, threading
class RateLimiter:
def __init__(self, rate_per_sec: float, burst: int):
self.rate = rate_per_sec
self.capacity = burst
self.tokens = burst
self.updated = time.monotonic()
self.lock = threading.Lock()
def acquire(self, n=1):
while True:
with self.lock:
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate)
self.updated = now
if self.tokens >= n:
self.tokens -= n
return
wait = (n - self.tokens) / self.rate
time.sleep(wait)
limit = RateLimiter(rate_per_sec=10, burst=20)
for item in items:
limit.acquire()
session.post(url, json=item, timeout=(3, 30))
import hashlib, hmac, secrets
def verify_sig(secret: bytes, body: bytes, signature_hex: str) -> bool:
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
return secrets.compare_digest(expected, signature_hex)
secrets.compare_digest is the constant-time comparison you must use
for any HMAC check; == leaks timing.
import csv
from pathlib import Path
# Read with header → list of dicts
with open("input.csv", newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
# Filter and write
keep = [r for r in rows if int(r["amount"]) > 100]
with open("output.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=rows[0].keys())
w.writeheader()
w.writerows(keep)
newline="" is required on all platforms. CSV files written without
it pick up extra blank lines on Windows.
import json
from pathlib import Path
# Read
data = json.loads(Path("config.json").read_text(encoding="utf-8"))
# Write — pretty, deterministic, UTF-8
Path("out.json").write_text(
json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
encoding="utf-8",
)
# Decode datetimes / Decimals (they aren't JSON natively)
import datetime, decimal
def default(o):
if isinstance(o, (datetime.datetime, datetime.date)):
return o.isoformat()
if isinstance(o, decimal.Decimal):
return str(o)
raise TypeError(f"not JSON-serialisable: {type(o)}")
json.dumps(obj, default=default)
import yaml
# ALWAYS use safe_load; load() can execute arbitrary Python via tags
data = yaml.safe_load(Path("config.yml").read_text(encoding="utf-8"))
# Dump in block style with stable key order
print(yaml.safe_dump(data, default_flow_style=False, sort_keys=False))
# 3.11+
import tomllib
data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
# Pre-3.11: pip install tomli, then `import tomli as tomllib`
# Writing TOML: pip install tomli-w
import tomli_w
Path("out.toml").write_bytes(tomli_w.dumps(data).encode("utf-8"))
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader("templates"),
autoescape=select_autoescape(["html", "xml"]),
keep_trailing_newline=True,
trim_blocks=True,
lstrip_blocks=True,
)
tpl = env.get_template("nginx.conf.j2")
rendered = tpl.render(servers=servers, upstream="api")
Path("/etc/nginx/conf.d/api.conf").write_text(rendered, encoding="utf-8")
autoescape matters when the output is HTML/XML and any input might
come from outside your script. Skip it for config files where literal
values are wanted.
A short collection of fixtures and parametrisation patterns that come up in ops codebases.
def test_writes_config(tmp_path):
out = tmp_path / "config.yml"
write_config(out, {"foo": "bar"})
assert out.exists()
assert "foo: bar" in out.read_text(encoding="utf-8")
def test_prints_warning(capsys):
do_thing_with_warning()
captured = capsys.readouterr()
assert "WARNING" in captured.err
import pytest
@pytest.mark.parametrize(
"input,expected",
[
("", 0),
("a", 1),
("a,b,c", 3),
("a, b, c", 3), # whitespace tolerant
],
ids=["empty", "single", "no-space", "with-space"],
)
def test_count_csv_fields(input, expected):
assert count_csv_fields(input) == expected
import pytest, subprocess
@pytest.fixture(scope="session")
def docker_pg():
"""Spin up a Postgres container for the test session."""
name = "test-pg"
subprocess.run(
["docker", "run", "-d", "--rm", "--name", name,
"-e", "POSTGRES_PASSWORD=test", "-p", "55432:5432",
"postgres:16-alpine"],
check=True,
)
try:
wait_for_port("127.0.0.1", 55432, timeout=30)
yield "postgresql://postgres:test@127.0.0.1:55432/postgres"
finally:
subprocess.run(["docker", "rm", "-f", name])
def test_query(docker_pg):
import psycopg
with psycopg.connect(docker_pg) as conn:
assert conn.execute("select 1").fetchone() == (1,)
from unittest.mock import patch
import subprocess
def test_calls_git(monkeypatch):
calls = []
def fake_run(cmd, **kw):
calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout="abc123\n", stderr="")
monkeypatch.setattr(subprocess, "run", fake_run)
sha = current_sha()
assert sha == "abc123"
assert calls == [["git", "rev-parse", "HEAD"]]
def test_at_specific_time(monkeypatch):
import datetime
fixed = datetime.datetime(2026, 1, 1, 12, 0, tzinfo=datetime.timezone.utc)
class FakeDT(datetime.datetime):
@classmethod
def now(cls, tz=None):
return fixed.astimezone(tz) if tz else fixed
monkeypatch.setattr("yourmod.datetime", FakeDT)
assert is_new_year() is True
import os, sys, pytest
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only")
def test_chmod():
...
@pytest.mark.skipif("CI" not in os.environ, reason="manual-only")
def test_against_real_aws():
...
conftest.py# conftest.py at the repo root
import os, pytest
@pytest.fixture(autouse=True)
def _utc_default(monkeypatch):
"""Force UTC for every test so date logic isn't host-timezone-dependent."""
monkeypatch.setenv("TZ", "UTC")
import time; time.tzset()
autouse=True applies the fixture to every test in scope without the
test having to ask for it. Use sparingly.
import logging, sys, os
def configure_logging(level=None):
level = level or os.environ.get("LOG_LEVEL", "INFO").upper()
fmt = "%(asctime)s %(levelname)-7s %(name)s %(message)s"
logging.basicConfig(level=level, format=fmt, datefmt="%Y-%m-%dT%H:%M:%S%z",
stream=sys.stderr)
# Quiet noisy libraries
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("botocore").setLevel(logging.WARNING)
configure_logging()
log = logging.getLogger(__name__)
log.info("starting up")
import json, logging, time
class JSONFormatter(logging.Formatter):
def format(self, record):
payload = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(record.created)),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
# Allow arbitrary extra fields via logger.info("...", extra={"key": "value"})
for k, v in record.__dict__.items():
if k in ("msg", "args", "name", "levelname", "levelno", "pathname",
"filename", "module", "exc_info", "exc_text", "stack_info",
"lineno", "funcName", "created", "msecs", "relativeCreated",
"thread", "threadName", "processName", "process", "message"):
continue
payload[k] = v
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, default=str)
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
logging.basicConfig(handlers=[handler], level=logging.INFO)
log = logging.getLogger(__name__)
log.info("user login", extra={"user_id": 42, "ip": "10.0.0.5"})
import contextvars, logging, uuid
_request_id = contextvars.ContextVar("request_id", default="-")
def new_request_id():
rid = uuid.uuid4().hex[:12]
_request_id.set(rid)
return rid
class RequestIdFilter(logging.Filter):
def filter(self, record):
record.request_id = _request_id.get()
return True
logging.getLogger().addFilter(RequestIdFilter())
# Format string can now reference %(request_id)s
import contextlib, logging, time
log = logging.getLogger(__name__)
@contextlib.contextmanager
def timed(label, level=logging.INFO):
start = time.perf_counter()
try:
yield
finally:
elapsed_ms = (time.perf_counter() - start) * 1000
log.log(level, f"{label} took {elapsed_ms:.1f} ms",
extra={"event": "timed", "label": label, "ms": elapsed_ms})
with timed("fetch users"):
users = api.fetch_users()
import argparse, sys
def build_parser():
p = argparse.ArgumentParser(
prog="myop",
description="Operate the things.",
)
p.add_argument("--config", "-c", default="config.yml", help="config file path")
p.add_argument("--verbose", "-v", action="count", default=0, help="repeat for more")
sub = p.add_subparsers(dest="cmd", required=True)
do = sub.add_parser("deploy", help="deploy a release")
do.add_argument("version")
do.add_argument("--target", required=True)
do.add_argument("--dry-run", action="store_true")
return p
def main(argv=None):
args = build_parser().parse_args(argv)
if args.cmd == "deploy":
return cmd_deploy(args)
return 0
if __name__ == "__main__":
sys.exit(main())
import click
@click.group()
@click.option("--config", "-c", default="config.yml", show_default=True)
@click.pass_context
def cli(ctx, config):
ctx.obj = load_config(config)
@cli.command()
@click.argument("version")
@click.option("--target", required=True)
@click.option("--dry-run", is_flag=True)
@click.pass_obj
def deploy(cfg, version, target, dry_run):
"""Deploy VERSION to TARGET."""
do_deploy(cfg, version, target, dry_run=dry_run)
if __name__ == "__main__":
cli()
def prompt(question: str, default: str | None = None) -> str:
suffix = f" [{default}]" if default is not None else ""
while True:
ans = input(f"{question}{suffix}: ").strip()
if ans:
return ans
if default is not None:
return default
def confirm(question: str, default=False) -> bool:
suffix = "[Y/n]" if default else "[y/N]"
ans = input(f"{question} {suffix} ").strip().lower()
if not ans:
return default
return ans in ("y", "yes")
import getpass
pw = getpass.getpass("Password: ")
import boto3
from botocore.config import Config
session = boto3.Session(profile_name="prod") # or leave blank for default chain
s3 = session.client(
"s3",
region_name="us-east-1",
config=Config(retries={"mode": "standard", "max_attempts": 5}),
)
# List a bucket, with paginator (handles continuation tokens)
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket="my-bucket", Prefix="logs/"):
for obj in page.get("Contents", []):
print(obj["Key"], obj["Size"])
import mimetypes
def upload(path, bucket, key):
ct, _ = mimetypes.guess_type(path)
s3.upload_file(
Filename=str(path),
Bucket=bucket,
Key=key,
ExtraArgs={"ContentType": ct or "application/octet-stream"},
)
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": "my-bucket", "Key": "report.pdf"},
ExpiresIn=900,
)
import docker
client = docker.from_env()
container = client.containers.run(
"alpine:3.20",
command=["sh", "-c", "echo hello && sleep 1 && echo world"],
detach=True,
remove=False,
)
try:
rc = container.wait()["StatusCode"]
logs = container.logs().decode("utf-8")
print(rc, logs)
finally:
container.remove(force=True)
from kubernetes import client, config
config.load_kube_config() # ~/.kube/config
# config.load_incluster_config() # when running inside a pod
v1 = client.CoreV1Api()
apps = client.AppsV1Api()
# List pods in a namespace
for pod in v1.list_namespaced_pod("default").items:
print(pod.metadata.name, pod.status.phase)
# Scale a Deployment
apps.patch_namespaced_deployment_scale(
name="api",
namespace="default",
body={"spec": {"replicas": 6}},
)
# Exec into a pod
from kubernetes.stream import stream
out = stream(
v1.connect_get_namespaced_pod_exec,
"api-abc123", "default",
command=["sh", "-c", "uname -a"],
stderr=True, stdin=False, stdout=True, tty=False,
)
print(out)
import yaml, textwrap, subprocess
from pathlib import Path
values = {
"image": {"repository": "myco/api", "tag": "v42"},
"replicas": 6,
"env": [{"name": "LOG_LEVEL", "value": "INFO"}],
}
Path("/tmp/values.yaml").write_text(yaml.safe_dump(values, sort_keys=False))
subprocess.run(
["helm", "upgrade", "--install", "api", "./charts/api",
"--namespace", "default", "-f", "/tmp/values.yaml"],
check=True,
)
import json, subprocess
from pathlib import Path
def tf(*args, cwd="terraform"):
return subprocess.run(["terraform", *args], cwd=cwd, check=True,
capture_output=True, text=True)
tf("init", "-input=false")
tf("plan", "-out=tfplan", "-input=false")
tf("apply", "-input=false", "-auto-approve", "tfplan")
# Read structured output
out = json.loads(tf("output", "-json").stdout)
api_url = out["api_url"]["value"]
For richer interactions look at python-terraform or tftest, but for
most ops scripts the subprocess shape above is the right amount of glue.
import os, yaml
from pathlib import Path
DEFAULTS = {
"log_level": "INFO",
"timeout": 30,
"endpoint": "https://api.example.com",
}
def load_config(path: Path | str = "config.yml") -> dict:
data = dict(DEFAULTS)
p = Path(path)
if p.exists():
data.update(yaml.safe_load(p.read_text(encoding="utf-8")) or {})
# Env overrides — uppercase the key, prefix MYAPP_
for k in list(data):
v = os.environ.get(f"MYAPP_{k.upper()}")
if v is not None:
data[k] = v
return data
import keyring
keyring.set_password("myapp", "api-token", "s3cr3t")
tok = keyring.get_password("myapp", "api-token")
keyring reads from the OS-native store: macOS Keychain, Windows
Credential Manager, Secret Service / kwallet on Linux.
def get_param(name: str, decrypt=True) -> str:
return session.client("ssm").get_parameter(
Name=name, WithDecryption=decrypt,
)["Parameter"]["Value"]
def get_secret(name: str) -> str:
return session.client("secretsmanager").get_secret_value(SecretId=name)["SecretString"]
Configure logging to filter known secret keys:
import logging, re
class SecretsFilter(logging.Filter):
PATTERNS = [
re.compile(r"(token|secret|password|api[_-]?key)\s*=\s*\S+", re.I),
re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key
]
REPLACEMENT = "***"
def filter(self, record):
msg = record.getMessage()
for p in self.PATTERNS:
msg = p.sub(self.REPLACEMENT, msg)
record.msg = msg
record.args = ()
return True
logging.getLogger().addFilter(SecretsFilter())
This is a defense-in-depth measure; never substitute it for not putting secrets in log messages in the first place.
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch(url):
return requests.get(url, timeout=(3, 15)).status_code
with ThreadPoolExecutor(max_workers=32) as ex:
futures = {ex.submit(fetch, u): u for u in urls}
for fut in as_completed(futures):
url = futures[fut]
try:
print(url, fut.result())
except Exception as e:
print(url, "ERROR", e)
from concurrent.futures import ProcessPoolExecutor
def parse(blob: bytes) -> dict:
# CPU-heavy; threads wouldn't help due to the GIL
return heavy_parse(blob)
with ProcessPoolExecutor(max_workers=os.cpu_count()) as ex:
results = list(ex.map(parse, blobs, chunksize=4))
import asyncio, httpx
async def fetch(client, url):
r = await client.get(url, timeout=15)
return url, r.status_code
async def main(urls):
async with httpx.AsyncClient(http2=True) as client:
tasks = [fetch(client, u) for u in urls]
for coro in asyncio.as_completed(tasks):
print(await coro)
asyncio.run(main(urls))
A single asyncio loop will happily drive thousands of in-flight HTTP
requests without the per-thread overhead. The cost is that the whole
codepath must be async; mixing sync requests into an async function
defeats the purpose.
sem = asyncio.Semaphore(50)
async def fetch_bounded(client, url):
async with sem:
return await client.get(url, timeout=15)
| Module | Use it for |
|---|---|
subprocess | Run external commands |
pathlib | Path manipulation, glob, simple read/write |
os / os.path | Lower-level filesystem and process bits |
shutil | High-level file ops: copy, move, archive, disk_usage, which |
tempfile | Temp files, temp dirs (cleaned up automatically) |
argparse | CLI parsing, stdlib-only |
logging | Structured logging, no external dep |
json / csv / tomllib | Built-in serializers |
re | Regular expressions |
datetime / zoneinfo | Time and timezone handling (3.9+ for zoneinfo) |
hashlib / hmac / secrets | Hashing, MAC, cryptographic randomness |
concurrent.futures | Thread / process pools, with a tidy API |
asyncio | High-concurrency I/O |
socket | Low-level networking; port checks |
http.server | One-line static fileserver for debugging |
urllib.parse | URL parsing without requests |
configparser | Reading INI files |
signal | Trap signals in long-running scripts |
contextlib | @contextmanager, ExitStack, suppress |
dataclasses | Lightweight typed records |
enum | Symbolic constants |
functools | lru_cache, cached_property, reduce, partial |
itertools | chain, groupby, islice, tee, pairwise (3.10+) |
collections | Counter, defaultdict, deque, ChainMap |
statistics | mean, median, quantiles — basic stats without numpy |
traceback | Format exceptions for logs |
unittest.mock | Mocking inside tests, even with pytest |
If a stdlib module covers your need, prefer it over a third-party dep. The fewer dependencies in an ops script, the longer it stays runnable without maintenance.
A short collection of the patterns that come up when Python is the glue between source control, the IaC tool, and the cloud target. Each recipe is self-contained.
import json, subprocess
from pathlib import Path
class TerraformRunner:
def __init__(self, workdir: Path, env: dict | None = None):
self.workdir = Path(workdir)
self.env = env
def _run(self, *args: str, capture: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(
["terraform", *args],
cwd=self.workdir,
env=self.env,
check=True,
text=True,
capture_output=capture,
)
def init(self, *extra: str) -> None:
self._run("init", "-input=false", "-no-color", *extra, capture=False)
def plan(self, var_file: Path | None = None, out: Path = Path("tfplan")) -> bool:
args = ["plan", "-input=false", "-no-color", "-detailed-exitcode", f"-out={out}"]
if var_file:
args.append(f"-var-file={var_file}")
try:
self._run(*args, capture=False)
return False # exit 0 → no changes
except subprocess.CalledProcessError as e:
if e.returncode == 2:
return True # exit 2 → diff present
raise
def apply(self, plan: Path = Path("tfplan")) -> None:
self._run("apply", "-input=false", "-no-color", str(plan), capture=False)
def output(self) -> dict:
r = self._run("output", "-json")
return {k: v["value"] for k, v in json.loads(r.stdout).items()}
tf = TerraformRunner(Path("infra/prod"))
tf.init()
if tf.plan(var_file=Path("prod.tfvars")):
tf.apply()
out = tf.output()
print(out["api_url"])
-detailed-exitcode is the key trick: code 0 = no diff, 1 = error,
2 = diff present. Ideal for CI gating.
import pulumi
import pulumi_aws as aws
config = pulumi.Config()
env = config.require("env")
region = config.get("region") or "us-east-1"
bucket = aws.s3.BucketV2(
f"app-{env}-data",
tags={"Environment": env, "ManagedBy": "pulumi"},
)
aws.s3.BucketVersioningV2(
f"app-{env}-data-versioning",
bucket=bucket.id,
versioning_configuration={"status": "Enabled"},
)
pulumi.export("bucket", bucket.id)
Pulumi is the "Python instead of HCL" story for Terraform-shaped work.
You get loops, conditionals, real types, and import — and you give
up the strict immutability guarantees of HCL planning.
from aws_cdk import (
App, Stack, Duration,
aws_lambda as _lambda,
aws_apigateway as apigw,
)
from constructs import Construct
class ApiStack(Stack):
def __init__(self, scope: Construct, id: str, **kw):
super().__init__(scope, id, **kw)
fn = _lambda.Function(
self, "HelloFn",
runtime=_lambda.Runtime.PYTHON_3_12,
handler="index.handler",
code=_lambda.Code.from_asset("./lambda"),
timeout=Duration.seconds(10),
memory_size=256,
)
apigw.LambdaRestApi(self, "HelloApi", handler=fn)
app = App()
ApiStack(app, "hello-prod", env={"region": "us-east-1"})
app.synth()
CDK synthesises CloudFormation from Python and applies it. Choice between CDK / Terraform / Pulumi is mostly about which control plane your team already operates.
import subprocess, json
from pathlib import Path
def ansible_run(playbook: Path, inventory: Path, extra_vars: dict | None = None,
check: bool = False) -> int:
cmd = ["ansible-playbook", str(playbook), "-i", str(inventory)]
if check:
cmd.append("--check")
if extra_vars:
cmd += ["-e", json.dumps(extra_vars)]
return subprocess.run(cmd, check=True).returncode
ansible_run(Path("site.yml"), Path("inventory/prod"),
extra_vars={"version": "v42"}, check=True) # dry run
ansible_run(Path("site.yml"), Path("inventory/prod"),
extra_vars={"version": "v42"}) # apply
For deeper integration there's ansible-runner (a library wrapper
designed for this) but most teams stop at the subprocess shape above.
from textwrap import dedent
import base64
cloud_init = dedent("""\
#cloud-config
package_update: true
packages:
- nginx
- jq
write_files:
- path: /etc/nginx/sites-available/default
owner: root:root
permissions: '0644'
content: |
server {
listen 80 default_server;
root /var/www/html;
}
runcmd:
- systemctl enable --now nginx
""")
user_data = base64.b64encode(cloud_init.encode()).decode()
# Pass as -user-data to the cloud SDK call you're using
#cloud-config is the YAML format cloud-init understands. Writing
it from a Jinja2 template with rendered values is a common pattern.
import subprocess, sys
def has_drift() -> bool:
r = subprocess.run(
["terraform", "plan", "-detailed-exitcode", "-no-color",
"-lock-timeout=60s"],
cwd="infra/prod",
)
if r.returncode == 0:
return False
if r.returncode == 2:
return True
raise SystemExit("terraform plan failed")
if __name__ == "__main__":
sys.exit(0 if not has_drift() else 1)
Run hourly; alert on non-zero exit. Catches the "someone made a console change" scenario before it becomes an incident.
Patterns for AWS Lambda, GCP Cloud Functions, and Azure Functions written in Python.
import json, logging, os
logger = logging.getLogger()
logger.setLevel(os.environ.get("LOG_LEVEL", "INFO"))
def handler(event, context):
logger.info("invoked", extra={
"request_id": context.aws_request_id,
"function": context.function_name,
})
body = json.loads(event.get("body") or "{}")
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"ok": True, "received": body}),
}
Three things move the needle, in priority order:
import boto3
s3 = boto3.client("s3") # one-time init
def handler(event, context):
return s3.get_object(...)
Most function-platforms guarantee at-least-once delivery. Idempotency must be in your code:
import boto3, hashlib, time
from botocore.exceptions import ClientError
ddb = boto3.client("dynamodb")
TABLE = "idempotency-records"
def already_processed(key: str, ttl_sec: int = 3600) -> bool:
try:
ddb.put_item(
TableName=TABLE,
Item={
"id": {"S": key},
"ttl": {"N": str(int(time.time()) + ttl_sec)},
},
ConditionExpression="attribute_not_exists(id)",
)
return False
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
return True
raise
def handler(event, context):
key = hashlib.sha256(event["body"].encode()).hexdigest()
if already_processed(key):
return {"statusCode": 200, "body": "already-processed"}
do_the_work(event)
return {"statusCode": 200, "body": "ok"}
The "use DynamoDB with a conditional put" pattern works equivalently on Cloud Firestore and Azure Cosmos.
Lambda's hard 15-minute timeout is the wall most workloads hit first. The orchestration patterns:
BatchSize=1.ecs.run_task(...).import json, importlib.util, sys
from pathlib import Path
def load_handler(path: Path):
spec = importlib.util.spec_from_file_location("lambda_module", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.handler
class FakeContext:
aws_request_id = "test-req-1"
function_name = "test-fn"
memory_limit_in_mb = 256
def get_remaining_time_in_millis(self): return 30000
if __name__ == "__main__":
handler = load_handler(Path(sys.argv[1]))
event = json.loads(Path(sys.argv[2]).read_text())
print(json.dumps(handler(event, FakeContext()), indent=2, default=str))
python harness.py path/to/handler.py path/to/event.json runs the
handler against a stored event payload — the kind you can copy out
of CloudWatch logs.
Function logs are billed by volume on every cloud. Practical rules:
The "wrap an sklearn or PyTorch model in an HTTP service" pattern, done carefully enough not to embarrass yourself in production.
import os, joblib
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import numpy as np
MODEL_PATH = os.environ.get("MODEL_PATH", "model.joblib")
model = joblib.load(MODEL_PATH)
classes = list(getattr(model, "classes_", []))
app = FastAPI(title="classifier", version=os.environ.get("VERSION", "dev"))
class Request(BaseModel):
features: list[float]
@app.get("/healthz")
def healthz():
return {"ok": True, "model": MODEL_PATH, "version": app.version}
@app.post("/predict")
def predict(req: Request):
x = np.array([req.features])
if x.shape[1] != model.n_features_in_:
raise HTTPException(400, f"expected {model.n_features_in_} features")
proba = model.predict_proba(x)[0].tolist()
return {
"label": classes[int(np.argmax(proba))],
"proba": dict(zip(classes, proba)),
}
Run with uvicorn module:app --workers 4 behind a reverse proxy.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY model.joblib service.py ./
ENV MODEL_PATH=/app/model.joblib
EXPOSE 8000
CMD ["uvicorn", "service:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
Two production hardenings to add:
RUN useradd -r app && USER app) so the
container can run on locked-down clusters.HEALTHCHECK instruction or matching Kubernetes liveness
probe pointing at /healthz.The minimum viable model-version metadata:
META = {
"model_path": MODEL_PATH,
"model_sha256": hashlib.sha256(Path(MODEL_PATH).read_bytes()).hexdigest(),
"trained_at": os.environ.get("TRAINED_AT"),
"training_commit": os.environ.get("TRAINING_COMMIT"),
"feature_names": getattr(model, "feature_names_in_", None).tolist(),
"version": os.environ.get("VERSION", "dev"),
}
Expose it on /version or include it in every response. Without this,
a bad prediction six weeks from now is impossible to triage.
joblib/pickle files are tied to the
exact Python and library versions used to save them. Pin those in
your container.Patterns that repeatedly come up in "convert this old thing to a new thing" projects.
import re, html2text
from pathlib import Path
from urllib.parse import urlparse
t = html2text.HTML2Text()
t.body_width = 0 # don't re-wrap; preserve original line shape
t.ignore_links = False
t.ignore_images = False
def slugify(s: str) -> str:
s = re.sub(r"[^\w\s-]", "", s.lower()).strip()
return re.sub(r"\s+", "-", s)[:80]
def convert(html_path: Path, out_dir: Path) -> Path:
html = html_path.read_text(encoding="utf-8", errors="replace")
title_m = re.search(r"<title>(.+?)</title>", html, re.I | re.S)
title = title_m.group(1).strip() if title_m else html_path.stem
body = t.handle(html)
front = (
"---\n"
f"title: \"{title}\"\n"
f"slug: \"{slugify(title)}\"\n"
"---\n\n"
)
out = out_dir / f"{slugify(title)}.md"
out.write_text(front + body, encoding="utf-8")
return out
for src in Path("legacy").rglob("*.html"):
convert(src, Path("content"))
Three things to plan for in any conversion:
htaccess / nginx map directive / S3 redirect
rules.<img src=> paths usually need rewriting.
Walk the converted Markdown after conversion and fix paths in a
second pass.slug, date,
taxonomies, aliases. Generate them programmatically from the
source rather than hand-editing.aws s3 sync from Python with content-type fixupimport mimetypes, boto3
from pathlib import Path
s3 = boto3.client("s3")
mimetypes.add_type("text/css; charset=utf-8", ".css")
mimetypes.add_type("application/javascript; charset=utf-8", ".js")
def sync(src: Path, bucket: str, prefix: str = "") -> None:
for p in src.rglob("*"):
if not p.is_file():
continue
key = (prefix + str(p.relative_to(src))).lstrip("/")
ct, _ = mimetypes.guess_type(p.name)
s3.upload_file(
str(p), bucket, key,
ExtraArgs={
"ContentType": ct or "application/octet-stream",
"CacheControl": "public, max-age=300, must-revalidate",
},
)
aws s3 sync is faster but gets ContentType wrong for some assets;
this version is explicit about it.
import subprocess, sys
r = subprocess.run(["git", "diff", "--stat", "HEAD~1", "HEAD"],
capture_output=True, text=True, check=True)
if "site/" not in r.stdout and "content/" not in r.stdout:
print("no site changes since last commit; skipping deploy.")
sys.exit(0)
A small guard that saves CI minutes and keeps invalidations cheap.
| The thing you're building | Pattern |
|---|---|
| 50-line glue between two CLIs | bash one-shot in bin/ |
| 200-line script with arg parsing | Python script + argparse, single file |
| Multiple subcommands | Python package + click group, installed with pip install -e . |
| HTTP service | FastAPI + uvicorn; container behind a reverse proxy |
| Background processor | redis/sqs/celery consumer; deployed as a Deployment + readiness probe |
| Periodic batch job | Kubernetes CronJob or AWS EventBridge → Lambda |
| Long-running ETL | Airflow / Dagster / Prefect DAG; or Step Functions for cloud-native |
| One-shot infra change | Pulumi/CDK/Terraform run from CI on merge |
| ML inference endpoint | FastAPI wrapper → container → K8s Deployment with HPA |
| Build-time tool only | make target calling a Python script; never deploy it |
| Fast-changing logic | feature flag + config file, not another redeploy path |