Blog Post

Building Delightful Command-Line Interfaces with Click

In software engineering, we talk a lot about creating intuitive and delightful user interfaces, which is to say, graphical user interfaces. But what about the command-line? At Ginkgo, our users are scientists and biological engineers, so some of the software that we write are best presented as command-line tools rather than web apps. Click is a powerful and easy-to-use Python library that you can use to create rich command-line interfaces, and I'll be going over some of its basic features.

The Basics

Getting Started

One of the nice things about Click is that it's easy to get started, and you can realize a lot of power without much boiler plate at all.
#!/usr/bin/env python3

# hello_world.py import click

@click.command() def hello_world(): click.echo('Hello, world!')

if __name__ == '__main__': hello_world()

The @click.command() decorator is not terrifically useful on its own -- it's a starting point from which we will add more features to our command-line UI. click.echo is very much like print, but the result is more consistent across different terminal environments.

Arguments

We enrich the behavior of our command-line user interface by adding decorators to our main function. For example, we can use the @click.argument() decorator to specify that our "Hello, World!" tool takes an argument name, which will form a part of the greeting:
#!/usr/bin/env python3 import click

@click.argument('name') @click.command() def hello_world(name): click.echo(f'Hello, {name}!')

if __name__ == '__main__': hello_world()

Now, we can run the tool with the argument:
> ./hello_world.py Ray Hello, Ray!
The decorator @click.argument('name') specifies that the command-line interface takes a single argument, which is passed to the main function as the named argument name.

Options

Specify command-line options also with a decorator:
#!/usr/bin/env python3 import click

@click.option('--punctuation', default='!') @click.option('--greeting', default='Hello') @click.argument('name') @click.command() def hello_world(name, greeting, punctuation): click.echo(f'{greeting}, {name}{punctuation}')

if __name__ == '__main__': hello_world()

Here, we have added two command-line options: --greeting and --punctuation. These options are passed into the command function as keyword arguments greeting and punctuation respectively (where Click generated the keyword argument names by parsing names of the options). We have set default values for both options, in case either option is left out when the command is invoked:
> ./hello_world.py Ray --greeting Bonjour Bonjour, Ray!
We can also set an option as required:
#!/usr/bin/env python3 import click

@click.option('--punctuation', default='!') @click.option('--greeting', required=True) @click.argument('name') @click.command() def hello_world(name, greeting, punctuation): click.echo(f'{greeting}, {name}{punctuation}')

if __name__ == '__main__': hello_world()

So, this time, if we were to leave out the --greeting option:
> ./hello_world.py Ray Usage: hello_world.py [OPTIONS] NAME Try 'hello_world.py --help' for help.

Error: Missing option '--greeting'.

As you can see, the command function will not run, and the script quits with an error message and suggestion to invoke the --help option, which brings us to the next feature of Click that we will discuss.

Help Documentation

Click makes it easy to create rich and informative help documentation. Click automatically adds a --help option to all commands (which can be disabled by passing add_help_option=False to @click.command()).

Docstring Integration

Click integrates with Python docstrings to generate descriptions of commands for the help screen:
#!/usr/bin/env python3 import click

@click.option('--punctuation', default='!') @click.option('--greeting', default='Hello') @click.argument('name') @click.command() def hello_world(name, greeting, punctuation): """ Prints a polite, customized greeting to the console. """ click.echo(f'{greeting}, {name}{punctuation}')

if __name__ == '__main__': hello_world()

> ./hello_world.py --help Usage: hello_world.py [OPTIONS] NAME

Prints a polite, customized greeting to the console.

Options: --greeting TEXT --punctuation TEXT --help Show this message and exit.

Here, by adding a docstring to the command function, we are simultaneously helping developers by documenting our source code, as well as our end-users by providing a useful help screen message.

Documenting Options and Arguments

Document your options by using the help argument:
#!/usr/bin/env python3 import click

@click.option('--punctuation', default='!', help="Punctuation to use to end our greeting.") @click.option('--greeting', default='Hello', help="Word or phrase to use to greet our friend.") @click.argument('name') @click.command() def hello_world(name, greeting, punctuation): """ Prints a polite, customized greeting to the console. """ click.echo(f'{greeting}, {name}{punctuation}')

if __name__ == '__main__': hello_world()

> ./hello_world.py --help Usage: hello_world.py [OPTIONS] NAME

Prints a polite, customized greeting to the console.

Options: --greeting TEXT Word or phrase to use to greet our friend. --punctuation TEXT Punctuation to use to end our greeting. --help Show this message and exit.

Note that you cannot specify help text for arguments -- only options. You can, however, provide a more descriptive help screen by tweaking the metavars:
#!/usr/bin/env python3 import click

