I built Unix-friendly Python CLIs with argparse subcommands, validated arguments, and meaningful exit codes. Following CLI conventions made the tools composable in shell pipelines and reliable in CI, where exit status drives control flow.

Objective & Context

A good CLI is predictable: clear help, validated input, and a non-zero exit on failure. This lab uses argparse subparsers and returns proper exit codes so scripts behave correctly under set -e and in automated pipelines.

Learning Objectives

  • Define subcommands with argparse subparsers.
  • Validate arguments with type callables and choices.
  • Return meaningful exit codes for scripting and CI.
  • Generate --help output automatically.
  • Follow Unix CLI conventions (stdin/stdout, exit status).

Environment & Prerequisites

Prerequisites

  • Python 3.11 with argparse (standard library)
  • A shell to test piping and exit codes (echo $?)
  • A task with at least two subcommands (scan and report are used here)
  • Assumed skill: functions and modules

Step-by-Step Execution

1. Define subcommands

cat > tyfctl.py <<'EOF' import argparse, sys def cmd_scan(args): print(f"scanning {args.target}") return 0 def cmd_report(args): print("report ok") return 0 def main(): p = argparse.ArgumentParser(prog="tyfctl") sub = p.add_subparsers(dest="cmd", required=True) sp = sub.add_parser("scan") sp.add_argument("target") sp.set_defaults(func=cmd_scan) rp = sub.add_parser("report") rp.set_defaults(func=cmd_report) args = p.parse_args() sys.exit(args.func(args)) if __name__ == "__main__": main() EOF

Expected output:

(no output; tyfctl.py written with scan and report subcommands)

What this teaches: Subparsers turn one tool into a command family; required=True makes argparse reject a bare invocation with a usage error instead of silently doing nothing. Each subparser wires its handler with set_defaults(func=...) so dispatch is one args.func(args) call, and sys.exit returns the handler's code to the shell.

2. Return a meaningful exit code

python tyfctl.py scan 192.168.20.0/24; echo "exit=$?"

Expected output:

scanning 192.168.20.0/24
exit=0

What this teaches: The exit code is the tool's machine-readable answer. CI and shell scripts branch on $? so a scan that found nothing and a scan that crashed must report differently. The ; echo "exit=$?" pattern captures the status of exactly the command before it.

3. Confirm generated help

python tyfctl.py --help

Expected output:

usage: tyfctl [-h] {scan,report} ...

What this teaches: Free --help documents every subcommand; users discover the interface without reading source, which is the difference between a script and a tool. (The full help lists each subcommand below the usage line.)

How It Works

How It Works

argparse converts a declarative description of the interface into parsing, validation, and help generation. You describe arguments once with add_argument(), and the library handles tokenizing, type conversion, error messages, and --help rendering.

Exit codes are the CLI's API for automation. By convention 0 means success and nonzero means failure, with distinct codes (1 for usage errors, 2 for runtime failures) letting callers react differently; argparse itself exits 2 on bad arguments.

Unix composability comes from three habits: read from stdin or files, write results to stdout, and diagnostics to stderr. A tool built this way plugs into pipes, xargs, and CI steps without modification, which is why the lab stresses conventions over features.

Validation & Testing

Run each subcommand with valid and invalid arguments and check the exit code in the shell. Pass criteria: usage errors exit non-zero with help, success exits 0, and the tool reads stdin/writes stdout cleanly for piping.

Advanced: Troubleshooting
  • Always exits 0: call sys.exit(code) explicitly on failure paths.
  • Broken pipes: handle BrokenPipeError when output is piped to head.
  • Unclear errors: let argparse handle validation; it prints usage and exits 2.

Troubleshooting

SymptomLikely causeFix
subcommand silently does nothingno function was wired to the parsed subcommanduse set_defaults(func=...) per subparser and call args.func(args)
error: the following arguments are requireda required argument or subcommand was omittedread the usage line argparse prints; it names exactly what is missing
type= callable raises obscure errorsthe conversion function raised ValueError without contextraise argparse.ArgumentTypeError with a clear message instead
help text wraps badlylong help strings with default formatteruse argparse.RawDescriptionHelpFormatter or shorten help strings
exit code lost in a pipelineonly the last command's status is in $?use PIPESTATUS in bash or set -o pipefail to catch mid-pipeline failures

Key Results

  • Shipped CLIs with subcommands and auto-generated help.
  • Returned correct exit codes for clean CI/pipeline integration.
  • Validated arguments declaratively via argparse.
  • Made tools composable through stdin/stdout conventions.

Key Takeaways

  • Subparsers organize multi-command tools under one entry point.
  • Exit codes are the machine-readable contract: 0 success, 1 runtime error, 2 usage error.
  • argparse generates --help for free; keep help strings accurate.
  • Validate with type callables and choices so bad input fails fast with clear errors.
  • Unix conventions (stdout results, stderr diagnostics) make tools composable.

Practice Challenges

Challenge 1: JSON output flag

Goal: Add a global --json flag that switches the report subcommand from human tables to JSON on stdout.

Hint: Add the flag to the main parser so every subcommand inherits it; branch on args.json in the handler.

Challenge 2: Dry-run mode

Goal: Add --dry-run to the scan subcommand that prints what would be scanned without touching the network.

Hint: Thread a dry_run flag through; the scan function should log targets instead of probing.