Python for Infrastructure Automation›05 · Command-line tools

Lesson 05 of 9 · Modules

Command-line tools

Turn scripts into proper command-line tools: argparse with subcommands and help, logging instead of print, configuration from file, environment and flags in a clear order, dry runs, and machine-readable output.

Practitioner
Key wordsargparsesubcommandsloggingconfig precedenceexit codes--dry-run--output json

From script to tool

A script is something you run. A tool is something others can run correctly without reading the source: it has --help, clear errors, sensible defaults, a dry run, and output other programs can use.

A script is a note you wrote for yourself: only you understand the shorthand. A tool is a vending machine: labelled buttons (options), a screen explaining what went wrong ("out of stock"), and it never takes your money without giving something back.

A tool with subcommands

#!/usr/bin/env python3
"""nodectl: small helpers for Kubernetes node maintenance."""
import argparse, json, logging, sys

log = logging.getLogger("nodectl")

def cmd_list(args: argparse.Namespace) -> int:
    nodes = [{"name": "w1", "ready": True}, {"name": "w2", "ready": False}]   # placeholder data
    if args.output == "json":
        print(json.dumps(nodes))
    else:
        for n in nodes:
            print(f"{n['name']:<10} {'Ready' if n['ready'] else 'NotReady'}")
    return 0

def cmd_drain(args: argparse.Namespace) -> int:
    if args.dry_run:
        log.info("dry run: would drain %s", args.node)
        return 0
    log.info("draining %s", args.node)
    # ... real work here (lesson 06) ...
    return 0

def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(prog="nodectl", description=__doc__)
    p.add_argument("-v", "--verbose", action="store_true", help="debug logging")
    sub = p.add_subparsers(dest="cmd", required=True)

    pl = sub.add_parser("list", help="list nodes")
    pl.add_argument("-o", "--output", choices=["table", "json"], default="table")
    pl.set_defaults(func=cmd_list)

    pd = sub.add_parser("drain", help="drain a node")
    pd.add_argument("node")
    pd.add_argument("--dry-run", action="store_true")
    pd.set_defaults(func=cmd_drain)

    args = p.parse_args(argv)
    logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
                        format="%(asctime)s %(levelname)s %(name)s: %(message)s")
    return args.func(args)

if __name__ == "__main__":
    sys.exit(main())
$ ./nodectl.py --help
usage: nodectl [-h] [-v] {list,drain} ...
$ ./nodectl.py list -o json
[{"name": "w1", "ready": true}, {"name": "w2", "ready": false}]
$ ./nodectl.py drain w2 --dry-run
2026-09-27 12:40:02,114 INFO nodectl: dry run: would drain w2

Notice: logs go to stderr (logging's default), and results go to stdout, so ./nodectl.py list -o json | jq works.

Configuration precedence

Users configure tools in several places. Resolve them in one clear order, from least to most specific:

built-in default → config file → environment variable → command-line flag

import os
from pathlib import Path
import yaml

def load_settings(args) -> dict:
    settings = {"namespace": "default", "timeout": 60}                        # defaults
    cfg = Path.home() / ".config" / "nodectl.yaml"
    if cfg.exists():
        settings.update(yaml.safe_load(cfg.read_text()) or {})               # config file
    if "NODECTL_NAMESPACE" in os.environ:
        settings["namespace"] = os.environ["NODECTL_NAMESPACE"]              # environment
    if getattr(args, "namespace", None):
        settings["namespace"] = args.namespace                              # flag wins
    return settings

Exit codes and errors

  • 0 success; 1 general failure; 2 usage error (argparse already uses 2 for bad arguments).
  • Print a one-line, actionable message for expected failures (cannot reach API server at …: check KUBECONFIG), and let unexpected ones show a traceback, or log it at DEBUG level.

Try it: build your own ops CLI

  1. Create opsctl.py with subcommands disk (reports usage for given mounts) and services (checks systemd services), reusing code from lessons 01 and 03.
  2. Add -o/--output table|json to both, and -v for debug logs.
  3. Add a config file ~/.config/opsctl.yaml with default mounts and services, overridable by OPSCTL_MOUNTS and flags.
  4. Make it exit 1 when any check is CRITICAL or any service is down, so it's usable from cron or CI.
  5. Pipe the JSON output into jq to prove stdout contains only data.

Going deeper: polished tools

  • click and typer reduce boilerplate for bigger CLIs (typer builds options from type hints); rich makes human-readable tables and progress bars.
  • Package the tool with an entry point ([project.scripts] opsctl = "opsctl.cli:main" in pyproject.toml) so it installs as a real command (lesson 08).
  • Make destructive subcommands dry-run by default or require --yes, and print exactly what will change first.
  • Structured logs (JSON lines) make tools easy to observe when they run in CI or Kubernetes.

Recap

  • argparse with subcommands, typed options, defaults and --help.
  • logging (levels, format, stderr) instead of print; results on stdout.
  • Settings precedence: default < config file < env < flag.
  • Clear exit codes, --dry-run, and --output json for machines.

This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.