8
\$\begingroup\$

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.

\$\endgroup\$
  • 1
    \$\begingroup\$ By the way, you are aware of click, right? It does something that is basically the same with only a slightly different interface (you need to specify each argument explicitly). \$\endgroup\$ – Graipher Mar 29 '18 at 13:20
  • 1
    \$\begingroup\$ @Graipher Thanks for the comment. Yes, I've used 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 think click does (e.g. autogenerating the help text from the docstrings) - does it? I was thinking of using click instead of argparse, but for this post I decided I'd stick with the standard library - keep things simple. \$\endgroup\$ – igal Mar 29 '18 at 14:43
  • \$\begingroup\$ It does autogenerate the usage, but it does not take into account docstrings AFAIK. It knows only what you explicitly tell it (each argument you add takes an optional help keyword argument). \$\endgroup\$ – Graipher Mar 29 '18 at 14:45
  • \$\begingroup\$ @Graipher I'm sorry, I think I'm being unclear. What I mean by "automatic" here is "completely hands-off". I want to attach parameterless decorators to the functions and let everything else take care of itself. The 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\$ – igal Mar 29 '18 at 14:50
  • 2
    \$\begingroup\$ I agree, that is where your module shines :-) \$\endgroup\$ – Graipher Mar 29 '18 at 14:51
4
\$\begingroup\$

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 where self.subparsers is defined and where it is actually used

  • there is no real need to check for the type of the cli parameter. If it has a register_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
    }
\$\endgroup\$
  • \$\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 Mar 30 '18 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\$ – Josay Mar 30 '18 at 12:39

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.