What?

  • Command Line Interface Creation Kit
     
  • Framework, NOT library

Features

  • arbitrary nesting of commands
  • automatic help pages
  • lazy loading
  • Unix/POSIX CLI conventions
  • environment variable handling
  • prompting
  • file handling
  • CLI helpers
  • argument types
  • composability
  • consistent error messages
  • ...

Why?

  • no other framework with all the feature
    (e.g. argument dispatching, nested parsing)
     
  • better than argparse or docopt
     
  • opinionated (e.g. help auto wrapping)
     
  • fun (really!)
     
  • support Flask
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('Hello %s!' % name)

if __name__ == '__main__':
    hello()

Hello World

$ python hello.py --count=3
Your name: John
Hello John!
Hello John!
Hello John!

output:

$ python hello.py --help
Usage: hello.py [OPTIONS]

  Simple program that greets NAME for a total of COUNT times.

Options:
  --count INTEGER  Number of greetings.
  --name TEXT      The person to greet.
  --help           Show this message and exit.

Entry point

(How to start?)

Call directly

import click

@click.command()
@click.option('--name', help='The person to greet.')
def hello(count, name):
    """Simple program that greets NAME for a total of COUNT times."""
    click.echo('Hello %s!' % name)


if __name__ == '__main__':
    hello()
$ python hello.py --name John
Hello John!

hello.py:

Setuptools entry_points

from setuptools import setup

setup(
    name='yourscript',
    version='0.1',
    py_modules=['yourscript'],
    install_requires=['Click'],
    entry_points='''
        [console_scripts]
        helloclick=yourscript:cli
    ''',
)
import click

@click.command()
@click.option('--name', help='The person to greet.')
def cli(name):
    """Example script."""
    click.echo('Hello %s!' % name)

yourscript/__init__.py

or

yourscript.py:

setup.py:

$ helloclick --name John
Hello John!

Help
(documenting scripts)

Help texts

@click.command()
@click.option('--count', default=1, help='number of greetings')
@click.argument('name')
def hello(count, name):
    """This script prints hello NAME COUNT times."""
    for x in range(count):
        click.echo('Hello %s!' % name)
$ hello --help
Usage: hello [OPTIONS] NAME

  This script prints hello NAME COUNT times.

Options:
  --count INTEGER  number of greetings
  --help           Show this message and exit.

output:

Meta variables

@click.command(options_metavar='<options>')
@click.option('--count', default=1, help='number of greetings',
              metavar='<int>')
@click.argument('name', metavar='<name>')
def hello(count, name):
    """This script prints hello <name> <int> times."""
    for x in range(count):
        click.echo('Hello %s!' % name)
$ hello --help
Usage: hello <options> <name>

  This script prints hello <name> <int> times.

Options:
  --count <int>  number of greetings
  --help         Show this message and exit.

Command short help

@click.group()
def cli():
    """A simple command line tool."""

@cli.command('init', short_help='init the repo')
def init():
    """Initializes the repository."""

@cli.command('delete', short_help='delete the repo')
def delete():
    """Deletes the repository."""
$ repo.py --help
Usage: repo.py [OPTIONS] COMMAND [ARGS]...

  A simple command line tool.

Options:
  --help  Show this message and exit.

Commands:
  delete  delete the repo
  init    init the repo
$ repo.py delete --help
Usage: repo.py delete [OPTIONS]

  Deletes the repository.

Options:
  --help  Show this message and exit.

repo.py:

Help parameter customization

CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])

@click.command(context_settings=CONTEXT_SETTINGS)
def cli():
    pass
$ cli -h
Usage: cli [OPTIONS]

Options:
  -h, --help  Show this message and exit.

Parameters

  • arguments
     
  • options

Arguments

  • more strict
     
  • arbitrary number of arguments
     
  • can be optional, but required most of the time
     
  • for subcommand input (e.g. filename, URL)

Basic Argument

@click.command()
@click.argument('name')
def arg(name):
    click.echo('Hello %s!' % name)
$ one-argument.py Balabit
Hello Balabit!

Variadic argument

@click.command()
@click.argument('names', nargs=-1)
def hello(names: tuple):
    for name in names:
        click.echo('Hello %s!' % name)
$ variadic.py Athos Porthos Aramis
Hello Athos!
Hello Porthos!
Hello Aramis!
variadic.py --help
Usage: variadic.py [OPTIONS] [NAMES]...

Options:
  --help  Show this message and exit.
$ variadic.py
$ echo $?
0

Required variadic argument ("+" in argparse)

@click.command()
@click.argument('names', nargs=-1, required=True)
def hello(names: tuple):
    for name in names:
        click.echo('Hello %s!' % name)
$ required-variadic.py
Usage: required-variadic.py [OPTIONS] NAMES...
Try "required-variadic.py --help" for help.

Error: Missing argument "NAMES...".

$  echo $?
2

