The Hitchhiker's Guide to CLIs in Python — Part 3: Writing and packaging a CLI using Click

Let'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

Comments!