Автоформирования CLI для Python-функции


Мотивация

При написании приложения командной строки в Python, я часто оказываюсь в ситуациях, где я хочу раскрыть функциональность некоторых определенный набор функций - независимо от остальной части приложения. Часто это для тестирования или для экспериментов, но иногда и для общего применения, например, иногда есть вспомогательный/вспомогательный/функции, которые являются полезными сами по себе. Вместо того, чтобы вручную писать отдельный CLI для каждой такой функции, я подумал, что было бы неплохо иметь способ, чтобы автоматически создать CLI из данного набора функций в модуль Python.

Особенности

Существует несколько основных особенностей, которые я хотел убедиться, что я включил:

  • Есть код как "Руки прочь", как это возможно, например, не требует от пользователя, чтобы указать, что сверх какой набор функций должен быть включен в CLI.

  • Есть модуль-анализатор уровня и автоматически создать подкоманду (с командной строки парсер) для каждой конкретной функции.

  • Автоматически генерировать справочную информацию, используя самонаблюдение, например, сделать функцию подписи с помощью inspect модуль и функция извлечения описания с док-строки.

  • Автоматическое преобразование между дефисы и подчеркивания, например, разрешить переносы для использования в командной строке имена параметров.

  • Позвольте возвращаемые значения должны быть напечатаны на консоль.

Реализация

Я написал небольшой модуль под названием autocli.py что дает AutoCLI класс, который выступает в качестве контроллера для авто-сгенерированный Кинк. Он отслеживает зарегистрированы функций, с помощью самонаблюдения для создания парсера и справку для каждого из них, и выполняет функции, соответствующие данной команде. В autocli.py модуль также содержит декоратор называется register_function_with_cli который используется для регистрации функции с заданной AutoCLI экземпляр; эта функция обернута AutoCLI.register экземпляр метода. Вот autocli.py модуль:

#!/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)

Пример

Я также написал небольшой пример скрипта (autocli_example.py) для иллюстрации основных использования:

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)

Запустив пример скрипта с -h флаг (т. е. python autocli_example.py -h) отображает следующий модуль-справка сообщение:

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)

Мы также можем отображать сообщения для каждого из двух подкоманд. Вот вывод для 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

И вот вывод для 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

Наконец, вот пример выполнения одной из функций по автогенерируемые командной строки:

python autocli_example.py --return-output return-string-1 "This is my input string"

Он генерирует следующий вывод:

'This is my input string'

Комментарии

Начиная с этой публикации я наткнулся на два проекта, которые, как представляется, имеют аналогичные цели в виду:

Я их здесь для контекста/сравнение.



253
8
задан 29 марта 2018 в 01:03 Источник Поделиться
Комментарии
1 ответ

Ваш код-это интересные, вроде бы работает нормально и хорошо документированы. Однако, есть еще некоторые места для улучшения.

В Python 2

Это кажется странным, чтобы написать некоторый код на Python 2 в 2018 году. Было бы интересно дать причины (если есть причина).

Комментарии ?

С моей точки зрения, там слишком много комментариев, захламляя код вместо того, чтобы помочь читателю. Большинство вещей прокомментировал достаточно очевидны и объясняют "как" (которые уже есть в коде), а не "почему".

Имена переменных

В _ в начале имен локальных переменных не принесет много. Вы могли бы просто избавиться от него.

issubclass -> isinstance

В isinstance встроенные вероятно, что вы заинтересованы в вашем случае.

itertools.izip_longest

Вы получаете различные элементы, чтобы быть в состоянии перебрать параметры без значений по умолчанию, то значения параметров со значениями по умолчанию.
Можно перебирать параметры и значения по умолчанию и заполнить none для параметров без. Потому что вы хотите, чтобы заполнить с самого начала, вы могли бы просто отменить оба списка ввода.

Дублировать логику

Такие выражения, как self.commands[subcommand_name] повторяются в нескольких местах. Это легко избавиться, потому что вы сохранили его в func уже.

На этом этапе код выглядит как:

#!/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)

Код реорганизации и утиной типизацией

Вместо того register_function способ вызова register_function_with_cli функция, мы могли бы иметь функцию вызова метода. Что бы добавить несколько преимуществ:


  • все соответствующие логике подошло бы в классе AutoCLI. Тогда это имеет больше смысла, чтобы увидеть, например, где self.subparsers определяется и где используется

  • нет никакой реальной необходимости, чтобы проверить тип cli параметр. Если он имеет register_function и ведет себя как AutoCLI объекта, этого достаточно (см. утиной типизацией).

В вашем случае, мы можем удалить уровень вызова функции, так register_function может принимать функцию в качестве параметра.

В конечном счете, я не уверен, что имеет смысл иметь register_function_with_cli на всех.

Вы получите:

#!/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

и в коде, используя это:

# 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

Замена - / _

Преобразования между - и _ произойти везде в нескольких направлениях. Вы могли избавиться от одного с помощью имени с - в self.commands ключи:

    self.commands[command_name] = func
....
func = self.commands[args.subcommand_name]

Кроме того, вы можете поставить больше, чем просто функция в dict. Мы можем представить себе, хранить параметры также:

    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
}

4
ответ дан 29 марта 2018 в 08:03 Источник Поделиться