Options

  • optional (hence the name)
     
  • automatic prompt for missing value
     
  • flags (e.g. boolean)
     
  • value from environment variables
     
  • fully documented
     
  • fixed number of arguments (default 1)

Option default

@click.command()
@click.option('--name', default="World")
def hello(name):
    click.echo('Hello %s!' % name)
$ option.py
Hello World!
$ option.py --name Balabit
Hello Balabit!
$ option.py --help
Usage: option.py [OPTIONS]

Options:
  --name TEXT
  --help       Show this message and exit.

Parameter Types

Basic Types

  • click.Int
     
  • click.Float
     
  • click.Bool
     
  • click.UUID
     
  • click.DateTime

click.File

class click.File(
    mode='r', 
    encoding=None, 
    errors='strict', 
    lazy=None, 
    atomic=False
)
  • automatically open and close
     
  • can write files atomically

click.Path

@click.option('-c', '--config', 'config_path', default=Config.DEFAULT_PATH,
              help=f'Default: {Config.DEFAULT_PATH}',
              type=click.Path(dir_okay=False, writable=True, resolve_path=True))
def main(config_path):
    ...

click.Choice

check agains a fixed set of values

click.IntRange

click.FloatRange

Custom Type

class EpisodeList(click.ParamType):
    name = "episodelist"

    def convert(self, value, param=None, ctx=None) -> Tuple[List[EpisodeParam], int]:
        biggest_last_n = 0
        episodes = set()
        episode_range_re = re.compile(r"^([0-9]{1,4})-([0-9]{1,4})$")

        for param in value.split(","):
            spec = param.upper()

            if spec.isnumeric():
                episodes.add(EpisodeParam(spec.zfill(4)))
                continue

            if spec == "LAST":
                biggest_last_n = max(biggest_last_n, 1)
                continue

            if spec.startswith("LAST:"):
                # will be added at the end once, when we know the biggest n value
                n = int(spec.split(":")[1])
                biggest_last_n = max(biggest_last_n, n)
                continue

            m = episode_range_re.match(spec)
            if m:
                first, last = m.group(1, 2)
                start, end = int(first), int(last) + 1
                episodes |= set(EpisodeParam(f"{e:04}") for e in range(start, end))
                continue

            if spec:
                episodes.add(EpisodeParam(param))

        return sorted(episodes), biggest_last_n

Custom Type

