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