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 Path
s 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 argument
s. 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!