The automation language of DevOps — when a Bash script grows past ~100 lines, this is where it goes. You'll set up clean environments, script real ops tasks (files, processes, HTTP, JSON/YAML), call cloud and Kubernetes APIs, and package tools that teammates can install. Practical and hands-on. Work top to bottom, or jump to a part.
The journey:
| You need | Why |
|---|---|
| Python 3.11+ | Modern syntax, better error messages |
| A terminal | Everything is CLI |
| Basic programming familiarity | Variables, functions, loops |
Confirm your version: python3 --version. On macOS/Linux python3 is standard; avoid the system Python for projects — use a virtual environment (Part 2).
Python is the default for automation because it has a huge standard library, first-class SDKs for every cloud and tool (AWS boto3, kubernetes, requests), and reads like pseudocode. Reach for it when:
Stay in Bash for short glue (a few piped commands); reach for a compiled language (Go/Rust) when you need a single static binary or raw performance. Python's sweet spot is the large middle: readable automation that evolves.
Never pip install into the system Python — you'll create version conflicts across projects. Use an isolated virtual environment:
python3 -m venv .venv # create an isolated env
source .venv/bin/activate # activate it (Windows: .venv\Scripts\activate)
pip install requests pyyaml # installs only into .venv
pip freeze > requirements.txt # pin what you installed
Modern projects declare dependencies in pyproject.toml and use faster tools like uv or pipx (for installing CLIs globally but isolated). The rule: dependencies live in the project, pinned and reproducible — not scattered in a shared interpreter.
The standard library covers most ops needs. Files with pathlib, external commands with subprocess, and arguments with argparse:
#!/usr/bin/env python3
import argparse, subprocess
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description="Tidy old logs")
parser.add_argument("dir", type=Path)
parser.add_argument("--days", type=int, default=7)
args = parser.parse_args()
for log in args.dir.glob("*.log"): # pathlib globbing
print(f"found {log} ({log.stat().st_size} bytes)")
# Run a shell command safely — a list, never a string, avoids injection
result = subprocess.run(["df", "-h"], capture_output=True, text=True, check=True)
print(result.stdout)
if __name__ == "__main__":
main()
Key idioms: pathlib.Path for filesystem work (not string paths), subprocess.run([...], check=True) with a list of args (never shell=True on untrusted input — that's an injection hole), and argparse for a real CLI with --help.
Most automation is calling an API and reshaping the response.
import requests, json, yaml
# Call a REST API with a timeout (always set one) and auth
resp = requests.get(
"https://api.example.com/v1/services",
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
resp.raise_for_status() # turn a 4xx/5xx into an exception
services = resp.json()
# Reshape and write YAML
config = {"services": [s["name"] for s in services if s["healthy"]]}
Path("services.yaml").write_text(yaml.safe_dump(config))
Always pass timeout= (a hung request without one blocks forever) and call raise_for_status() so failures are loud. Use yaml.safe_load/safe_dump (never plain yaml.load on untrusted input — it can execute code).
The official SDKs turn infrastructure into a few lines:
# AWS with boto3 — list running EC2 instances
import boto3
ec2 = boto3.client("ec2", region_name="us-east-1")
for r in ec2.describe_instances()["Reservations"]:
for i in r["Instances"]:
if i["State"]["Name"] == "running":
print(i["InstanceId"], i.get("PrivateIpAddress"))
# Kubernetes with the official client — list pods
from kubernetes import client, config
config.load_kube_config() # or load_incluster_config() inside a pod
v1 = client.CoreV1Api()
for pod in v1.list_namespaced_pod("default").items:
print(pod.metadata.name, pod.status.phase)
boto3 reads the same credential chain as the AWS CLI (env, profile, IAM role) — so on an EC2/EKS workload it uses the instance/pod role automatically, no keys needed. The Kubernetes client mirrors the API objects you already know from YAML.
Ops scripts fail in production; make failures clear and recoverable.
import logging, sys
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("deploy")
def deploy(env: str) -> None:
try:
log.info("deploying to %s", env)
# ... work ...
except requests.HTTPError as e:
log.error("API call failed: %s", e)
sys.exit(1) # non-zero exit so CI knows it failed
finally:
log.info("cleanup done")
Use the logging module (not print) so you get levels, timestamps, and can route to stdout/files. Catch specific exceptions, not a bare except:. Exit non-zero on failure so CI and shell callers detect it. Type hints (env: str) document intent and catch bugs with a type checker (mypy/pyright).
Automation that touches production deserves tests. pytest is the standard:
# code.py
def parse_cpu(s: str) -> int:
"""Convert '250m' or '1' to millicores."""
return int(s[:-1]) if s.endswith("m") else int(s) * 1000
# test_code.py
from code import parse_cpu
def test_parse_cpu():
assert parse_cpu("250m") == 250
assert parse_cpu("2") == 2000
pip install pytest
pytest -q
Extract pure logic (parsing, transforms) into small functions and test those directly; mock the network/cloud calls (unittest.mock or responses) so tests are fast and don't hit real infrastructure. Wire pytest into CI so a broken script never merges.
Turn a script into an installable tool with a pyproject.toml and an entry point:
# pyproject.toml
[project]
name = "opsctl"
version = "0.1.0"
dependencies = ["requests", "boto3"]
[project.scripts]
opsctl = "opsctl.cli:main" # `opsctl` runs opsctl/cli.py:main
pipx install . # install the CLI, isolated, on PATH
opsctl --help
Now teammates get a real command, not a script to copy around. For richer CLIs, libraries like Typer or Click add subcommands, help, and validation with far less boilerplate than raw argparse.
requirements.txt or a lockfile).subprocess.run([...], check=True) with a list; avoid shell=True on untrusted input.timeout= on every network call and raise_for_status().logging, not print; exit non-zero on failure.yaml.safe_load / json, never yaml.load on untrusted data.pyproject.toml + entry point) so tools are installable and versioned.shell=True with user input. Command injection. Pass a list of args instead.timeout=.except:. Hides real errors (including Ctrl-C). Catch specific exceptions.print instead of logging. No levels, no timestamps, hard to route in production.yaml.load on untrusted YAML. It can execute arbitrary code — use safe_load.argparse script that finds files over N MB under a directory and prints them, with --help working.kubectl get pods -o json via subprocess.run, parse the JSON, and print pods not in Running.requests (timeout + raise_for_status), transform the result, and write it as YAML.pytest test with two cases, and run pytest -q.pyproject.toml entry point and pipx install . your tool, then run it by name.Self-check:
subprocess.run, and why avoid shell=True?requests call include?You now have the loop: isolate the env → script the task → call APIs → handle errors → test → package. That's Python as DevOps teams actually use it.