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.
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
0success;1general failure;2usage 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
- Create
opsctl.pywith subcommandsdisk(reports usage for given mounts) andservices(checks systemd services), reusing code from lessons 01 and 03. - Add
-o/--output table|jsonto both, and-vfor debug logs. - Add a config file
~/.config/opsctl.yamlwith default mounts and services, overridable byOPSCTL_MOUNTSand flags. - Make it exit 1 when any check is CRITICAL or any service is down, so it's usable from cron or CI.
- Pipe the JSON output into
jqto prove stdout contains only data.
Going deeper: polished tools
clickandtyperreduce boilerplate for bigger CLIs (typer builds options from type hints);richmakes human-readable tables and progress bars.- Package the tool with an entry point (
[project.scripts] opsctl = "opsctl.cli:main"inpyproject.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 jsonfor machines.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.