Argparse Template

Subcommand Skeleton

Python's argparse library is super flexible. I want to use it to design CLIs like the azure-cli, with the following structure (imagine a conversation-generator CLI):

conversation-generator
        --log-level <value>
    greet
        --name <value>
    conversation
        --mood <value>
        ask
        declare
            --add-random-fact <true|false>

This is a pretty rigid structure that I find easy to read and expand. It has the following designed limitations

  • No top-level commands - program-name doesn't do anything besides print subcommands. All "action" happens via subcommands
  • Subcommands can be grouped by subsections, almost like you can group files by directory
  • No positional arguments - commands receive all parameters via --flags

I wrote a Go library to give me this easily, but argparse is flexible enough to do it in Python without too much work. I'm also adding a few convenience flags I find useful, like --log-level.

I try to stick to stdlib Python, but if I'm regularly passing files as flag values, I also use shtab to add zsh completion for files/directories.

So, here's the skeleton:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import argparse
import logging
import sys


__author__ = "Benjamin Kane"
__version__ = "0.1.0"
__doc__ = f"""
<description>
Examples:
    {sys.argv[0]}
Help:
Please see Benjamin Kane for help.
Code at <repo>
"""

logger = logging.getLogger(__name__)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    parser.add_argument(
        "--log-level",
        choices=["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
        default="INFO",
        help="log level",
    )

    subcommands = parser.add_subparsers(dest="subcommand_name", required=True)

    # greet
    greet_cmd = subcommands.add_parser("greet", help="Say a greeting")
    greet_cmd.add_argument("--name", required=True, help="Person to greet")

    # conversation
    conversation_cmd = subcommands.add_parser("conversation", help="conversation subcommands")
    conversation_cmd.add_argument("--mood", required=True, help="mood for the conversation")

    conversation_subcommands = conversation_cmd.add_subparsers(
        dest="conversation_subcommand_name",
        required=True,
        help="subcommand name",
    )

    # ask
    _ = conversation_subcommands.add_parser("ask", help="ask a question")

    # declare
    declare_cmd = conversation_subcommands.add_parser("declare", help="declare something")

    # Use boolean with a default (--include-random-fact/--no-include-random-fact)
    # alternative to action='store_true'/'store_false'
    declare_cmd.add_argument(
        "--include-random-fact",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="stream change output to stdout in addition to a file",
    )
    return parser


def main():
    parser = build_parser()
    args = parser.parse_args()

    logging.basicConfig(
        format="# %(asctime)s %(levelname)s %(name)s %(filename)s:%(lineno)s\n%(message)s\n",
        level=logging.getLevelName(args.log_level),
    )

    match args.subcommand_name:
        case "greet":
            print(f"Hello {args.name}")
        case "conversation":
            match args.conversation_subcommand_name:
                case "ask":
                    print("Huh?")
                case "declare":
                    random_fact = "The sky is blue" if args.include_random_fact else "boring..."
                    print(f"Mood: {args.mood}. I declare: {random_fact}")
                case _:
                    raise SystemExit(f"Unknown subcommand: {args.conversation_subcommand_name!r}")
        case _:
            raise SystemExit(f"Unknown command: {args.subcommand_name!r}")

if __name__ == "__main__":
    main()

Simple Skeleton

This is if I don't need subcommands, but instead I just need to parse a few flags

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import argparse
import logging
import sys

__author__ = "Benjamin Kane"
__version__ = "0.1.0"
__doc__ = f"""
<description>
Examples:
    {sys.argv[0]}
Help:
Please see Benjamin Kane for help.
Code at <repo>
"""

logger = logging.getLogger(__name__)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    parser.add_argument(
        "--log-level",
        choices=["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
        default="INFO",
        help="log level",
    )

    return parser


def main():

    parser = build_parser()
    args = parser.parse_args()

    logging.basicConfig(
        format="# %(asctime)s %(levelname)s %(name)s %(filename)s:%(lineno)s\n%(message)s\n",
        level=logging.getLevelName(args.log_level),
    )

    logger.debug("debug")
    logger.info("info")
    logger.warning("warning")


if __name__ == "__main__":
    main()

Extras

stdin or a file

If I want to use either stdin or a file for an argument, I can use this:

# Use a file or stdin for an argument
# https://stackoverflow.com/a/11038508/2958070
parser.add_argument(
    "infile",
    nargs="?",
    type=argparse.FileType("r"),
    default=sys.stdin,
    help="Use a file or stdin",
)

Later (after parsing) I can read the contents like this:

with args.infile:
    pass