Documentation and APIs
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.pyOutput:
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 12Output:
ticket 7 → table 12
Case 3: -h is documentation too
Same file, different argv.
uv run python label.py -hOutput:
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.pyOutput (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.pyOutput:
#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/TypeErrorin the library. Let argparse handle argv mistakes. - Do not promise a plugin system in the docstring of a 12-line module.
Try this
- In
ticket_api.py, raiseValueErrorwhentable <= 0and mention it in the docstring. - In
label.py, add--statuswith default"open"and include it inlabel. - Run
uv run python label.py 7without--tableand read the argparse error.