Make large command-line tools with python

Published: mar. 09 février 2021
Updated: mer. 24 mars 2021
By Jorispilot

In python.

Many moderne command-line programs comes with a subcommand style of organization: Git has git status, git commit, git checkout, and each subcommand has its own arguments, options, and documentation.

This article presents a Python skeleton for making well-organized and easily extensible subcommand-style, command-line programs. It uses the standard module argparse.

General code organization

We start with a regular organisation of a python project:

myproject/
├── mypackage/
└── setup.py

Where the code actual code is in directory mypackage and a classic setup.py script like this:

from setuptools import setup, find_packages

setup(
    ## Usual info.
    packages = ["mypackage"],
)

We place command-line code in a separate directory called cmdline and we make it a package:

myproject/
├── mypackage/
├── setup.py
└── cmdline
    ├── __init__.py
    └── __main__.py

The content of __init__.py and __main__.py is section The magic trick scripts below.

Each subcommand will be a python file placed in the cmdline directory, which will be automatically discovered and added by the __init__.py script. Each python file must implement these two functions:

def add_argument(parser):
    ## Add command-line arguments to the parser argument, which is an
    ## instance of argparse.ArgumentParser
    pass

def execute(args, parser):
    "oneline description of this subcommand"
    ## Command-line code goes here.
    ## Parsed arguments are args and the parser object is also provided if
    ## needed. This function should ideally return an integer that is the
    ## process return status.
    return 0

For a subcommand within a subcommand, just make a directory instead of a python file, and copy (or symlink) the __init__.py file into that directory.

In this file, you will add import mypackage and use is as an external package.

The main command

Now modify setup.py like this:

from setuptools import setup, find_packages

setup(
    ## Usual info.
    packages = ["mypackage"],
    entry_points = {
    "console_scripts": [
        "myprog = cmdline.__main__:main",
        ],
    },
)

Running something like python setup.py develop --user will automatically create a script called myprog in $HOME/.local/bin, which should be in your $PATH, and you’re on !

You can easily add, rename, and delete subcommands with file operations, and command-line code is also cleanly separated from the core code.

The magic trick scripts

The file __init__.py has the following content:

## pylint: disable=missing-module-docstring,missing-function-docstring

## Automatically import modules of this package as sub-commands.
##
## Modules should have functions "add_arguments" and "execute". Docstring of
## the latter is command-line help.
##
## Please, do not mix this package with modules that are not intended to be
## sub-commands; do not put other files either.

import importlib
import os
import sys


def _find_modules(directory):

    cmds = dict()
    for dir_entry in os.scandir(directory):

        if dir_entry.name in ["__init__.py", "__main__.py", "__pycache__"]:
            continue

        name, _ = os.path.splitext(dir_entry.name)
        modname = __name__ + "." + name

        module = importlib.import_module(modname)

        if (hasattr(module, "add_arguments") and
            hasattr(module, "execute")):
            cmds[name] = module

        else:
            print("Incompatible module:", modname, file=sys.stderr)

    return cmds


CMDS = _find_modules(__path__[0])
CMD_NAME = __name__


def add_arguments(parser):

    subparsers = parser.add_subparsers(dest=CMD_NAME)

    for name, module in CMDS.items():

        doc = module.execute.__doc__
        subparser = subparsers.add_parser(name, help=doc)
        module.add_arguments(subparser)


def execute(args, parser):

    name = getattr(args, CMD_NAME)

    try:
        module = CMDS[name]

    except KeyError:
        parser.print_help(file=sys.stderr)
        return 1

    return module.execute(args, parser)

And the __main__.py has the content:

import argparse
import sys

from . import add_arguments, execute


def main():
    parser = argparse.ArgumentParser()
    add_arguments(parser)
    args = parser.parse_args()
    return execute(args, parser)

links

social