Designing a CLI with argparse - 3/9
Replacing manual argument parsing with argparse while keeping parsing, network behavior, output, and process termination separate.
Manual parsing taught us what a CLI must do, but the bookkeeping is already obscuring the HTTP client. argparse lets us describe the interface—arguments, options, defaults, and validation—and generates consistent help and usage errors from that description.
The important improvement is not merely fewer if statements. It is separating the command-line contract from the work performed after parsing.
Series
- From a Script to a Command-Line Tool
- When
sys.argvStops Scaling - Designing a CLI with
argparse - Why a Bash Installer Becomes a Liability
- Packaging a Python CLI with Console Scripts
- Testing a Python CLI and Automating CI
- Documentation as Part of the Product
- Building a Reliable PyPI Release Pipeline
- Publishing a Pure-Python CLI with Conda
Describe the interface first
We can express the positional URL, the two output modes, and their help text in one place:
import argparse
from urllib.parse import urlsplit
def http_url(value: str) -> str:
candidate = value if "://" in value else f"https://{value}"
parsed = urlsplit(candidate)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise argparse.ArgumentTypeError("URL must use HTTP or HTTPS")
return candidate
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="tihttp",
description="Send a GET request and print the response.",
)
parser.add_argument("url", type=http_url, help="URL to request")
output = parser.add_mutually_exclusive_group()
output.add_argument(
"-H",
"--headers-only",
action="store_const",
const="headers",
dest="section",
help="print response headers only",
)
output.add_argument(
"-B",
"--body-only",
action="store_const",
const="body",
dest="section",
help="print the response body only",
)
parser.set_defaults(section="both")
return parser
The URL converter performs validation during parsing. Raising ArgumentTypeError lets argparse report the problem using the same usage format as an unknown option or a missing argument.
The output options are mutually exclusive because “headers only” and “body only” cannot both describe the same output. With neither option, the default remains both.
Keep application behavior outside the parser
Parsing and execution now meet in a small main() function:
import sys
from typing import List, Optional
import requests
def print_response(response: requests.Response, section: str) -> None:
if section in {"headers", "both"}:
for name, value in sorted(response.headers.items()):
print(f"{name}: {value}")
if section == "both":
print()
if section in {"body", "both"}:
print(response.text)
def main(argv: Optional[List[str]] = None) -> int:
args = build_parser().parse_args(argv)
try:
response = requests.get(args.url, timeout=10)
response.raise_for_status()
except requests.RequestException as error:
print(f"tihttp: request failed: {error}", file=sys.stderr)
return 1
print_response(response, args.section)
return 0
def run() -> None:
raise SystemExit(main())
if __name__ == "__main__":
run()
There are three useful seams here:
build_parser()owns the public command-line contract.main(argv)accepts an explicit argument list, which will make testing easier.run()is the process boundary where an integer return value becomes an operating-system exit status.
Expected request failures are handled close to the HTTP boundary. An unexpected programming error is deliberately not caught by a blanket except Exception: a traceback is more useful than converting a defect into a vague “request failed” message.
Generated help is part of the interface
$ tihttp --help
usage: tihttp [-h] [-H | -B] url
Send a GET request and print the response.
positional arguments:
url URL to request
optional arguments:
-h, --help show this help message and exit
-H, --headers-only print response headers only
-B, --body-only print the response body only
Help output is not decoration. Option names, defaults, error messages, and exit codes form a public interface that users may put into scripts. Changing them deserves the same care as changing a Python function signature.
We now have a reasonable CLI, but users still need the source directory and its environment. Part 4 explores a tempting Bash-based installation approach before Python packaging gives us a better abstraction.