$ podcast-dl --episode 16,last talkpython
Specified podcast: talkpython - Talk Python To Me (https://talkpython.fm)
Download directory: /home/walkman/talkpython
Downloading RSS feed: https://talkpython.fm/episodes/rss ...
Searching episodes: 0016 and/or last 1.
Searching missing episodes...
Found a total of 2 missing episodes.
Downloading episodes...

Exception handling

Exceptions

  • Abort: Tell click to abort (exit the script)
     

  • UsageError: something went wrong
     

  • BadParameter: wrong parameter, invalid value
     

  • FileError: raised by FileType if can't open file
     

  • NoSuchOption
     

  • BadOptionUsage: incorrect option
     

  • BadArgumentUsage

Exception example

try:
    podcast = parse_site(podcast_name)
except InvalidSite:
    raise click.BadArgumentUsage(
        f'The given podcast "{podcast_name}" is not supported or invalid.\n'
        f'See the list of supported podcasts with "{ctx.info_name} --list-podcasts"',
        ctx=ctx,
    )
$ podcast-dl nonexisting
Usage: podcast-dl [OPTIONS] PODCAST
Try "podcast-dl --help" for help.

Error: The given podcast "nonexisting" is not supported or invalid.
See the list of supported podcasts with "podcast-dl --list-podcasts"

output:

Commands and groups

(nesting and composing commands)

Nesting commands

import click
import subcommand


@click.group()
def main():
    """Main group."""


@main.command()
def version():
    """Version subcommand."""
    print("1.0")


main.add_command(subcommand)

Dynamically loading subcommands

import click
import os

plugin_folder = os.path.join(os.path.dirname(__file__), 'commands')

class MyCLI(click.MultiCommand):

    def list_commands(self, ctx):
        rv = []
        for filename in os.listdir(plugin_folder):
            if filename.endswith('.py'):
                rv.append(filename[:-3])
        rv.sort()
        return rv

    def get_command(self, ctx, name):
        ns = {}
        fn = os.path.join(plugin_folder, name + '.py')
        with open(fn) as f:
            code = compile(f.read(), fn, 'exec')
            eval(code, ns, ns)
        return ns['cli']

cli = MyCLI(help='This tool\'s subcommands are loaded from a '
            'plugin folder dynamically.')

Merging multiple commands

import click

@click.group()
def cli1():
    pass

@cli1.command()
def cmd1():
    """Command on cli1"""

@click.group()
def cli2():
    pass

@cli2.command()
def cmd2():
    """Command on cli2"""

cli = click.CommandCollection(sources=[cli1, cli2])
$ cli --help
Usage: cli [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  cmd1  Command on cli1
  cmd2  Command on cli2

Command Chaining

$ setup.py sdist bdist_wheel
sdist called
bdist_wheel called
@click.group(chain=True)
def cli():
    pass


@cli.command('sdist')
def sdist():
    click.echo('sdist called')


@cli.command('bdist_wheel')
def bdist_wheel():
    click.echo('bdist_wheel called')

output:

Command Pipelines

User input

Input prompt

value = click.prompt('Please enter a valid integer', type=int)
value = click.prompt('Please enter a number', default=42.0)

Confirmation prompt

if click.confirm('Do you want to continue?'):
    click.echo('Well done!')
click.confirm('Do you want to continue?', abort=True)

Pause

click.pause()

Character input

import click

click.echo('Continue? [yn] ', nl=False)
c = click.getchar()
click.echo()
if c == 'y':
    click.echo('We will go on')
elif c == 'n':
    click.echo('Abort!')
else:
    click.echo('Invalid input :(')

Utilities

Printing

  • same in Python 2 and 3
     
  • detects misconfigured output streams
     
  • never fail
     
  • can print both Unicode and binary data
     
  • supports Windows console

 

click.echo('Hello World!')
click.echo('Hello World!', err=True)
click.echo(b'\xe2\x98\x83', nl=False)

Colors

import click

click.echo(click.style('Hello World!', fg='green'))
click.echo(click.style('Some more text', bg='blue', fg='white'))
click.echo(click.style('ATTENTION', blink=True, bold=True))

Pager support

@click.command()
def less():
    click.echo_via_pager('\n'.join('Line %d' % idx
                                   for idx in range(200)))

Screen cleaning

import click
click.clear()

Launching

import click

def get_commit_message():
    MARKER = '# Everything below is ignored\n'
    message = click.edit('\n\n' + MARKER)
    if message is not None:
        return message.split(MARKER, 1)[0].rstrip('\n')
import click
click.edit(filename='/etc/passwd')
click.launch("https://click.palletsprojects.com/")

Applications:

Editor:

click.launch("/my/downloaded/file.txt", locate=True)

Intelligent File Opening

import click

stdout = click.open_file('-', 'w')
test_file = click.open_file('test.txt', 'w')
with click.open_file(filename, 'w') as f:
    f.write('Hello World!\n')
  • reliable access to stdout and stdin
  • same on Python 2 and 3
  • consistent for a wide variaty of terminal configurations
import click

stdin_text = click.get_text_stream('stdin')
stdout_binary = click.get_binary_stream('stdout')

Standard streams

Finding Application Folders

import os
import click
import ConfigParser

APP_NAME = 'My Application'

def read_config():
    cfg = os.path.join(click.get_app_dir(APP_NAME), 'config.ini')
    parser = ConfigParser.RawConfigParser()
    parser.read([cfg])
    rv = {}
    for section in parser.sections():
        for key, value in parser.items(section):
            rv['%s.%s' % (section, key)] = value
    return rv

Progress bar

for user in all_the_users_to_process:
    modify_the_user(user)
import click

with click.progressbar(all_the_users_to_process) as bar:
    for user in bar:
        modify_the_user(user)

Testing

Basic testing

import click
from click.testing import CliRunner

def test_hello_world():
    @click.command()
    @click.argument('name')
    def hello(name):
        click.echo('Hello %s!' % name)

    runner = CliRunner()
    result = runner.invoke(hello, ['Peter'])
    assert result.exit_code == 0
    assert result.output == 'Hello Peter!\n'

Testing subcommands

import click
from click.testing import CliRunner

def test_sync():
    @click.group()
    @click.option('--debug/--no-debug', default=False)
    def cli(debug):
        click.echo('Debug mode is %s' % ('on' if debug else 'off'))

    @cli.command()
    def sync():
        click.echo('Syncing')

    runner = CliRunner()
    result = runner.invoke(cli, ['--debug', 'sync'])
    assert result.exit_code == 0
    assert 'Debug mode is on' in result.output
    assert 'Syncing' in result.output

File System isolation

import click
from click.testing import CliRunner

def test_cat():
    @click.command()
    @click.argument('f', type=click.File())
    def cat(f):
        click.echo(f.read())

    runner = CliRunner()
    with runner.isolated_filesystem():
        with open('hello.txt', 'w') as f:
            f.write('Hello World!')

        result = runner.invoke(cat, ['hello.txt'])
        assert result.exit_code == 0
        assert result.output == 'Hello World!\n'

Input streams

import click
from click.testing import CliRunner

def test_prompts():
    @click.command()
    @click.option('--foo', prompt=True)
    def test(foo):
        click.echo('foo=%s' % foo)

    runner = CliRunner()
    result = runner.invoke(test, input='wau wau\n')
    assert not result.exception
    assert result.output == 'Foo: wau wau\nfoo=wau wau\n'

Bash completion

Python Click

By Kiss György

Python Click

  • 407