The Hitchhiker's Guide to CLIs in Python — Part 2: Python packages for writing CLIs
04 May 2020 TweetLet's see how we can write a command-line interface using Python. There are several options to do this, in the standard library and on PyPI.
We'll use an example CLI called smol-pip
, and see how we can implement it using these different options.
$ smol-pip install --upgrade package_name
smol-pip
has one subcommand called install
that can be used to install a package from PyPI. It also has an --upgrade
option that will upgrade the package if it's already installed. And the package_name
required argument to identify the package.
Standard library
sys
We'll look at the standard library first. It has the sys
module that defines the argv
variable. sys.argv
is a list where the first element contains the name of the CLI that was invoked, and the rest of them are the command-line options passed to the CLI.
Internally, sys.argv
uses the getopt module to parse and create this list. The getopt module implements a parser for command-line options whose API designed to similar to the Unix getopt function. It follows the POSIX standard.
Let's look at smol-pip
, written using the sys
module.
import sys
help = "Pip Installs Packages."
if __name__ == "__main__":
arguments = sys.argv
if arguments[1] in ["-h", "--help"]:
print(help)
elif arguments[1] in ["-v", "--version"]:
print("0.1.0")
else:
print(arguments)
# ['smol-pip', 'install', '--upgrade', 'Click']
if arguments[1] == "install":
# dispatch to install / upgrade code
else:
raise ValueError("Unknown subcommand!")
When the CLI is invoked, we get the list of arguments using sys.argv
. Since the first element is the name of the CLI itself, we check the element at index 1. If it is -h
or --help
, we print the help. And do the same for the version. Finally, we check the subcommand that was invoked, and dispatch control to the relevant code.
optparse
Up until Python 3.2, the standard library also had the optparse
module. It has since been deprecated. optparse
could parse only options, and not positional arguments. Steven Bethard, talks about it in detail in PEP 389. This PEP proposed the deprecation of optparse
in favor of the new and improved argparse
module! It was approved by Guido on February 21, 2010.
argparse
argparse
was written because both getopt
and optparse
support only options, and not arguments. argparse
handles both, and as a result, it is able to generate better help messages. argparse
also allows customization of characters that are used to identify options. For example, using a +
instead of a -
, or even forward slashes.
-pf
-file
+f
+rgb
/f
/file
argparse
also added support for subcommands. This is a common pattern in CLIs. For example:
pip install
pip freeze
pip search
Let's look at smol-pip
, written using the argparse
module.
import argparse
parser = argparse.ArgumentParser(
description="Pip Installs Packages."
)
parser.add_argument(
"-v",
"--version",
action="version",
version="0.1.0"
)
We import argparse
, initialize a parser
object and add the description of our CLI. We also add a version option, which will be print the version of our CLI when it is invoked with -v
or --version
.
subparsers = parser.add_subparsers(dest="subparser_name")
install = subparsers.add_parser("install")
install.add_argument(
"-u",
"--upgrade",
action="store_true",
help="Upgrade package to the newest available version.",
)
install.add_argument("package_name")
We then initialize a subparsers
object, and add a subparser for the install
subcommand. To which we then add an --upgrade
option and a package_name
argument. The action=store_true
makes sure that --upgrade
is treated like a boolean flag.
if __name__ == "__main__":
arguments = parser.parse_args()
print(arguments)
# Namespace(package_name='Click', upgrade=True)
if arguments.subparser_name == "install":
# dispatch to install / upgrade code
else:
raise ValueError("Unknown subcommand!")
When the CLI is invoked, we use the parser.parse_args()
function to get a Namespace
object. This object contains the parsed command-line options and arguments as its attributes. Finally, we check the subcommand that was invoked, and dispatch control to the relevant code.
$ smol-pip --help
usage: smol-pip [-h] [-v] {install} ...
Pip Installs Packages.
positional arguments:
{install}
optional arguments:
-h, --help show this help message and exit
-v, --version show program's version number and exit
argparse
automatically generates a help for our CLI! This can be viewed by invoking the CLI with -h
or --help
.
Python Package Index
Now let's look at some packages on PyPI.
docopt
docopt
was written by Vladimir Keleshev, and is kinda cool in the way it works. It takes a documentation-first approach. docopt
just requires a POSIX-compliant help string as an input from which it'll infer subcommands, options and arguments.
help = """Pip Installs Packages.
Usage:
smol-pip install PACKAGE_NAME
smol-pip install --upgrade PACKAGE_NAME
Options:
-h --help Show this screen.
--version Show version.
"""
So this time, we first create a help string that shows our CLI's description and usage.
from docopt import docopt
if __name__ == "__main__":
arguments = docopt(help, version="0.1.0")
print(arguments)
# {'--upgrade': True,
# 'PACKAGE_NAME': 'Click',
# 'install': True}
if arguments["install"]:
# dispatch to install / upgrade code
else:
raise ValueError("Unknown subcommand!")
And when the CLI is invoked, we call docopt()
, pass in the help string and a version. And docopt()
returns a dictionary of parsed command-line options and arguments! Finally, we check the subcommand that was invoked, and dispatch control to the relevant code.
In all the previous examples, we had to write some boilerplate to dispatch control to the relevant install and upgrade code, along with parsing results. We would need some more boilerplate if we had to validate the parsed command-line options and arguments. This boilerplate can grow real big for large applications.
Also, there might be some common CLI features we might want to add. For example, progress bars and colored text, which would require some more code. Let's look at a package that can help us reduce boilerplate, and implement common CLI features.
click
click
(short for Command Line Interface Creation Kit) was written by Armin Ronacher to support the Flask project. It is designed to nestable and composable. It supports arbitrary nesting of commands. For example, python setup.py sdist bdist_wheel
, where the bdist_wheel
subcommand is called after sdist
, kinda like subcommand chain.
click
automatically dispatches control to relevant code based on the invoked subcommand. It also supports callbacks that can be used to validate parsed command-line options and arguments. And it's POSIX-compliant.
Let's look at smol-pip
, written using the click
.
import click
@click.group("pip")
@click.version_option("0.1.0")
def cli(*args, **kwargs):
"""Pip Installs Packages."""
pass
We import click
and create a cli()
function with a docstring. click
follows a decorator based approach to writing CLIs.
We add a click.group()
decorator to the cli()
function to make it a command group. A command group can contain contain one or more subcommands. We also add a version_option
, which will print the version of our CLI when it is invoked with -v
or --version
.
@cli.command("install")
@click.option(
"-u",
"--upgrade",
is_flag=True,
help="Upgrade package to the newest available version.",
)
@click.argument("package_name")
def install(*args, **kwargs):
"""Install packages."""
# install / upgrade package_name
We then create a install()
function with a docstring. This function will contain the code required to install or upgrade a package. We use the cli.command()
decorator to convert this function into a subcommand. Here cli
is the command group we defined earlier.
We then use the click.option()
decorator to add an option called --upgrade
along with a help string. The is_flag=True
makes sure that --upgrade
is treated like a boolean flag. We also add a package_name
argument using the click.argument()
decorator.
if __name__ == "__main__":
cli()
When the CLI is invoked, click
will automatically dispatch control to the relevant code, which is the install()
function in this case. We will get the parsed command-line options and arguments as kwargs to the install()
function, which we can then use to install or upgrade a package.
$ smol-pip --help
Usage: smol-pip [OPTIONS] COMMAND [ARGS]...
Pip Installs Packages.
Options:
--version Show the version and exit.
--help Show this message and exit.
Commands:
install Install packages.
Like argparse
, click
automatically generates a help for our CLI based on the function docstrings and option help strings we added! Again, this can be viewed by invoking the CLI with -h
or --help
.
click
promises that when multiple apps written using click are strung together, they will work seamlessly. This is helpful because multiple people can work on smaller parts of a large CLI and stitch all of them together at the end.
The click
way of writing a CLI where we don't have to define parsers from the start, or focus on our help text from the start, is great for quick iterations!
Continue to Part 3 — Writing and packaging a CLI using Click