Juha-Matti Santala
Community Builder. Dreamer. Adventurer.

C is for command line interfaces - Python A to Z

Code in this blog post was written with versions: Python: 3.14

Python A-Z is a blog series about Python. Each day, I share insights, ideas and examples for different parts of Python development that match with the letter of the day. Blaugust is an annual blogging festival in August where the goal is to write a blog post every day of the month.

Python is a great language for writing scripts that are executed from the command line. I’m a big fan of command line interfaces and love building them for all sorts of needs both personally and when I’m part of a team.

Command Line Interface Guidelines

An integral part of command line software is dealing with arguments and options. In this post, I’ll focus on the technical part within the Python code but I highly recommend reading Command Line Interface Guidelines. It’s an open source guide to “help you write better command-line programs, taking traditional UNIX principles and updating them for the modern day”. I keep it at hand and always refer to it when I’m designing my interfaces.

Simple arguments through sys.argv

For the simplest use cases, accessing the list of arguments through sys.argv is the best option. I usually use it at the beginning while I’m still forming the ideas of what the tool will look like or when the script only requires one or two positional arguments.

Let’s say we run a script with the following:

python script.py 2 file.txt

To access those arguments, we write script.py to be:

import sys

script_name = sys.argv[0]  # script.py
copies = sys.argv[1]  # 2
filename = sys.argv[2]  # file.py

You can’t trust the user to always provide the right amount of arguments though so it’s a good idea to add some checks and instruct the user how to use it properly.

import sys

if __name__ == "__main__":
    arguments = sys.argv
    if len(arguments) < 3:
        print("""Two arguments required for the amount of copies and the filename.
    
Usage; script.py <amount of copies> <filename>""")
        sys.exit(1)

    copies = arguments[1]
    filename = arguments[2]

This provides a top level check AND documentation at the same time and is a great habit to get into.

Documentation-first approach with docopt

Speaking of documentation, docopt and its maintained Python implementation docopt-ng provide a documentation-driven way for declaring the command line interface.

Defining the interface first leads to better user experience. In my experience, starting with implementation details often leads to either suboptimal interfaces or spaghetti code to try to mend an implementation to interface later. If you start by thinking how the user should interact with the application, then the rest follows to fill the gaps in an elegant way.

Using docopt, you write the usage pattern in your docstring and let the tool figure out what arguments and options it needs to derive from it (example from docs):

"""Naval Fate.

Usage:
  naval_fate.py ship new <name>...
  naval_fate.py ship <name> move <x> <y> [--speed=<kn>]
  naval_fate.py ship shoot <x> <y>
  naval_fate.py mine (set|remove) <x> <y> [--moored | --drifting]
  naval_fate.py (-h | --help)
  naval_fate.py --version

Options:
  -h --help     Show this screen.
  --version     Show version.
  --speed=<kn>  Speed in knots [default: 10].
  --moored      Moored (anchored) mine.
  --drifting    Drifting mine.

"""
from docopt import docopt


if __name__ == '__main__':
    arguments = docopt(__doc__, version='Naval Fate 2.0')
    print(arguments)

I have written about docopt a few years ago and that post still holds water so I recommend you go read that for more information.

Click

The library I use most often these days — because it works so nicely with custom Django commands with django-click — is click. Click uses Python decorators to define commands, subcommands, arguments and options.

# Example from click documentation
# https://click.palletsprojects.com/en/stable/

import click

@click.command()
@click.option('--count', default=1, help='Number of greetings.')
@click.option('--name', prompt='Your name',
              help='The person to greet.')
def hello(count, name):
    """Simple program that greets NAME for a total of COUNT times."""
    for x in range(count):
        click.echo(f"Hello {name}!")

if __name__ == '__main__':
    hello()

You can add extra validation through callbacks directly at this level which makes the actual code of your application a bit cleaner when the validation and first level input processing is done before the arguments and options ever reach your application logic.

# Example from click documentation
# https://click.palletsprojects.com/en/stable/advanced/#callbacks-for-validation

def validate_rolls(ctx, param, value):
    if isinstance(value, tuple):
        return value

    try:
        rolls, _, dice = value.partition("d")
        return int(dice), int(rolls)
    except ValueError:
        raise click.BadParameter("format must be 'NdM'")

@click.command()
@click.option(
    "--rolls", type=click.UNPROCESSED, callback=validate_rolls,
    default="1d6", prompt=True,
)
def roll(rolls):
    sides, times = rolls
    click.echo(f"Rolling a {sides}-sided dice {times} time(s)")

With django-click, you can use the same interface to define an interface for your custom commands.

import djclick as click

@click.command()
@click.option("--file", help="Load set from a local JSON file")
def command(file):
	...


If something above resonated with you, let's start a discussion about it! Email me at juhis@hamatti.org and share your thoughts. This year, I want to have more deeper discussions with people from around the world and I'd love if you'd be part of that.