I’ve always used argparse. I’ve tried a few others, but it’s hard to be beat a built-in argument parser with power and flexibility of argparse. Recently, however, I’ve found click appearing increasingly in my requirements.txt and pyproject.toml. While I have not explored the depths of click (most of my use cases don’t involve a high degree of complexity), I thought I’d cover the basics.
click operates by decorating a function with the desired parameters. We start with by add the decorator click.command() which tells click that when we call this function, we want it to parse and then pass any command line arguments (technically, it gets turned into a Command object). Here’s an example which we’ll eventually modify to run some algorithm on multiple files:
# usage: python this.py
from pathlib import Path
import click # the library will need to be installed: `pip install click`
@click.command()
def _run_files(files: list[Path] = None):
if files is None:
click.echo(f'No files to process.') # I never use `echo`
if __name__ == '__main__':
_run_files()
#> No files to process.
We can now run this script with python this.py. We haven’t told click about any of our desired arguments yet, and if we do include one (e.g., python this.py file1.txt) we’ll get an error and usage notice from click.
(The if __name__ == '__main__' guard ensure that the _run_files function is only run when this particular file is run. In larger code bases, I will often have _run_files directly call a run_files function that can be imported from within Python, without having to worry about the click decorators.)
Note that echo is probably a more robust version of print when building command line interfaces. Most of my CLIs, however, don’t quite require this attention to detail.
Okay, our function thus far is quite boring. Being told there aren’t files to process without being able to supply those as arguments is guaranteed to receive some nasty feedback. Let’s alter this to allow a single file to be specified as an option. An option (as opposed to an argument) is a unordered parameter specified with a flag (e.g., calling this: python this.py --file file1.txt, rather than python this.py file1.txt). The argument would not use any --file flags.
Let’s add a --file option and have the program calculate the length of each input file. In our case, only a single file is allowed (but we’ll fix that later).
from pathlib import Path
import click
@click.command()
@click.option('--file', default=None, type=click.Path(exists=True, dir_okay=False, path_type=Path),
help='Specify file to run.')
def _run_files(file: Path = None):
length = 0
if file: # file defaults to None, so need to check that
with open(file) as fh:
length = len(fh.read()) # get length
print(f'Length of file {file} is {length}.')
if __name__ == '__main__':
_run_files()
#>>> python this.py
#> Length of file None is 0.
#>>> python this.py --file file1.txt
#> Length of file file1.txt is 247.
Here, we’ve add the click.option decorator supplying our first option. I’ve also introduced the three most important options: default, type, and help. default provides a default value if this option is not supplied. type instructs click how to interpret the data (I’ve shown the complex case of wanting a pathlib.Path object, but in many other cases using something like int when wanting a number are much more straightforward). Finally, help provides context/instructions for use of the command.
I want to make one edit to the above code, however, and that is to convert the file option into an argument. I really don’t want my users to have to type --file in every time when it’s both obvious that a file needs to be given, and not providing a file to this function has no meaning. Within click‘s philosophy, arguments should be obvious and required parameters. To make the change will simply require removing the hyphens preceding ‘file’, and removing the help argument. click does not allow a help argument in click.argument since, once again, arguments should be obvious and required. Otherwise, use an option!
@click.command()
@click.argument('file', type=click.Path(exists=True, dir_okay=False, path_type=Path))
def _run_files(file: Path): # the 'file' in `click.argument` tells click to supply this to the `file` parameter
with open(file) as fh:
length = len(fh.read())
print(f'Length of file {file} is {length}.')
if __name__ == '__main__':
_run_files()
#>>> python this.py
#> prints usage information
#>>> python this.py file1.txt
#> Length of file file1.txt is 247.
Our initial design was to allow multiple files to be specified — how can we do that in click? Just like in argparse, click supports a nargs function. By default, it’s quite sensibly 1. We could set it to 2 if we always wanted 2 arguments supplied to file, or -1 if we want to allow any number of arguments. Let’s change file to files and set the number of arguments as infinite. This will pass a tuple of Paths to the function (so we’ll update the function signature too).
@click.command()
@click.argument('files', nargs=-1, type=click.Path(exists=True, dir_okay=False, path_type=Path))
def _run_files(files: tuple[Path]):
for file in files:
with open(file) as fh:
length = len(fh.read())
print(f'Length of file {file} is {length}.')
if __name__ == '__main__':
_run_files()
#>>> python this.py file1.txt
#> Length of file file1.txt is 247.
#>>> python this.py file1.txt file2.txt file3.txt
#> Length of file file1.txt is 247.
#> Length of file file2.txt is 0.
#> Length of file file3.txt is 811.
Let’s explore options a little more too. Suppose we want to add a feature which allows counting the number of regular expression matches in each file. Most of this should be familiar, but you can also see a lambda argument passed to the type so that all the regular expressions will be passed into the function compiled.
@click.command()
@click.argument('files', nargs=-1, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option('--regex', default=None, type=lambda x: re.compile(x, re.I),
help='Regular expression to search for.')
def _run_files(files: tuple[Path], regex):
for file in files:
with open(file) as fh:
text = fh.read()
print(f'Length of file {file} is {len(text)}.')
count = sum(1 for _ in regex.finditer(text))
print(f'* Found "{regex.pattern}": {count}')
if __name__ == '__main__':
_run_files()
Output:
# python this.py file1.txt file2.txt file3.txt --regex file
Length of file file1.txt is 247.
* Found "file": 6
Length of file file2.txt is 0.
* Found "file": 0
Length of file file3.txt is 811.
* Found "file": 14
A single regular expression as output is fine, but better would be to permit as many as the user would like. Unfortunately, click doesn’t allow an unlimited number of elements (nargs) passed to an option, and you can’t have two infinite arguments. The solution is multiple=True, allowing the same option to be used multiple times. Also, since ‘regex’ isn’t a very good name for multiple regular expressions, we’re going to add the destination label we want applied to the entire set of supplied --regex options.
@click.command()
@click.argument('files', nargs=-1, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option('--regex', 'regexes', multiple=True, type=lambda x: re.compile(x, re.I),
help='Regular expression to search for.')
def _run_files(files: tuple[Path], regexes): # changed 'regex' to 'regexes' as specified in 'option'
for file in files:
with open(file) as fh:
text = fh.read()
print(f'Length of file {file} is {len(text)}.')
for regex in regexes: # iterate through all regular expressions
count = sum(1 for _ in regex.finditer(text))
print(f'* Found "{regex.pattern}": {count}')
if __name__ == '__main__':
_run_files()
Output:
# python this.py file1.txt file2.txt file3.txt --regex file --regex with
Length of file file1.txt is 247.
* Found "file": 6
* Found "with": 1
Length of file file2.txt is 0.
* Found "file": 0
* Found "with": 0
Length of file file3.txt is 811.
* Found "file": 14
* Found "with": 7
And those are the basics!