The Hitchhiker's Guide to CLIs in Python — Part 3: Writing and packaging a CLI using Click
04 May 2020 TweetLet's look at some common CLI use cases and how we can use click
to implement them.
This time we'll use an example CLI called smol-git
.
$ smol-git --help
Usage: smol-git [OPTIONS] COMMAND [ARGS]...
smol-git - the stupid content tracker
Options:
--version Show the version and exit.
--help Show this message and exit.
Commands:
clone Clone a repository into a new directory.
commit Record changes to the repository.
config Get and set repository or global options.
log Show commit logs.
push Update remote refs along with associated objects.
status Show the working tree status.
smol-git
, as the name suggests, is a small clone of git. It has six subcommands; clone
, config
, log
, status
, commit
and push
.
import click
@click.group("smol-git")
@click.version_option("0.1.0")
def cli(*args, **kwargs):
"""smol-git - the stupid content tracker"""
pass
Again, we import click
and create a cli()
function with a docstring. We convert it into a command group using the click.group()
decorator and also add a version_option
.
Use cases
Progress bars
A common CLI use case is to display progress bars to the user. For example, in the case of the clone
subcommand, where we should let the user know about the progress of how many files have been cloned. click
has a utility that can help us add a progress bar to our output.
@cli.command()
@click.argument("src")
@click.argument("dest", required=False)
def clone(src, dest):
...
with click.progressbar(files) as _files:
for file in _files:
# download file
First, we define the clone
subcommand with the src
and dest
arguments. Now let's say we have a list called files
, which contains all the files we want to clone. We can simply pass that list to the click.progressbar
context manager that returns an iterator. As we iterate on it and download each file, click
will show the progress to the user.
Application folders
Another CLI use case is to persist user-specific configuration to a file. For example, in the case of the config
subcommand, where we should save the user's name and email in a file in our application folder. But how do we get the path to our application folder? We can do this using the click.get_app_dir()
function.
@cli.command()
@click.argument("key")
@click.argument("value")
def config(key, value):
app_dir = click.get_app_dir("smol_git")
if not os.path.exists(app_dir):
os.makedirs(app_dir)
cfg = os.path.join(app_dir, "config")
# set repository or global options
First, we define a config
subcommand with key
and value
arguments. We get the path to smol-git
's application folder using the click.get_app_dir()
function and save it in the app_dir
variable. We should create the path if it does not exist. Finally, we store user-specific config in a file called config
.
click.get_app_dir()
makes sure that our CLI follows the XDG spec. And since it is cross-platform, it will return the most appropriate path on Windows, macOS and Linux.
Paged output
Another CLI use case is to page large output, instead of printing it all at once. For example, in the case of the log subcommand, which prints a large commit log. click
supports paged output by using a terminal pager program like more
or less
.
@cli.command()
def log():
...
click.echo_via_pager(log_string)
We define a log
subcommand, where we can use the click.echo_via_pager()
function to display the large log string.
Colored text
Sometimes, we might need to color some of our CLI's output to distinguish it from other output. For example, we should color files that have changed, when they are printed using the status
subcommand. click
supports adding color to text using the click.style()
function.
Internally, it uses the colorama
package, which uses ANSI escape sequences to color the text.
@cli.command()
def status():
...
for file in files:
file_status = "new file" if file.added else "modified"
status_string += click.style(
f"\t{file_status}: {file.name}\n",
fg="green",
bold=True
)
click.echo(status_string)
First, we define a status
subcommand. Now let's say we have a list called files
, which contains all the files that have changed. We iterate on files
and add each file name to the status_string
variable. But before that, we use the click.style()
function to add a green foreground color to the file name and also make it bold. Finally, we print the status_string
variable using the click.echo()
function.
click
will strip the ANSI escape sequences if the output is redirected to a file. For example, in the case of a log file, where we would not want to look at confusing escape sequences.
Launching editors
Sometimes, we might also want multi-line input from the user. For example, in the case of the commit
subcommand, where we should ask the user for a commit message. click
supports launching editors for this use case. It will automatically open the user’s defined editor (using the EDITOR
environment variable), or fall back to a sensible default.
@cli.command()
@click.option("-m", "--message", help="The commit message.")
def commit(*args, **kwargs):
if kwargs["message"] is None:
commit_message = click.edit()
else:
commit_message = kwargs["message"]
# commit changes
We define a commit
subcommand and add a --message
option. If the user doesn't use --message
when this subcommand is invoked, we launch an editor to get the commit message.
User prompts
We can also ask users for one-line input using the click.prompt()
function. This can be useful for the push
subcommand, where we need to ask users for credentials to push files to a remote repository.
@cli.command()
@click.argument("repository")
@click.argument("branch")
def push(repository, branch):
username = click.prompt("Username for 'https://github.com'")
password = click.prompt(
f"Password for 'https://{username}@github.com'",
hide_input=True
)
# push changes
So we define a push
subcommand with two arguments, the remote repository
we want to push to, and the local branch
we want to push. We then use click.prompt()
function to ask the username and password. The return values for click.prompt()
can be stored in the username
and password
variables.
For the password prompt, we should set hide_input=True
. This won't print the user input on the terminal. Internally, click
uses the getpass module from the Python standard library to do this. And getpass
turns off echo
using the termios module, while password is being entered.
$ smol-git --help
Usage: smol-git [OPTIONS] COMMAND [ARGS]...
smol-git - the stupid content tracker
Options:
--version Show the version and exit.
--help Show this message and exit.
Commands:
clone Clone a repository into a new directory.
commit Record changes to the repository.
config Get and set repository or global options.
log Show commit logs.
push Update remote refs along with associated objects.
status Show the working tree status.
And when we're done, we have the CLI help auto generated! You can check out the code on the smol-git GitHub repository.
Testing click code
click
also lets us test the CLIs that we write!
from click.testing import CliRunner
from smol_git.cli import cli
def test_git_log():
runner = CliRunner()
result = runner.invoke(cli, ['log'])
assert result.exit_code == 0
assert result.output == expected_output_log
We can use the CliRunner
class to invoke each subcommand in our CLI and check its result against the expected output.
These are only a subset of features which click
has to offer. You can check out the documentation to look at more awesome things that you can do with click
!
Packaging
Now that we have a CLI, let's package it!
.
├── setup.py
└── smol_git
├── cli.py
├── __init__.py
├── utils.py
└── __version__.py
To do this, we need to create a setup.py
outside the smol_git
module. The module contains a cli.py
which has the command group and subcommand code. Along with a utils.py
which has helper functions that are used in cli.py
.
from setuptools import setup
setup(
...
name="smol-git",
entry_points={
"console_scripts": [
"smol-git = smol_git.cli:cli"
]
},
...
)
In the setup.py
, we call the setup()
function (from setuptools
) with an entry point called console_scripts
. This registers the cli
command group from smol_git.cli
as a command-line program called smol-git
whenever someone installs the package. You can check out the full setup.py here.
$ python setup.py sdist bdist_wheel
$ twine upload dist/*
Now that we've packaged our CLI, we can also push it to PyPI so that other people can install and use it. To do this, we create a source distribution and a wheel using the setup.py
, and upload them to PyPI using twine.
Continue to Part 4 — User Experience