Sheets Python for automation
Cheat sheet
Python for automation
Virtual environments, subprocess, files, JSON and YAML, HTTP, and CLI scripts with logging.
This is the slice used to automate a host, an API, or a cluster. It is not a language tutorial. Shell commands the scripts call out to are on the Linux sheet and the OpenShift sheet.
Verified against Python 3.12.3 (venv, subprocess, pathlib, json, argparse, logging) and PyYAML 6.0.1. requests, boto3, and the Kubernetes client were checked from their docs and were not imported on this machine. The interpreter names were checked against RHEL 7 (python is 2.7) and RHEL 8 and 9 (python3).
On RHEL 8 and 9 the command below is python3. On RHEL 7 the system command is python, which is Python 2.7. Python 3 is a separate package there and is not the default interpreter. python3 -m venv needs that Python 3 install.
Virtual environments and pip
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python -m pip install 'requests==2.32.3'
python -m pip freeze > requirements.txt
python -m pip list --outdated
deactivate
rm -rf .venv
python3 -m venv creates an isolated environment in .venv. source activates it in the current shell. deactivate leaves it. python -m pip uses the interpreter you just activated, so the install cannot land on a different Python than the one that runs the script. The == pin is an exact version. 2.32.3 is an example, not a requirement to stay on that release. freeze writes whatever is installed, including transitive dependencies.
pipx is a separate program for CLI tools (pipx install ansible-lint). It is not part of the standard library. Each tool gets its own environment so it does not share packages with the project.
error: externally-managed-environmentis PEP 668. Debian 12, Ubuntu 23.04 and newer, and current Fedora mark the system interpreter sopip installcannot modify it. RHEL 9 does not raise that error by default, and installing into the system Python is still the wrong place. Create a venv, or use pipx for a CLI tool.
Run shell commands
import subprocess
result = subprocess.run(
["systemctl", "is-active", "nginx"],
capture_output=True,
text=True,
check=True,
timeout=60,
)
print(result.returncode, result.stdout.strip())
import os
import shlex
import shutil
import subprocess
argv = shlex.split("oc get pods -o name")
if shutil.which("oc") is None:
raise SystemExit("oc is not on PATH")
subprocess.run(
argv,
check=True,
timeout=60,
env={**os.environ, "KUBECONFIG": "/path/to/kubeconfig"},
)
subprocess.run waits for the process. Pass the command as a list. capture_output=True and text=True put stdout and stderr in strings. check=True raises CalledProcessError on a non-zero exit. timeout raises TimeoutExpired instead of waiting forever. shlex.split turns a command string you wrote into that list. shutil.which returns None when the binary is missing. The env dict replaces the child’s environment, so copy os.environ and then add the one variable.
shell=Truehands the string to/bin/sh. Any part of that string that came from a user, a file, or an API can run extra commands. Keepshell=Falseand the list form unless you wrote the whole string yourself.
Files, paths, and environment
import os
from pathlib import Path
text = Path("/etc/os-release").read_text()
for path in Path(".").glob("*.md"):
print(path)
kubeconfig = Path.home() / ".kube" / "config"
env_name = os.environ.get("ENV", "dev")
glob("**/*.log") walks every subdirectory. The loop above stays in the current directory so copying it does not depend on a logs folder.
import shutil
import tempfile
from pathlib import Path
demo = Path("demo-out")
demo.mkdir(parents=True, exist_ok=True)
shutil.copy2("/etc/os-release", demo / "os-release.copy")
with tempfile.TemporaryDirectory() as tmp:
scratch = Path(tmp) / "work"
scratch.mkdir()
shutil.rmtree(demo)
read_text returns the whole file as a string. mkdir(parents=True, exist_ok=True) is mkdir -p. / on a Path joins without worrying about extra slashes. os.environ.get returns the default when the variable is unset. copy2 keeps permissions and times. rmtree deletes a tree. The second block removes demo-out when it finishes. TemporaryDirectory removes its own scratch directory when the block exits, including on an exception.
JSON and YAML
import json
from pathlib import Path
data = json.loads('{"name": "web"}')
print(json.dumps(data, indent=2))
path = Path("demo.json")
path.write_text(json.dumps(data))
with path.open() as handle:
data = json.load(handle)
path.unlink()
from pathlib import Path
import yaml
single = Path("demo.yaml")
single.write_text("name: web\n")
with single.open() as handle:
data = yaml.safe_load(handle)
with single.open("w") as handle:
yaml.safe_dump(data, handle, sort_keys=False)
single.unlink()
multi = Path("demo-multi.yaml")
multi.write_text("name: web\n---\nname: api\n")
with multi.open() as handle:
documents = list(yaml.safe_load_all(handle))
multi.unlink()
json is in the standard library. YAML needs PyYAML (python -m pip install pyyaml). safe_load reads one document and raises if the file has a second ---. safe_load_all is the generator for a multi-document manifest, so list pulls them all. sort_keys=False keeps the key order you wrote. From the shell, python3 -m json.tool file.json pretty-prints a JSON file.
yaml.loadwithout a safe loader can construct arbitrary Python objects. PyYAML 6.0 and newer refuse to run it until you pass aLoader. Useyaml.safe_load.
HTTP APIs
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
url = "https://example.com"
token = "replace-me"
payload = {"ok": True}
response = requests.get(
url,
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
response.raise_for_status()
data = response.json()
requests.post(url, json=payload, timeout=10)
session = requests.Session()
session.mount(
"https://",
HTTPAdapter(
max_retries=Retry(
total=3,
backoff_factor=1,
status_forcelist=[502, 503, 504],
)
),
)
timeout is seconds. raise_for_status raises on 4xx and 5xx. json= on post encodes the body and sets the content type. Retry retries idempotent methods (GET, HEAD, OPTIONS, and the others in its allow list) on those status codes. POST is not in that list unless you set allowed_methods.
requestshas no default timeout. A call without one waits until the job runner kills the job.
CLI scripts and logging
import argparse
import logging
import sys
def main() -> int:
parser = argparse.ArgumentParser(description="Rotate logs")
parser.add_argument("--env", choices=["dev", "prod"], default="dev")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
log = logging.getLogger(__name__)
log.info("deployed %s", args.env)
return 0
if __name__ == "__main__":
sys.exit(main())
choices rejects any other --env. action="store_true" is a flag that is false when absent. log.info("deployed %s", args.env) formats the message only if that level is enabled. Inside an except block, log.exception("deploy failed") logs the traceback. sys.exit returns the integer to the shell, which CI and Ansible can read.
AWS, Kubernetes, and OpenShift
import boto3
session = boto3.Session(profile_name="dev", region_name="us-east-1")
s3 = session.client("s3")
for page in s3.get_paginator("list_objects_v2").paginate(Bucket="b"):
for obj in page.get("Contents", []):
print(obj["Key"])
Name the profile and the region. A list API returns one page. The paginator follows NextToken until the bucket is exhausted. Contents is absent when the page is empty.
from kubernetes import client, config, dynamic
config.load_kube_config()
pods = client.CoreV1Api().list_namespaced_pod("team-dev")
for pod in pods.items:
print(pod.metadata.name)
route_api = dynamic.DynamicClient(client.ApiClient()).resources.get(
api_version="route.openshift.io/v1",
kind="Route",
)
load_kube_config() reads ~/.kube/config or KUBECONFIG. load_incluster_config() is the service-account token when the script runs in a pod. The typed client covers core pods. Routes are a custom resource, so the dynamic client looks them up by apiVersion and kind. Both libraries are separate installs: boto3 and kubernetes.
One-liners
python3 -m http.server 8000
python3 -c 'import sys; print(sys.version)'
python3 -m json.tool file.json
python3 -m pdb script.py
http.server serves the current directory. It has no authentication. pdb steps through the script. breakpoint() inside a file does the same thing on Python 3.7 and newer.
Works here, fails in CI
which python3
python3 --version
python -m pip --version
The interpreter and pip have to be the same environment. python -m pip is the check. Rebuild .venv from requirements.txt on the runner instead of depending on packages that were already there.
A missing token should fail in main with a clear message, before any request is sent. os.environ["TOKEN"] raises KeyError when it is unset. shutil.which("oc") should fail the same way before subprocess does. Every requests call and every subprocess.run gets a timeout.