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.
1 Answer 1
Your code is interesting, seems to be working fine and is well documented. However, there is still some place for improvement.
Python 2
It seems weird to write some Python 2 code in 2018. It could be interesting to give a reason (if there is a reason).
Comments ?
From my point of view, there are too many comments, cluttering up the code rather than helping the reader. Most things commented are fairly obvious and explain the "how" (which is already in the code) rather than the "why".
Variable names
The _
at the beginning of the local variable names does not bring much. You could simply get rid of it.
issubclass
-> isinstance
The isinstance
builtin is probably what you interested in your case.
itertools.izip_longest
You are retrieving various elements to be able to loop over parameters without default values then over the parameters with default values. You could loop over parameters and defaults values and fill with None for the parameters without. Because you want to fill from the beginning, you could simply reverse both input lists.
Duplicated logic
Expressions such as self.commands[subcommand_name]
are repeated in multiple places. This is easy to get rid of because you have stored it in func
already.
At this stage, the code looks like:
#!/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
import itertools
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 isinstance(cli, AutoCLI)
# Define the decorator function
def decorator(func):
argspec = inspect.getargspec(func)
func_name = func.__name__
command_name = func_name.replace('_', '-')
# Add a subparser corresponding to the given function
subparser = cli.subparsers.add_parser(
command_name,
help=func.__doc__.split("\n")[0] if func.__doc__ else "",
description="Function: %s" % func_name,
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
for param, default in itertools.izip_longest(
reversed(argspec.args if argspec.args else []),
reversed(argspec.defaults if argspec.defaults else []),
fillvalue=None):
param = param.replace('_', '-')
if default is None:
subparser.add_argument(param)
else:
# NOTE: ArgumentDefaultsHelpFormatter requires non-empty
# help string to display default value.
subparser.add_argument(param, nargs='?', default=default, help=' ')
# Register the function with the CLI
cli.commands[func_name] = func
# Return the original function untouched
return func
# 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
# NOTE: Convert hyphens/dashes to underscores
arg_dict = { k.replace('-', '_'): v for k, v in vars(args).iteritems() }
# Extract the subcommand name (convert hyphens/dashes to underscores)
subcommand_name = args.subcommand_name.replace('-', '_')
# Get the corresponding function object
func = self.commands[subcommand_name]
# Get the argument specification object of the function
argspec = inspect.getargspec(func)
# Extract the arguments for the subcommand
# NOTE: Superfluous arguments are ignored!
relevant_args = { k: v for k, v in arg_dict.iteritems() if k in argspec.args }
# Log some output
self.logger.debug("Executing function: %s" % func)
# Execute the command
return_value = func(**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)
Code reorganisation and duck typing
Instead of having a register_function
method calling a register_function_with_cli
function, we could have the function calling the method. That would add a few benefits:
all the related logic would fit in the class
AutoCLI
. Then it makes more sense to see for instance whereself.subparsers
is defined and where it is actually usedthere is no real need to check for the type of the
cli
parameter. If it has aregister_function
and behaves like an AutoCLI object, that's enough (see Duck Typing).
In your case, we can remove a level of function call because register_function
could take the function as a parameter.
Ultimately, I am not convinced that it makes sense to have a register_function_with_cli
at all.
You'd get:
#!/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
import itertools
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
# NOTE: Convert hyphens/dashes to underscores
arg_dict = { k.replace('-', '_'): v for k, v in vars(args).iteritems() }
# Extract the subcommand name (convert hyphens/dashes to underscores)
subcommand_name = args.subcommand_name.replace('-', '_')
# Get the corresponding function object
func = self.commands[subcommand_name]
# Get the argument specification object of the function
argspec = inspect.getargspec(func)
# Extract the arguments for the subcommand
# NOTE: Superfluous arguments are ignored!
relevant_args = { k: v for k, v in arg_dict.iteritems() if k in argspec.args }
# Log some output
self.logger.debug("Executing function: %s" % func)
# Execute the command
return_value = func(**relevant_args)
# If desired, print the canonical representation of the return value
if args.return_output:
print(return_value.__repr__())
def register_function(self, func):
"""Register a function with the registrar."""
argspec = inspect.getargspec(func)
func_name = func.__name__
command_name = func_name.replace('_', '-')
# Add a subparser corresponding to the given function
subparser = self.subparsers.add_parser(
command_name,
help=func.__doc__.split("\n")[0] if func.__doc__ else "",
description="Function: %s" % func_name,
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
for param, default in itertools.izip_longest(
reversed(argspec.args if argspec.args else list()),
reversed(argspec.defaults if argspec.defaults else []),
fillvalue=None):
param = param.replace('_', '-')
if default is None:
subparser.add_argument(param)
else:
# NOTE: ArgumentDefaultsHelpFormatter requires non-empty
# help string to display default value.
subparser.add_argument(param, nargs='?', default=default, help=' ')
# Register the function with the CLI
self.commands[func_name] = func
# Return the original function untouched
return func
def register_function_with_cli(cli):
"""A decorator to register functions with the command-line interface controller.
"""
return cli.register_function
and in the code using it:
# 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
Replacing -
/ _
Conversions between -
and _
happen everywhere in multiple directions. You could get rid of one by using the name with -
in self.commands
keys:
self.commands[command_name] = func
....
func = self.commands[args.subcommand_name]
Also, you could put more than just the function in the dict. We could imagine storing the params as well:
params = [p.replace('_', '-') for p in argspec.args] if argspec.args else []
...
self.commands[command_name] = func, params
func, params = self.commands[args.subcommand_name]
# Extract the arguments for the subcommand
# NOTE: Superfluous arguments are ignored!
relevant_args = {
k.replace('-', '_'): v
for k, v in vars(args).iteritems()
if k in params
}
-
\$\begingroup\$ Thank you for the great response - upvoted! This is my first post to CodeReview. The criteria for accepting a solution here seems a little less clear to me then on other StackExchange sites. I'd like to keep the question open to see if anyone else has further suggestions. I've modified my code to include most of the suggestions you've made. Should I update the code in my post to reflect that or should I leave it as-is? \$\endgroup\$igal– igal2018年03月30日 11:51:02 +00:00Commented Mar 30, 2018 at 11:51
-
1\$\begingroup\$ I'm glad you like my answer. Leaving the question open for a bit longer is a good idea. Ultimately, you should not update the question in your post but if you want to, you can repost a new question based on it - see codereview.meta.stackexchange.com/questions/1763/… . \$\endgroup\$SylvainD– SylvainD2018年03月30日 12:39:58 +00:00Commented Mar 30, 2018 at 12:39
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\$