Argparse Parents vs Function for Common Arguments

When creating a new ArgumentParser there is a parents argument that can be used to add a set of additional arguments from a different parser, like so:

from argparse import ArgumentParser

base = ArgumentParser(add_help=False)
base.add_argument("-a")
base.add_argument("-b")

p1 = ArgumentParser(parents=[base])
p1.add_argument("-c")

p2 = ArgumentParser(parents=[base])
p2.add_argument("-d")

From what I can tell, this is no different than simply using a function for adding common arguments to the parser, like so:

from argparse import ArgumentParser


def add_common_args(parser: ArgumentParser) -> None:
    parser.add_argument("-a")
    parser.add_argument("-b")


p1 = ArgumentParser()
add_common_args(p1)
p1.add_argument("-c")

p2 = ArgumentParser()
add_common_args(p2)
p2.add_argument("-d")

But I feel like there must be more to it than that, so what (if any) reasons are there to prefer to use the base argument, instead of just having a function to add the necessary arguments?

Maybe the base parent parser is useful too, e.g. a base CLI without using any special commands that are delegated to subparsers. But with your function, those common args can also be applied to non-child parsers (or anything with a .add_argument method), which could also be useful.

In the context of subparsers, it can be useful to use simple functions to share options, or configure options (e.g., --log-level).

Here’s an example:

def validate_path(sx: str) -> Path:
    p = Path(sx).expanduser().absolute()
    if p.exists():
        return p
    raise ArgumentTypeError(f"Path '{sx}' does not exist")


def add_core(p: ArgumentParser) -> ArgumentParser:
    p.add_argument('-s', '--src', help="Source file", type=validate_path)
    return p

def add_logging(p: ArgumentParser, default = logging.INFO) -> ArgumentParser:
    p.add_argument('--log-level', help="Log file", default=default)
    return p


def p_alpha(p: ArgumentParser) -> ArgumentParser:
    add_core(p)
    p.add_argument('-o', '--output', help="dest file", type=Path)
    return add_logging(p, logging.DEBUG)


def p_beta(p: ArgumentParser) -> ArgumentParser:
    add_core(p)
    p.add_argument('-m', '--max-records', help="Max records to process", type=int)
    return add_logging(p, logging.ERROR)

Also, argparse is a bit clumsy with subparser creation. Depending on how big your CLI tool is, It can be useful to create a shim layer that wires things together.

Note, the big issue with refactoring is at the argparse Namespace component. You have to keep this in sync with the each subparser definition. Static analyzing tools can’t really help you here.

def run_alpha(src: Path, output: Path) -> int:
    logger.info(f"Processing '{src}'")
    return 0


def run_beta(src: Path, max_records: int) -> int:
    logger.info(f"Beta Processing '{src}' with {max_records} records")
    return 0


def run_gamma(src:Path) -> int:
    logger.info(f"Gamma Processing '{src}'")
    return 0

def get_parser() -> ArgumentParser:
    p = ArgumentParser(description="Example Subparser")

    sp = p.add_subparsers(dest='commands')

    def _build(
        name: str,
        help_: str,
        add_opts: F[[ArgumentParser], ArgumentParser],
        func: F[[Any], int],
    ) -> ArgumentParser:
        px = sp.add_parser(
            name,
            formatter_class=ArgumentDefaultsHelpFormatter,
            help=help_,
            description=help_,
        )
        px = add_opts(px)
        px.set_defaults(func=func)
        return px

    _build('alpha', "Run alpha", p_alpha, lambda ns: run_alpha(ns.src, ns.output))
    _build('beta', "Run Beta", p_beta, lambda ns: run_alpha(ns.src, ns.max_records))
    _build("gamma", "Run gamma", p_gamma, lambda ns: run_gamma(ns.src))


    p.add_argument("--version", action='version', version="0.1.0")
    return p


def main(argv: list[str]) -> int:
    pa = get_parser().parse_args(argv)
    logging.basicConfig(level=pa.log_level)
    return pa.func(pa)

Complete example is here