The Coding Lab
<- All posts

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

  1. From a Script to a Command-Line Tool
  2. When sys.argv Stops Scaling
  3. Designing a CLI with argparse
  4. Why a Bash Installer Becomes a Liability
  5. Packaging a Python CLI with Console Scripts
  6. Testing a Python CLI and Automating CI
  7. Documentation as Part of the Product
  8. Building a Reliable PyPI Release Pipeline
  9. 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.

Further reading

<< section 2 | section 4 >>