Search code examples
pythonpython-3.xargparse

How to use argparse to create command groups like git?


I'm trying to figure out how to use properly builtin argparse module to get a similar output than tools such as git where I can display a nice help with all "root commands" nicely grouped, ie:

$ git --help
usage: git [--version] [--help] [-C <path>] [-c <name>=<value>]
           [--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
           [-p | --paginate | -P | --no-pager] [--no-replace-objects] [--bare]
           [--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
           [--super-prefix=<path>] [--config-env=<name>=<envvar>]
           <command> [<args>]

These are common Git commands used in various situations:

start a working area (see also: git help tutorial)
   clone     Clone a repository into a new directory
   init      Create an empty Git repository or reinitialize an existing one

work on the current change (see also: git help everyday)
   add       Add file contents to the index
   mv        Move or rename a file, a directory, or a symlink
   restore   Restore working tree files
   rm        Remove files from the working tree and from the index

examine the history and state (see also: git help revisions)
   bisect    Use binary search to find the commit that introduced a bug
   diff      Show changes between commits, commit and working tree, etc
   grep      Print lines matching a pattern
   log       Show commit logs
   show      Show various types of objects
   status    Show the working tree status

grow, mark and tweak your common history
   branch    List, create, or delete branches
   commit    Record changes to the repository
   merge     Join two or more development histories together
   rebase    Reapply commits on top of another base tip
   reset     Reset current HEAD to the specified state
   switch    Switch branches
   tag       Create, list, delete or verify a tag object signed with GPG

collaborate (see also: git help workflows)
   fetch     Download objects and refs from another repository
   pull      Fetch from and integrate with another repository or a local branch
   push      Update remote refs along with associated objects

'git help -a' and 'git help -g' list available subcommands and some
concept guides. See 'git help <command>' or 'git help <concept>'
to read about a specific subcommand or concept.
See 'git help git' for an overview of the system.

Here's my attempt:

from argparse import ArgumentParser


class FooCommand:
    def __init__(self, subparser):
        self.name = "Foo"
        self.help = "Foo help"
        subparser.add_parser(self.name, help=self.help)


class BarCommand:
    def __init__(self, subparser):
        self.name = "Bar"
        self.help = "Bar help"
        subparser.add_parser(self.name, help=self.help)


class BazCommand:
    def __init__(self, subparser):
        self.name = "Baz"
        self.help = "Baz help"
        subparser.add_parser(self.name, help=self.help)


def test1():
    parser = ArgumentParser(description="Test1 ArgumentParser")
    root = parser.add_subparsers(dest="command", description="All Commands:")

    # Group1
    FooCommand(root)
    BarCommand(root)

    # Group2
    BazCommand(root)

    args = parser.parse_args()
    print(args)


def test2():
    parser = ArgumentParser(description="Test2 ArgumentParser")

    # Group1
    cat1 = parser.add_subparsers(dest="command", description="Category1 Commands:")
    FooCommand(cat1)
    BarCommand(cat1)

    # Group2
    cat2 = parser.add_subparsers(dest="command", description="Category2 Commands:")
    BazCommand(cat2)

    args = parser.parse_args()
    print(args)

If you run test1 you'd get:

$ python mcve.py --help
usage: mcve.py [-h] {Foo,Bar,Baz} ...

Test1 ArgumentParser

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

subcommands:
  All Commands:

  {Foo,Bar,Baz}
    Foo          Foo help
    Bar          Bar help
    Baz          Baz help

Obviously this is not what I want, in there I just see all commands in a flat list, no groups or whatsoever... so the next logical attempt would be trying to group them. But if I run test2 I'll get:

$ python mcve.py --help
usage: mcve.py [-h] {Foo,Bar} ...
mcve.py: error: cannot have multiple subparser arguments

Which obviously means I'm not using properly argparse to accomplish the task at hand. So, is it possible to use argparse to achieve a similar behaviour than git? In the past I've relied on "hacks" so I thought the best practice here would be using the concept of add_subparsers but it seems I didn't understand properly that concept.


Solution

  • This isn't supported natively by argparse -- you can't nest subparsers, so if you want this sort of cli using argparse you're going to need to build a lot of logic on top of argparse. You can set nargs=argparse.REMAINDER to collect a subcommand and arguments without having them parsed by argparse, which means we can build something like this:

    import argparse
    import copy
    
    
    class Command:
        def __init__(self):
            self.subcommands = {}
            self.parser = argparse.ArgumentParser()
    
        def add_subcommand(self, name, sub):
            self.subcommands[name] = sub
    
        def add_argument(self, *args, **kwargs):
            return self.parser.add_argument(*args, **kwargs)
    
        def parse_args(self, args=None):
            if not self.subcommands:
                args = self.parser.parse_args(args)
                return args
    
            p = copy.deepcopy(self.parser)
            p.add_argument("subcommand")
            p.add_argument("args", nargs=argparse.REMAINDER)
            args = p.parse_args(args)
    
            try:
                sub = self.subcommands[args.subcommand]
            except KeyError:
                return self.parser.parse_args(args)
    
            sub_args = sub.parse_args(args.args)
    
            for attr in dir(sub_args):
                if attr.startswith("_"):
                    continue
                setattr(args, attr, getattr(sub_args, attr))
    
            return args
    
    
    def main():
        root = Command()
        root.add_argument("-v", "--verbose", action="count")
    
        cmd1 = Command()
        cmd1_foo = Command()
        cmd1_foo.add_argument("-n", "--name")
        cmd1.add_subcommand("foo", cmd1_foo)
        root.add_subcommand("cmd1", cmd1)
    
        cmd2 = Command()
        cmd2_bar = Command()
        cmd2_bar.add_argument("-s", "--size", type=int)
        cmd2.add_subcommand("bar", cmd2_bar)
        root.add_subcommand("cmd2", cmd2)
    
        print(root.parse_args())
    
    
    if __name__ == "__main__":
        main()
    

    This is horrible and ugly and poorly structured, but it means we can do this:

    $ python argtest.py --verbose cmd1 foo --name lars
    Namespace(verbose=1, subcommand='foo', args=['--name', 'lars'], name='lars')
    

    Or this:

    $ python argtest.py --verbose cmd2 bar --size 10
    Namespace(verbose=1, subcommand='bar', args=['--size', '10'], size=10)
    

    If you're willing to look beyond argparse, libraries like Click and Typer make things much easier. For example, the above command could be implemented using Click like this:

    import click
    
    @click.group()
    def main():
        pass
    
    @main.group()
    def cmd1():
        pass
    
    @cmd1.command()
    @click.option('-n', '--name')
    def foo(name):
        pass
    
    @main.group()
    def cmd2():
        pass
    
    
    @cmd2.command()
    @click.option('-s', '--size', type=int)
    def bar():
        pass
    
    if __name__ == '__main__':
        main()
    

    So much nicer!