Build Python Click Like Command-line Applications With Argparse Part 1
Click is a popular package to create Python command-Line applications. And it's for a good reason. Using decorators keeps the command-line configuration next to the handling code. It is easier to relate configuration with handling code.
The argparse experience can be improved to gain Click like decorator based configuration.
The Simple command case
Let's start with a single command build script with a few options:
a source positional argument to specify the build source
a --output option to specify the build result directory
a --clean flag to cleanup things before building
a repeatable --verbose flag to increase the verbosity level
Using argparse the classic way
When using argparse the classic way, arguments are defined in a separate location from where they will be used:
build-python-click-like-command-line-applications-with-argparse/build_argparse_classic.py (Source)
import argparse from pathlib import Path def parse_args(args=None): parser = argparse.ArgumentParser(description=build.__doc__) parser.add_argument("source", default="./src", type=Path, help="Source directory.") parser.add_argument( "--output", "-o", default="./build", type=Path, help="Build directory." ) parser.add_argument( "--clean", "-c", action="store_true", help="Clean before build." ) parser.add_argument( "--verbose", "-v", action="count", default=0, dest="verbosity", help="Add more verbosity.", ) return parser.parse_args(args) def build(source, output, clean, verbosity): """Build Stuff.""" print("Build:") print(f" - source: {source}") print(f" - output: {output}") print(f" - clean: {clean}") print(f" - verbosity: {verbosity}") def main(args=None): args = parse_args(args) build(args.source, args.output, args.clean, args.verbosity) if __name__ == "__main__": main()
The generated help looks like:
build-python-click-like-command-line-applications-with-argparse/build_argparse_help.txt (Source)
usage: argparse_decorators.py [-h] [--verbose] [--clean] [--output OUTPUT] source
Build Stuff.
positional arguments:
source Source directory.
optional arguments:
-h, --help show this help message and exit
--verbose, -v Add more verbosity.
--clean, -c Clean before build.
--output OUTPUT, -o OUTPUT
Build directory.
Using Click
When using click, arguments are defined where they will be used:
build-python-click-like-command-line-applications-with-argparse/build_click.py (Source)
from pathlib import Path import click @click.command() @click.argument("source", default="./src", type=Path, help="Source directory.") @click.option("--output", "-o", default="./build", type=Path, help="Build directory.") @click.option("--clean", "-c", is_flag=True, help="Clean before build.") @click.option("--verbose", "-v", "verbosity", count=True, help="Add more verbosity.") def build(source, output, clean, verbosity): """Build Stuff.""" print("Build:") print(f" - source: {source}") print(f" - output: {output}") print(f" - clean: {clean}") print(f" - verbosity: {verbosity}") if __name__ == "__main__": build()
Using argparse the Click way
We can have something similar using argparse:
build-python-click-like-command-line-applications-with-argparse/build_argparse_decorators.py (Source)
from pathlib import Path from argparse_decorators import add_argument as arg from argparse_decorators import get_main @arg("source", default="./src", type=Path, help="Source directory.") @arg("--output", "-o", default="./build", type=Path, help="Build directory.") @arg("--clean", "-c", action="store_true", help="Clean before build.") @arg( "--verbose", "-v", action="count", default=0, dest="verbosity", help="Add more verbosity.", ) def build(source, output, clean, verbosity): """Build Stuff.""" print("Build:") print(f" - source: {source}") print(f" - output: {output}") print(f" - clean: {clean}") print(f" - verbosity: {verbosity}") main = get_main(build) if __name__ == "__main__": main()
At first build the main() instead of just calling build() might seems strange. However, it has its advantages:
main can be called with command-line args main("./src", "--clean")
build is not modified and its call matches its signature: build(Path("./src"), clean=True).
Those are valuablbe interfaces when testing or when using the script as a library.
How does it works
add_argument creates build.__argparse_annotations__ if it does not exists and appends information in it for later use.
Then get_main reads build.__argparse_annotations__ to build an argparse.ArgumentParser and build the main function
Full listing:
build-python-click-like-command-line-applications-with-argparse/argparse_decorators.py (Source)
import inspect from argparse import ArgumentParser def get_main(function): """Create a main function for a command. The main function will be callable using the command-line arguments: `main('--flag', '--option=option')` The command will be called according to its argspec using available args. """ parser = get_parser(function) def main(*args: str): namespace = parser.parse_args(args if args else None) return call(function, namespace) return main def get_parser(function): """Create a parser for a function. The parser description will use the function docstring. Example: .. code: python from argparse_decorators import add_argument, get_parser @add_argument("argument") def command(argument) ... parser = get_parser(command) It's same as: .. code: python import argparse from argparse_decorators import add_argument, get_parser def command(argument) ... parser = argparse.ArgumentParser(description=command.__doc__) parser.add_argument("argument") The parser will be configured according to __argparse_annotations__. """ parser = ArgumentParser(description=function.__doc__) configure_parser(parser, function) return parser def configure_parser(parser, command): """Configure a parser from a decorated function.""" annotations = getattr(command, "__argparse_annotations__", []) for args, kwargs in annotations: parser.add_argument(*args, **kwargs) def call(function, args): """Call a function according to its argspec using available cli args. Example: .. code: python import argparse from argparse_decorators import add_argument, call @add_argument("argument") def command(argument) assert argument == "value" call(command, argparse.Namespace(argument="value")) The parser will be configured according to __argparse_annotations__. """ argspec = inspect.getargspec(function) call_args = {arg: getattr(args, arg) for arg in argspec.args} return function(**call_args) def add_argument(*args, **kwargs): """Build a decorator that will add argparse annotations to a function.""" return lambda decorated: _add_annotation(decorated, (args, kwargs)) def _add_annotation(function, argument): """Add argparse annotations to a function.""" annotations = getattr(function, "__argparse_annotations__", []) annotations.append(argument) setattr(function, "__argparse_annotations__", annotations) return function
In part 2 we will see:
How to fallback to classic argparse if needed without loosing all the benefits from décorators
How to extend to simplify some parts (like a better --verbose declaration).