@click.option('--punctuation', default='!', metavar='PUNCTUATION_MARK', help="Punctuation to use to end our greeting.") @click.option('--greeting', default='Hello', help="Word or phrase to use to greet our friend.") @click.argument('name', metavar='NAME_OF_OUR_FRIEND') @click.command() def hello_world(name, greeting, punctuation): """ Prints a polite, customized greeting to the console. """ click.echo(f'{greeting}, {name}{punctuation}')

if __name__ == '__main__': hello_world()

> ./hello_world.py --help Usage: hello_world.py [OPTIONS] NAME_OF_OUR_FRIEND

Prints a polite, customized greeting to the console.

Options: --greeting TEXT Word or phrase to use to greet our friend. --punctuation PUNCTUATION_MARK Punctuation to use to end our greeting. --help Show this message and exit.

Types

Click gives us some validation right out of the box with types. For example, you can specify that an argument or option must be an integer:
#!/usr/bin/env python3 import click

@click.option('--punctuation', default='!', metavar='PUNCTUATION_MARK', help="Punctuation to use to end our greeting.") @click.option('--greeting', default='Hello', help="Word or phrase to use to greet our friend.") @click.option('--number', default=1, type=click.INT, help="The number of times to greet our friend.") @click.argument('name', metavar='NAME_OF_OUR_FRIEND') @click.command() def hello_world(name, greeting, punctuation, number): """ Prints a polite, customized greeting to the console. """ for _ in range(0, number): click.echo(f'{greeting}, {name}{punctuation}')

if __name__ == '__main__' hello_world()

> ./hello_world.py --help Usage: hello_world.py [OPTIONS] NAME_OF_OUR_FRIEND

Prints a polite, customized greeting to the console.

Options: --number INTEGER The number of times to greet our friend. --greeting TEXT Word or phrase to use to greet our friend. --punctuation PUNCTUATION_MARK Punctuation to use to end our greeting. --help Show this message and exit. > ./hello_world.py --number five Ray Usage: hello_world.py [OPTIONS] NAME_OF_OUR_FRIEND Try 'hello_world.py --help' for help.

Error: Invalid value for '--number': 'five' is not a valid integer. > ./hello_world.py --number 5 Ray Hello, Ray! Hello, Ray! Hello, Ray! Hello, Ray! Hello, Ray!

Click gives types that are beyond the primitives like integer, string, etc. You can specify that an argument or option must be a file:
#!/usr/bin/env python3 import click

@click.option('--punctuation', default='!', metavar='PUNCTUATION_MARK', help="Punctuation to use to end our greeting.") @click.option('--greeting', default='Hello', help="Word or phrase to use to greet our friend.") @click.argument('fh', metavar='FILE_WITH_LIST_OF_NAMES', type=click.File()) @click.command() def hello_world(greeting, punctuation, fh): """ Prints a polite, customized greeting to the console. """ for name in fh.readlines(): click.echo(f'{greeting}, {name.strip()}{punctuation}')

if __name__ == '__main__': hello_world()

The user enters a path to a file for FILE_WITH_LIST_OF_NAMES argument, and click will automatically open the file and pass the handle into the command function. (By default, the file will be open for read, but you can pass other arguments to click.File() to open the file in other ways.) Click fails gracefully if it cannot open the file at the specified path.
> ./hello_world.py ./wrong_file.txt Usage: hello_world.py [OPTIONS] FILE_WITH_LIST_OF_NAMES Try 'hello_world.py --help' for help.

Error: Invalid value for 'FILE_WITH_LIST_OF_NAMES': './wrong_file.txt': No such file or directory > ./hello_world.py ./names.txt Hello, Ray! Hello, Ben! Hello, Julia! Hello, Patrick! Hello, Taylor! Hello, David!

Click provides many useful types, and you can even implement your own custom types by subclassing click.ParamType.

Multiple and Nested Commands

Command Groups

The examples so far have included just a single command in our command-line tool, but you can implement several commands to create a more robust tool and richer command-line experience. We can use the click.group() decorator create a "command group", and then assign several Click "commands" to that group. The main script invokes the command group rather than the command:
#!/usr/bin/env python3 import click

@click.group() def hello_world(): """ Engage in a polite conversation with our friend. """ pass

@click.option('--punctuation', default='!', metavar='PUNCTUATION_MARK', help="Punctuation to use to end our greeting.") @click.option('--greeting', default='Hello', help="Word or phrase to use to greet our friend.") @click.argument('name', metavar='NAME_OF_OUR_FRIEND') @hello_world.command() def hello(name, greeting, punctuation): """ Prints a polite, customized greeting to the console. """ click.echo(f'{greeting}, {name}{punctuation}')

@hello_world.command() def goodbye(): """ Prints well-wishes for our departing friend. """ click.echo('Goodbye, and safe travels!')

if __name__ == '__main__': hello_world()

