Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

multicommand uses only the standard library and is ~100 lines of code (modulo comments and whitespace)

Overview

Multicommand enables you to easily write CLIs with deeply nested commands using vanilla argparse. You provide it with a package, it searches that package for parsers (argparse.ArgumentParser objects), and connects, names, and converts those parsers into subcommands based on the package structure.

        Package                       ->                    CLI


commands/unary/negate.py                            mycli unary negate ...
commands/binary/add.py                              mycli binary add ...
commands/binary/divide.py             ->            mycli binary divide ...
commands/binary/multiply.py                         mycli binary multiply ...
commands/binary/subtract.py                         mycli binary subtract ...

All it needs is for each module to define a module-level parser variable which points to an instance of argparse.ArgumentParser.

Goals

  • Small - The magic happens in a single file multicommand.py
  • Simple API - Structure commands however you like, then call multicommand.create_parser(...)
  • Dependency-free - You need python 3.10+, and that’s it!

A happy by-product of multicommand being small and depedency-free is that it’s even portable. Don’t want add an additional dependency? Grab multicommand.py, drop in in your project, and use it like any other module. (Not a huge fan of this option, but it can be done)

Motivation

I like argparse. It’s flexible, full-featured and it’s part of the standard library, so if you have python you probably have argparse. I also like the “subcommand” pattern, i.e. one root command that acts as an entrypoint and subcommands to group related functionality. Of course, argparse can handle adding subcommands to parsers, but it’s always felt a bit cumbersome, especially when there are many subcommands with lots of nesting.

If you’ve ever worked with technologies like Next.js or oclif (or even if you haven’t) there’s a duality between files and objects. For Next.js each file under pages/ maps to a webpage, in oclif each module under commands/ maps to a CLI command. And that’s the basic premise for multicommand: A light-weight package that lets you write one parser per file, pretty much in isolation, and it handles the wiring, exploiting the duality between command structure and file system structure.

Installation

multicommand is hosted on PyPI and should be installable using any tool which can pull from there.

System Requirements

multicommand requires python >= 3.10 and is a pure python package. It should run on any OS that python will run on.

With pip

The quickest way to get multicommand is with pip

pip install multicommand

With uv

If you’re using uv then just

uv add multicommand

Basic Usage

We’re going to create a small CLI with a single (deeply-nested) greet command/function:

<mycli> topic cmd subcmd greet ...

<mycli> is the placeholder for the name of the executable (in this case it’s just going to end up being python -m mypkg.cli). topic is a command (we’re already 1 level deep!). cmd is a subcommand of topic. subcmd is a subcommand of cmd (sub-subcommand of topic). Finally, greet is our actual function.

Normally we would need a fair bit of boilerplate to wire this up, but we’ll see how multicommand makes this super easy.

Setup

Create a directory to work in, for example:

mkdir ~/multicommand-sample && cd ~/multicommand-sample

Install multicommand:

python3 -m venv ./venv
source ./venv/bin/activate

python3 -m pip install multicommand

Create the subpackage to house our parsers:

mkdir -p mypkg/parsers/topic/cmd/subcmd

Note: That’s a long path. Feel free to skip over this note and continue, but if you’re perplexed by this directory structure here’s a high-level explanation:

Part of that path should already be looking familiar. (The topic/cmd/subcmd part - that’s no coicidence!) Basically, mypkg is the name of what will become our installable package (i.e. we’ll eventually be able to import mypkg).

The folder mypkg/parsers is going to be a sub-package (i.e it’s going to contain an __init__.py file - in fact, all these folders will be sub-packages). This sub-package (mypkg.parsers) will be the thing that we pass to multicommand, from which we’ll get our configured argparse.ArgumentParser instance.

As for the remaining folders, multicommand will use those to create the command hierarchy that we’re after.

Create the *.py files we’ll need.

touch mypkg/__init__.py
touch mypkg/parsers/__init__.py
touch mypkg/parsers/topic/__init__.py
touch mypkg/parsers/topic/cmd/__init__.py
touch mypkg/parsers/topic/cmd/subcmd/{__init__.py,greet.py}

The code

First, add a parser to greet.py:

import argparse


# using this handler we'll be able to tell this parser how to "handle itself"
def handler(args):
    greeting = f'Hello, {args.name}!'
    print(greeting.upper() if args.shout else greeting)


