Simon Willison’s Weblog

Subscribe

Things I’ve learned about building CLI tools in Python

30th September 2023

I build a lot of command-line tools in Python. It’s become my favorite way of quickly turning a piece of code into something I can use myself and package up for other people to use too.

My biggest CLI projects are sqlite-utils, LLM, shot-scraper and Datasette—but I have dozens of others and I build new ones at the rate of at least one a month. A fun recent example is blip-caption, a tiny CLI wrapper around the Salesforce BLIP model that can generate usable captions for image files.

Here are some notes on what I’ve learned about designing and implementing CLI tools in Python so far.

Starting with a template

I build enough CLI apps that I developed my own Cookiecutter template for starting new ones.

That template is simonw/click-app. You can create a new application from that template directly on GitHub, too—I wrote more about that in Dynamic content for GitHub repository templates using cookiecutter and GitHub Actions.

Arguments, options and conventions

Almost all of my tools are built using the Click Python library. Click encourages a specific way of designing CLI tools which I really like—I find myself annoyed at the various tools from other ecosystems that don’t stick to the conventions that Click encourages.

I’ll try to summarize those conventions here.

  • Commands have arguments and options. Arguments are positional—they are strings that you pass directly to the command, like data.db in datasette data.db. Arguments can be required or optional, and you can have commands which accept an unlimited number of arguments.
  • Options are, usually, optional. They are things like --port 8000. Options can also have a single character shortened version, such as -p 8000.
    • Very occasionally I’ll create an option that is required, usually because a command has so many positional arguments that forcing an option makes its usage easier to read.
  • Some options are flags—they don’t take any additional parameters, they just switch something on. shot-scraper --retina is an example of this.
  • Flags with single character shortcuts can be easily combined—symbex -in fetch_data is short for symbex --imports --no-file fetch_data for example.
  • Some options take multiple parameters. datasette --setting sql_time_limit_ms 10000 is an example, taking both the name of the setting and the value it should be set to.
  • Commands can have sub-commands, each with their own family of commands. llm templates is an example of this, with llm templates list and llm templates show and several more.
  • Every command should have help text—the more detailed the better. This can be viewed by running llm --help—or for sub-commands, llm templates --help.

Click makes it absurdly easy and productive to build CLI tools that follow these conventions.

Consistency is everything

As CLI utilities get larger, they can end up with a growing number of commands and options.

The most important thing in designing these is consistency with other existing commands and options (example here)—and with related tools that your user may have used before.

I often turn to GPT-4 for help with this: I’ll ask it for examples of existing CLI tools that do something similar to what I’m about to build, and see if there’s anything in their option design that I can emulate.

Since my various projects are designed to complement each other I try to stay consistent between them as well—I’ll often post an issue comment that says “similar to functionality in X”, with a copy of the --help output for the tool I’m about to imitate.

CLI interfaces are an API—version appropriately

I try to stick to semantic versioning for my projects, bumping the major version number on breaking changes and the minor version number for new features.

The command-line interface to a tool is absolutely part of that documented API. If someone writes a Bash script or a GitHub Actions automation that uses one of my tools, I’m cautious to avoid breaking that without bumping my major version number.

Include usage examples in --help

A habit I’ve formed more recently is trying to always including a working example of the command in the --help for that command.

I find I use this a lot for tools I’ve developed myself. All of my tools have extensive online documentation, but I like to be able to consult --help without opening a browser for most of their functionality.

Here’s one of my more involved examples—the help for the sqlite-utils convert command:

Usage: sqlite-utils convert [OPTIONS] DB_PATH TABLE COLUMNS... CODE

  Convert columns using Python code you supply. For example:

      sqlite-utils convert my.db mytable mycolumn \
          '"\n".join(textwrap.wrap(value, 10))' \
          --import=textwrap

  "value" is a variable with the column value to be converted.

  Use "-" for CODE to read Python code from standard input.

  The following common operations are available as recipe functions:

  r.jsonsplit(value, delimiter=',', type=<class 'str'>)

      Convert a string like a,b,c into a JSON array ["a", "b", "c"]

  r.parsedate(value, dayfirst=False, yearfirst=False, errors=None)

      Parse a date and convert it to ISO date format: yyyy-mm-dd
      
      - dayfirst=True: treat xx as the day in xx/yy/zz
      - yearfirst=True: treat xx as the year in xx/yy/zz
      - errors=r.IGNORE to ignore values that cannot be parsed
      - errors=r.SET_NULL to set values that cannot be parsed to null

  r.parsedatetime(value, dayfirst=False, yearfirst=False, errors=None)

      Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS
      
      - dayfirst=True: treat xx as the day in xx/yy/zz
      - yearfirst=True: treat xx as the year in xx/yy/zz
      - errors=r.IGNORE to ignore values that cannot be parsed
      - errors=r.SET_NULL to set values that cannot be parsed to null

  You can use these recipes like so:

      sqlite-utils convert my.db mytable mycolumn \
          'r.jsonsplit(value, delimiter=":")'

Options:
  --import TEXT                   Python modules to import
  --dry-run                       Show results of running this against first
                                  10 rows
  --multi                         Populate columns for keys in returned
                                  dictionary
  --where TEXT                    Optional where clause
  -p, --param <TEXT TEXT>...      Named :parameters for where clause
  --output TEXT                   Optional separate column to populate with
                                  the output
  --output-type [integer|float|blob|text]
                                  Column type to use for the output column
  --drop                          Drop original column afterwards
  --no-skip-false                 Don't skip falsey values
  -s, --silent                    Don't show a progress bar
  --pdb                           Open pdb debugger on first error
  -h, --help                      Show this message and exit.

Including --help in the online documentation

My larger tools tend to have extensive documentation independently of their help output. I update this documentation at the same time as the implementation and the tests, as described in The Perfect Commit.

I like to include the --help output in my documentation sites as well. This is mainly for my own purposes—having the help visible on a web page makes it much easier to review it and spot anything that needs updating.

Here are some example pages from my documentation that list --help output:

All of these pages are maintained automatically using Cog. I described the pattern I use for this in Using cog to update --help in a Markdown README file, or you can view source on the Datasette CLI reference for a more involved example.

This is Things I’ve learned about building CLI tools in Python by Simon Willison, posted on 30th September 2023.

Part of series My open source process

  1. Automating screenshots for the Datasette documentation using shot-scraper - Oct. 14, 2022, 11:44 p.m.
  2. The Perfect Commit - Oct. 29, 2022, 8:41 p.m.
  3. Coping strategies for the serial project hoarder - Nov. 26, 2022, 3:47 p.m.
  4. Things I've learned about building CLI tools in Python - Sept. 30, 2023, 12:12 a.m.
  5. Publish Python packages to PyPI with a python-lib cookiecutter template and GitHub Actions - Jan. 16, 2024, 9:59 p.m.

Next: Weeknotes: the Datasette Cloud API, a podcast appearance and more

Previous: Talking Large Language Models with Rooftop Ruby