Motivation
When writing command-line applications in Python I frequently find myself in situations where I want to expose the functionality of some specific set of functions - independently of the rest of the application. Often this is for testing or for experimentation, but occasionally also for general use, e.g. sometimes there are auxiliary/helper/utility functions which are useful on their own. Instead of manually writing a separate CLI for each such function, I thought it would be nice to have a way to automatically generate a CLI from a given collection of functions in a Python module.
Features
There were a few basic features that I wanted to make sure I included:
Have the code be as "hands-off" as possible, e.g. don't require the user to specify anything beyond which set of functions should be included in the CLI.
Have a module-level parser and automatically generate a subcommand (with its own command-line parser) for each specified function.
Automatically generate help information using introspection, e.g. get function signatures using the
inspect
module and extract function descriptions from doc-strings.Automatically convert between hyphens and underscores, e.g. allow hyphens to be used in command-line parameter names.
Allow return values to be printed to the console.
Implementation
I wrote a small module called autocli.py
that provides an AutoCLI
class which acts as a controller for an auto-generated CLI. It keeps track of registered functions, using introspection to generate a parser and a help message for each one, and executes the function corresponding to a given command. The autocli.py
module also contains a decorator called register_function_with_cli
which is used to register functions with a given AutoCLI
instance; this function is wrapped by the AutoCLI.register
instance method. Here is the autocli.py
module:
#!/usr/bin/env python2
# -*- encoding: ascii -*-
"""autocli.py
Example that illustrates autogenerating
command-line interfaces for Python modules.
"""
from __future__ import print_function
import argparse
import inspect
import logging
def register_function_with_cli(_cli):
"""A decorator to register functions with the command-line interface controller.
"""
# Make sure we're passing in an AutoCLI object
assert(issubclass(type(_cli), AutoCLI))
# Define the decorator function
def _decorator(_function):
# Get command name and convert underscores to hyphens/dashes
command_name = _function.__name__.replace('_', '-')
# Get the help message from the doc-string if the doc-string exists
if _function.__doc__:
help_string = \
_function.__doc__.split("\n")[0]
else:
help_string = ""
# Add a subparser corresponding to the given function
subparser = _cli.subparsers.add_parser(
command_name,
help=help_string,
description="Function: %s" % _function.__name__,
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
# Get the argument specification for the function
args, varargs, varkwargs, defaults = inspect.getargspec(_function)
argspec = inspect.getargspec(_function)
# Ensure that args are a list
# (i.e. handle case where no arguments are given)
parameters = argspec.args if argspec.args else list()
# Ensure that defaults are a list
# (i.e. handle case where no defaults are given)
defaults = argspec.defaults if argspec.defaults else list()
# Get the total number of parameters
n_params = len(parameters)
# Get the number of parameters with default values
# (i.e. the number of keyword arguments)
n_defaults = len(defaults)
# Get the starting index of the keyword arguments
# (i.e. the number of positional arguments)
kw_start_index = n_params - n_defaults
# Add the positional function parameter to the subparsers
for parameter in parameters[:kw_start_index]:
# Convert underscores to hyphens/dashes
parameter = parameter.replace('_', '-')
# Add the parameter to the subparser
subparser.add_argument(parameter)
# Add the keyword parameters and default values
for parameter, default_value in zip(parameters[kw_start_index:], defaults):
# Convert underscores to hyphens/dashes
parameter = parameter.replace('_', '-')
# NOTE: ArgumentDefaultsHelpFormatter requires non-empty
# help string to display default value.
subparser.add_argument(parameter, nargs='?', default=default_value, help=' ')
# Register the function with the CLI
_cli.commands[_function.__name__] = _function
# Return the original function untouched
return _function
# Return the decorator
return _decorator
class AutoCLI(object):
"""Keeps track of registered functions."""
def __init__(self):
# Create a logger for this CLI
self.logger = logging.getLogger(str(self))
# By default print warnings to standard-output
self.logger.stream_handler = logging.StreamHandler()
self.logger.stream_handler.setLevel(logging.WARNING)
self.logger.log_formatter = logging.Formatter(
"%(levelname)5s:%(filename)s:%(lineno)d:%(name)s - %(message)s"
)
self.logger.stream_handler.setFormatter(self.logger.log_formatter)
self.logger.addHandler(self.logger.stream_handler)
# Instantiate a dict to store registered commands
self.commands = {}
# Instantiate the main parser for the CLI
self.parser = argparse.ArgumentParser(
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
# Allow debugging level to be set
self.parser.add_argument(
"--log-level", dest="log_level", metavar="LEVEL",
choices=[
"NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL",
],
help="Set the logging level"
)
# Specifies whether or not the return value of the executed function should be printed
self.parser.add_argument(
"--return-output", dest="return_output", action='store_true',
help="Print the returned value of the executed function"
)
# Allow logging to a file instead of to the console
self.parser.add_argument(
"--log-file", dest="log_file", metavar="LOGFILE",
help="Write logs to a file instead of to the console"
)
# Customize help message (replace "positional arguments header")
self.parser._positionals.title = "Subcommands"
# Add support for subparsers (customize layout using metavar)
self.subparsers = self.parser.add_subparsers(
help="Description",
dest="subcommand_name",
metavar="Subcommand",
)
def run(self):
"""Parse the command-line and execute the given command."""
# Parse the command-line
args = self.parser.parse_args()
# Set log level
if(args.log_level):
self.logger.setLevel(args.log_level)
# Set log file
if(args.log_file):
self.logger.file_handler = logging.FileHandler(args.log_file)
self.logger.file_handler.setFormatter(self.logger.log_formatter)
self.logger.addHandler(self.logger.file_handler)
self.logger.file_handler.setLevel(logging.NOTSET)
else:
self.logger.stream_handler.setLevel(logging.NOTSET)
# Convert the Namespace object to a dictionary
arg_dict = vars(args)
# Extract the subcommand name
subcommand_name = args.subcommand_name
# Convert hyphens/dashes to underscores
subcommand_name = subcommand_name.replace('-', '_')
# Get the corresponding function object
_function = self.commands[subcommand_name]
# Get the argument specification object of the function
argspec = inspect.getargspec(_function)
# Extract the arguments for the subcommand
# NOTE: Convert hyphens/dashes to underscores
# NOTE: Superfluous arguments are ignored!
relevant_args = {
key.replace('-', '_'): arg_dict[key]
for key in arg_dict
if key.replace('-', '_') in argspec.args
}
# Log some output
self.logger.debug("Executing function: %s" % self.commands[subcommand_name])
# Execute the command
return_value = self.commands[subcommand_name](**relevant_args)
# If desired, print the canonical representation of the return value
if args.return_output:
print(return_value.__repr__())
def register_function(self):
"""Register a function with the registrar."""
return register_function_with_cli(self)
Example
I also wrote a small example script (autocli_example.py
) to illustrate the basic usage:
from autocli_simple import register_function_with_cli
from autocli_simple import AutoCLI
import sys
# Example program
if __name__ == "__main__":
# Instantiate a CLI
cli = AutoCLI()
# Define a function and register it with
# the CLI by using the function decorator
@register_function_with_cli(cli)
def return_string_1(input_string):
"""Returns the given string. No default value."""
return input_string
# Define a function and register it with the
# CLI by using the instance method decorator
@cli.register_function()
def return_string_2(input_string="Hello world!"):
"""Returns the given string. Defaults to 'Hello world!'"""
return input_string
# Run the CLI
try:
cli.run()
except Exception as e:
cli.logger.warning("Invalid command syntax")
cli.parser.print_usage()
sys.exit(1)
Running the example script with the -h
flag (i.e. python autocli_example.py -h
) displays the following module-level help message:
usage: autocli_example.py [-h] [--log-level LEVEL] [--return-output]
[--log-file LOGFILE]
Subcommand ...
Subcommands:
Subcommand Description
return-string-1 Returns the given string. No default value.
return-string-2 Returns the given string. Defaults to 'Hello world!'
optional arguments:
-h, --help show this help message and exit
--log-level LEVEL Set the logging level (default: None)
--return-output Print the returned value of the executed function
(default: False)
--log-file LOGFILE Write logs to a file instead of to the console (default:
None)
We can also display help messages for each of the two subcommands. Here is the output for python autocli_example.py return-string-1 -h
:
usage: autocli_example.py return-string-1 [-h] input-string
Function: return_string_1
positional arguments:
input-string
optional arguments:
-h, --help show this help message and exit
And here is the output for python autocli_example.py return-string-2 -h
:
usage: autocli_example.py return-string-2 [-h] [input-string]
Function: return_string_2
positional arguments:
input-string (default: Hello world!)
optional arguments:
-h, --help show this help message and exit
Finally, here is an example of executing one of the functions via the autogenerated CLI:
python autocli_example.py --return-output return-string-1 "This is my input string"
It produces the following output:
'This is my input string'
Comments
Since posting this I came across two projects which appear to have similar goals in mind:
I'm including them here for context/comparison.
click
, right? It does something that is basically the same with only a slightly different interface (you need to specify each argument explicitly). \$\endgroup\$click
a few times before. But the main thing that I'm looking for is that automatic generation of the CLI, which I don't thinkclick
does (e.g. autogenerating the help text from the docstrings) - does it? I was thinking of usingclick
instead ofargparse
, but for this post I decided I'd stick with the standard library - keep things simple. \$\endgroup\$help
keyword argument). \$\endgroup\$click
module does automate most of the CLI construction process, but it doesn't use introspection to construct the CLI from the function definitions themselves. That's the part that I'm interested in here. \$\endgroup\$