parser = argparse.ArgumentParser(
    description='Show a greeting',
    formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument('name', help='Name to use in greeting')
parser.add_argument('--shout', action='store_true', help='Yell the greeting')
parser.set_defaults(handler=handler)  # link the handler to this parser/command

Second, add an entrypoint (this is the module we’ll run from the command line):

touch mypkg/cli.py

with the following content:

import multicommand
from mypkg import parsers


def main():
    # pass the module 'mypkg.parsers' to multicommand for it to make us a parser
    parser = multicommand.create_parser(parsers)
    args = parser.parse_args()
    if hasattr(args, 'handler'):
        return args.handler(args)
    parser.print_help()


if __name__ == "__main__":
    exit(main())

Third, there is no third step! Let’s try it out!

$ python3 -m mypkg.cli
usage: cli.py [-h] [command] ...

optional arguments:
  -h, --help  show this help message and exit

subcommands:

  [command]
    topic

Take a look at our greet command:

$ python3 -m mypkg.cli topic cmd subcmd greet --help
usage: cli.py topic cmd subcmd greet [-h] [--shout] name

Show a greeting

positional arguments:
  name        Name to use in greeting

optional arguments:
  -h, --help  show this help message and exit
  --shout     Yell the greeting (default: False)

From this we get:

$ python3 -m mypkg.cli topic cmd subcmd greet "World"
Hello, World!

$ python3 -m mypkg.cli topic cmd subcmd greet --shout "World"
HELLO, WORLD!

Bonus

Want to add the command <mycli> topic cmd ungreet ... to say goodbye?

Add the module:

touch mypkg/parsers/topic/cmd/ungreet.py

Note: Notice that since we want to create the command <mycli> topic cmd ungreet ... we’re creating the module <sub-pkg-that-we'll-pass-to-multicommand>/topic/cmd/ungreet.py, where in this case <sub-pkg-that-we'll-pass-to-multicommand> is mypkg/parsers/

with contents:

import argparse


def handler(args):
    print(f'Goodbye, {args.name}!')


parser = argparse.ArgumentParser(description='Show an un-greeting')
parser.add_argument('name', help='Name to use in un-greeting')
parser.set_defaults(handler=handler)

The new command is automatically added!:

$ python3 -m mypkg.cli topic cmd --help
usage: cli.py topic cmd [-h] [command] ...

optional arguments:
  -h, --help  show this help message and exit

subcommands:

  [command]
    subcmd
    ungreet   Show an un-greeting

Try it out:

$ python3 -m mypkg.cli topic cmd ungreet "World"
Goodbye, World!

Concepts

Contrary to what the Basic Usage example might have you believing, multicommand doesn’t quite treat all modules in the subpackage passed to multicommand.create_parser(subpkg) eqaully. In Fact, there are a few things to keep in mind in order to make using multicommand as smooth as possible.

Throughout the remainder of this discussion assume we’re working in a package called mypkg, with an entrypoint (module + function) called cli.py and a subpackage called commands to house our parsers.

mypkg
├── __init__.py
├── cli.py
└── commands
    ├── __init__.py
    └── ...

I.e. somewhere we’re calling multicommand.create_parser(mypkg.commands).

The module-level parser

The variable name

Each module in commands/ - at least, the ones you want multicommand to find - must export (which, in python, really just means “must define”) a module-level parser variable. Specifically, for every module in commands/ that you want multicommand to use in the construction of the main root parser, must have a line something like:

import argparse

# other code

parser = argparse.ArgumentParser()

So the following “exports” would be ignored by multicommand

# variable name is wrong
my_parser = argparse.ArgumentParser()
_parser = argparse.ArgumentParser()
command = argparse.ArgumentParser()

# this code is never executed when the module
# is imported, so multicommand doesn't see it
if __name__ == "__main__":
    parser = argparse.ArgumentParser()

# similarly, defining a factory function and calling it later,
# multicommand doesn't see this either
def my_parser_factory() -> argparse.ArgumentParser:
    ...

my_parser_factory()

The variable value

In addition to requiring a module-level parser variable, this variable must point at an instance of argparse.ArgumentParser (or an instance of a subclass of argparse.ArgumentParser).

That is, in addition so having parser = ... somewhere in the module, it should point at an appropriate instance.

So the following are non-examples:

# `parser` points at the wrong instance types
parser = None
parser = 'fake-parser'

# Duck-typing a parser also won't work
class FakeParser:
    def add_argument(*args, **kwargs):
        ...

    def parser_args(argv: List[str]):
        ...

parser = FakeParser()

However, if the object pointed to by parser does inherit from argparse.ArgumentParser, it really doesn’t matter how it came about, for example something like this would work:

import argparse


def my_parser_factory() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser()

    # configure the parser ...

    return parser


parser = my_parser_factory()

This segues nicely to the next concept.

A “parser module”

It’s probably desirable to structure each module under commands/ consistently. In general, having consistent coding habits is less of a statement toward multicommand usage and more so just a general programming best practice.

That said, if you have no opinions or are open to suggestions, a module structure something like the following might be useful to adopt:

import argparse

from mypkg import business_logic  # note the import from our local package


def create_parser() -> argparse.ArgumentParser:
    """A factory function to create the parser for handling this
    module's command. Its only job is creating, configuring, and
    returning the parser"""
    parser = argparse.ArgumentParser()

    # configure the parser ...

    # connect a handler function to this parser so that
    # this parser knows how to "handle itself"
    parser.set_defaults(handler=handler)

    return parser


def handler(args: argparse.Namespace) -> None:
    """A function that receives parsed args and sends them on to
    the "python API" exposed by this package. (I.e. the code that
    you'll probably have to import from somewhere else in this package"""
    param = args.param
    speacial_option = args.special_option

    business_logic.do_stuff(param, special_option)


# define the module-level parser so multicommand will find it 🚀
parser = create_parser()

The key take-aways from the above snippet are (in order of appearance):

  • Prefer absolute imports - when importing objects from the local package absolute imports are far easier to understand and digest than explicit relative imports, especially for deeply nested commands where you might have to go far “up the tree” and end up with something like from ....subpkg.submodule import MyClass.

    They’re also more portable, and with the combination of multicommand and absolute imports, it means you can easily move/alter your command hierarchy just by moving files, whereas explicit relative imports would (likely) break after moving a module.

    Further discussion

  • Create a parser factory function (or class) - This has two benefits.

    First, it nicely encapsulates the parser creation behind and easy-to-call function (or easy-to-instantiate class).

    Second, this encapsulation means it’s easier to test your parsers. Need an instance of your parser? Call create_parser. Need another? Call create_parser again!

  • Embrace some kind of handler semantics - As in the above example we define a handler function and link this function to the parser in the parser factory function. This has two benefits.

    First, the handlers become the spots to look for where command line arguments are converted into python objects and subsequently passed to the packages python API.

    Second, by using some kind of consistent handler structure among modules we can simplify the entrypoint considerably. If we use the handler-style function defined above across all our parser modules, we can do something like this for the entrypoint:

    import multicommand
    
    import mypkg
    
    def main():
        parser = multicommand.create_parser(mypkg.commands)
        args = parser.parse_args()
    
        # call the handler if one exists
        if hasattr(args, 'handler'):
            return args.handler(args)
    
        # otherwise just print the usage
        parser.print_help()
    

File Parsers

A file parser is any parser which comes from (is defined in):

  • An importable module (i.e. a file ending in .py)
  • The module is located in the commands/ (or similar) sub-package

These parsers basically map directly to some specific functionality of the CLI and “end there”. That is, there are no further subcommands. If git was implemented with multicommand the parsers defining the git add and git commit commands could probably be file parsers.

In many (most?) cases file parsers are all you need, especially if the topology of the CLI is simple and/or only “one level deep”, for example:

mycli add <thing>
mycli remove <thing>
mycli run <job_id> --option=<something>
mycli show

The above CLI might manifest itself with the following package structure:

mypkg
├── __init__.py
├── cli.py
└── commands
    ├── __init__.py
    ├── add.py
    ├── remove.py
    ├── run.py
    └── show.py

Directory Parsers

You may have noticed something in the Bonus section of the Basic Usage document. Specifically, take a look at the help produced by running python -m mypkg.cli topic cmd --help (reproduced here):

$ python3 -m mypkg.cli topic cmd --help
usage: cli.py topic cmd [-h] [command] ...

optional arguments:
  -h, --help  show this help message and exit

subcommands:

  [command]
    subcmd
    ungreet   Show an un-greeting

Notice the descriptions of the subcommands: ungreet has a description, but subcmd doesn’t. Both ungreet and subcmd are valid substitutions for [command] (above). So how do we add a description for subcmd and how can we even target that command? It’s represented as a directory (sub-package) after all!

As mentioned in the Motivation section of the Introduction, multicommand aims to simplify authoring nested CLIs by exploiting the duality between the filesystem hierarchy and command hierarchy. In a filesystem there are files, but there are also directories! Well, basically everything’s a file in linux filesystems, and there are more than two types of files, but for this analogy we only need to know that there are (at least) two types of objects in the tree.

I mention files and directories because in the same way that directories act as a parent node for files (and other nested directories) in a filesystem, directory parsers act as the parent node for file parsers (and other nested commands).

So, a directory parser is any parser which comes from (is defined in):

  • A package’s __init__.py file,
  • The package is located in the commands/ (or similar) sub-package.

These are the only requirements. Circling back to our Basic Usage question above, we could add a description by adding a directory parser to the __init__.py of the subcmd package:

import argparse


def create_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description='Perform subcmd-like functions!')
    parser.set_defaults(handler=lambda args: None)
    return parser


parser = create_parser()

Adding this would yield:

$ python3 -m mypkg.cli topic cmd --help
usage: cli.py topic cmd [-h] [command] ...

optional arguments:
  -h, --help  show this help message and exit

subcommands:

  [command]
    subcmd    Perform subcmd-like functions!
    ungreet   Show an un-greeting

Thoughts on directory parsers

Directory parsers can act like file parsers. A directory parser defined in __init__.py can have its own arguments and handler, effectively making it both a parent for subcommands and a command in its own right. For example, mycli topic could accept arguments directly while also having mycli topic subthing as a subcommand.

Directory parsers should generally only define options (flags), not positional arguments. Since subcommand names are themselves positional, having a directory parser that expects positional arguments can create ambiguity or awkward UX. Stick to --flag style arguments for directory parsers.

You often don’t need to define directory parsers explicitly. Throughout the examples so far, we haven’t needed to create __init__.py files with parsers—multicommand automatically generates minimal directory parsers for any package in your command hierarchy. You only need to define one explicitly when you want to:

  • Add a description (shown in parent’s help text)
  • Define shared options at that level
  • Attach a handler for when the directory command is invoked without a subcommand