Documentation and APIs

Updated

September 8, 2026

Documentation and APIs

The boring public API is a small set of functions with docstrings, plus a CLI that calls them. argparse is enough. The docstring is the contract you can keep.

Mental model

An API is what callers are allowed to depend on: names, arguments, return values, and exceptions. Everything else is private, even if Python will still import it.

A docstring is the string literal under the def (or on the module). help() and inspect.getdoc read it. Write one paragraph that says what the function returns and what it raises.

argparse parses sys.argv into those same arguments. The CLI is a user of the function, not a second implementation.

Worked examples

Case 1: A docstring is the promise

Save as ticket_api.py. The function is the API. main only prints.

# ticket_api.py
def label(ticket_id: int, table: int) -> str:
    """Return a one-line ticket label for the desk display."""
    return f"ticket {ticket_id} → table {table}"


def main() -> None:
    print(label.__doc__)
    print(label(7, 12))


if __name__ == "__main__":
    main()

Run:

uv run python ticket_api.py

Output:

Return a one-line ticket label for the desk display.
ticket 7 → table 12

Case 2: argparse wraps the same function

Save as label.py. Flags map onto label. There is no copy of the format string in main.

# label.py
import argparse


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Print a ticket label.")
    parser.add_argument("ticket_id", type=int)
    parser.add_argument("--table", type=int, required=True)
    return parser


def label(ticket_id: int, table: int) -> str:
    """Return a one-line ticket label for the desk display."""
    return f"ticket {ticket_id} → table {table}"


def main() -> None:
    args = build_parser().parse_args()
    print(label(args.ticket_id, args.table))


if __name__ == "__main__":
    main()

Run:

uv run python label.py 7 --table 12

Output:

ticket 7 → table 12

Case 3: -h is documentation too

Same file, different argv.

uv run python label.py -h

Output:

usage: label.py [-h] --table TABLE ticket_id

Print a ticket label.

positional arguments:
  ticket_id

options:
  -h, --help     show this help message and exit
  --table TABLE

That help text is why description= exists. Keep it accurate.

Case 4: Invalid input fails at the edge

Save as label_bad.py (same parser, explicit parse_args on a list so the listing stays non-interactive).

# label_bad.py
import argparse


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Print a ticket label.")
    parser.add_argument("ticket_id", type=int)
    parser.add_argument("--table", type=int, required=True)
    return parser


def main() -> None:
    parser = build_parser()
    try:
        parser.parse_args(["nope", "--table", "12"])
    except SystemExit as exc:
        print("exit", exc.code)


if __name__ == "__main__":
    main()

Run:

uv run python label_bad.py

Output (stderr from argparse, then stdout):

usage: label_bad.py [-h] --table TABLE ticket_id
label_bad.py: error: argument ticket_id: invalid int value: 'nope'
exit 2

Callers of label() still pass an int. The CLI is the one that translates strings.

The trap

A CLI that formats the string itself, and a function that formats it differently.

Save as two_apis.py:

# two_apis.py
def label(ticket_id: int, table: int) -> str:
    """Return a one-line ticket label for the desk display."""
    return f"ticket {ticket_id} → table {table}"


def main() -> None:
    ticket_id = 7
    table = 12
    print(f"#{ticket_id} @ {table}")
    print(label(ticket_id, table))


if __name__ == "__main__":
    main()

Run:

uv run python two_apis.py

Output:

#7 @ 12
ticket 7 → table 12

Two formats, one desk. Tests will pick one and production the other. main should only print(label(...)).

The boring rule

  • Document the public functions. Keep the set small.
  • CLI via argparse (or a thin wrapper) that calls those functions.
  • One format string, one place.
  • Raise ValueError / TypeError in the library. Let argparse handle argv mistakes.
  • Do not promise a plugin system in the docstring of a 12-line module.

Try this

  1. In ticket_api.py, raise ValueError when table <= 0 and mention it in the docstring.
  2. In label.py, add --status with default "open" and include it in label.
  3. Run uv run python label.py 7 without --table and read the argparse error.