In this example, we have moved the functionality of our greeting functionality to a command hello and implemented a new command goodbye. hello_world is now a command group containing these two commands. Click implements a help option for our command group much in the way that it implements them for commands:
> ./hello_world.py --help Usage: hello_world.py [OPTIONS] COMMAND [ARGS]...

Engage in a polite conversation with our friend.

Options: --help Show this message and exit.

Commands: goodbye Prints well-wishes for our departing friend. hello Prints a polite, customized greeting to the console.

The hello command preserves the options, arguments, and documentation that it had when it was the root command.
> ./hello_world.py hello --help Usage: hello_world.py hello [OPTIONS] NAME_OF_OUR_FRIEND

Prints a polite, customized greeting to the console.

Options: --greeting TEXT Word or phrase to use to greet our friend. --punctuation PUNCTUATION_MARK Punctuation to use to end our greeting. --help Show this message and exit. > ./hello_world.py hello --punctuation . Ray Hello, Ray.

Nesting

We can arbitrarily nest command groups and commands by putting command groups inside of other command groups:
#!/usr/bin/env python3 import click

@click.group() def hello_world(): """ Engage in a polite conversation with our friend. """ pass

@click.option('--punctuation', default='!', metavar='PUNCTUATION_MARK', help="Punctuation to use to end our greeting.") @click.option('--greeting', default='Hello', help="Word or phrase to use to greet our friend.") @click.argument('name', metavar='NAME_OF_OUR_FRIEND') @hello_world.command() def hello(name, greeting, punctuation): """ Prints a polite, customized greeting to the console. """ click.echo(f'{greeting}, {name}{punctuation}')

@hello_world.group(name='other-phrases') def other(): """ Further conversation with our friend. """ pass

@other.command() def goodbye(): """ Prints well-wishes for our departing friend. """ click.echo('Goodbye, and safe travels!')

@other.command(name='how-are-you') def how(): """ Prints a polite inquiry into the well-being of our friend. """ click.echo('How are you?')

if __name__ == '__main__': hello_world()

> ./hello_world.py --help Usage: hello_world.py [OPTIONS] COMMAND [ARGS]...

Engage in a polite conversation with our friend.

Options: --help Show this message and exit.

Commands: hello Prints a polite, customized greeting to the console. other-phrases Further conversation with our friend. > ./hello_world.py other-phrases --help Usage: hello_world.py other-phrases [OPTIONS] COMMAND [ARGS]...

Further conversation with our friend.

Options: --help Show this message and exit.

Commands: goodbye Prints well-wishes for our departing friend. how-are-you Prints a polite inquiry into the well-being of our friend. > ./hello_world.py other-phrases how-are-you How are you?

Notice also that we can give our command groups and commands custom names, if we wish to name our commands and command groups something different from the Python functions that implement them.

Context

You can define options for command groups just as you can for commands. You can then use the Click context object to apply a command group's options to its commands:
#!/usr/bin/env python3 import click

@click.option('--punctuation', default='!', metavar='PUNCTUATION_MARK', help="Punctuation to use to end our sentences.") @click.group() @click.pass_context def hello_world(ctx, punctuation): """ Engage in a polite conversation with our friend. """ ctx.ensure_object(dict) ctx.obj['punctuation'] = punctuation pass

@click.option('--greeting', default='Hello', help="Word or phrase to use to greet our friend.") @click.argument('name', metavar='NAME_OF_OUR_FRIEND') @hello_world.command() @click.pass_context def hello(ctx, name, greeting): Prints a polite, customized greeting to the console. """ click.echo(f'{greeting}, {name}{ctx.obj["punctuation"]}')

@hello_world.group(name='other-phrases') def other(): """ Further conversation with our friend. """ pass

@other.command() @click.pass_context def goodbye(ctx): """ Prints well-wishes for our departing friend. """ click.echo(f'Goodbye, and safe travels{ctx.obj["punctuation"]}')

@other.command(name='how-are-you') @click.pass_context def how(ctx): """ Prints a polite inquiry into the well-being of our friend. """ click.echo(f'How are you{ctx.obj["punctuation"]}')

if __name__ == '__main__': hello_world()

Here, we initialize our context object to a dict, which we can then use to store and retrieve context values (such as the value for the punctuation option of the root command group).
./hello_world.py --help Usage: hello_world.py [OPTIONS] COMMAND [ARGS]...

Engage in a polite conversation with our friend.

Options: --punctuation PUNCTUATION_MARK Punctuation to use to end our sentences. --help Show this message and exit.

Commands: hello Prints a polite, customized greeting to the console. other-phrases Further conversation with our friend > ./hello_world.py --punctuation . hello Ray Hello, Ray. > ./hello_world.py --punctuation ?! other-phrases how-are-you How are you?!

Conclusion

We are really only scratching the surface of what Click can do. For more, check out the Click documentation! I hope this helps you get started in building robust command-line interfaces.

(Feature photo by Sai Kiran Anagani on Unsplash)



Posted by Raymond Lam