Learning bite
CLI tools and subprocesses
Create a predictable command interface without treating input as shell code.
On this page
Make a useful command-line interface
A command-line interface, or CLI, is a program's agreement with its caller: accepted arguments, successful output, error messages, and an exit status. A shell or CI job normally treats status 0 as success and nonzero as failure. Printing an error while returning zero makes broken automation look successful.
Keep the earlier capacity.py, read_capacity.py, and valid capacity.json in capacity-tool. Save this as capacity_cli.py:
import argparse
import json
import sys
from pathlib import Path
from read_capacity import read_capacity
def main() -> int:
parser = argparse.ArgumentParser(description="Read a capacity fixture")
parser.add_argument("config", type=Path)
args = parser.parse_args()
try:
remaining = read_capacity(args.config)
except (OSError, ValueError, TypeError) as error:
print(f"cannot read capacity: {error}", file=sys.stderr)
return 1
print(json.dumps({"remaining_gib": remaining}))
return 0
if __name__ == "__main__":
raise SystemExit(main())
argparse handles help and missing arguments. The positional config argument is required. type=Path converts its text to a Path, not a guarantee that the file exists. SystemExit transfers the integer returned by main to the process exit status.
Run these from the project root:
python capacity_cli.py --help
python capacity_cli.py capacity.json > result.json 2> error.txt
echo $?
cat result.json
cat error.txt
python capacity_cli.py does-not-exist.json > result.json 2> error.txt
echo $?
cat error.txt
The valid run should exit 0, put {"remaining_gib": 12} in result.json, and leave error.txt empty. The missing-file run should exit 1 and put the explanation in error.txt. Each redirect replaces the previous contents. Run echo $? immediately after the command whose status you need. With no argument, argparse normally exits 2 for a usage error.
Stdout is the data channel; stderr is the diagnostic channel. Extra progress text on stdout would break a caller expecting one JSON object. A larger tool can use the logging module for severity and timestamps while preserving that distinction. Never dump secrets to either channel.
Call another program deliberately
A subprocess is a child process started by your program. Prefer a library when it expresses the operation clearly. When a command is needed, resolve the executable, pass separate arguments, and bound the wait. Save and run git_version.py with Git installed:
import shutil
import subprocess
executable = shutil.which("git")
if executable is None:
raise SystemExit("git is not on PATH")
result = subprocess.run(
[executable, "--version"], check=True, capture_output=True,
text=True, timeout=5,
)
print(result.stdout.strip())
Expect your installed Git version, not a particular version number. check=True raises for a nonzero exit; timeout=5 prevents an indefinite wait; text=True decodes output. A missing executable, failed command, and timeout are distinct failures. shell=False is the default. Passing a filename as a list item does not interpret semicolons as shell instructions; building a shell=True string from user input can.
Practice and reasoning
Run the CLI with a malformed fixture and verify that it still fails nonzero. Why catch ValueError here? JSON decoding and our validation use that exception family. Why keep the calculation outside main? Tests can check it without parsing arguments or starting processes.
Large child-process output can consume memory when captured. Long-running commands may need streaming and shutdown handling; this five-second version probe does not teach those policies. Keep the CLI for the lab. Next, distinguish a process failure from a failure returned by an HTTP service.
References: argparse↗, subprocess↗, and logging↗.
Your notes and evidence
Record observations, questions, or links to your work. Keep credentials out of your notes.
Back up or restore this path
Progress and notes stay in this browser. A backup contains only this learning path.