¿Cómo analizar múltiples subcomandos anidados usando Python argparse?

81

Estoy implementando un programa de línea de comandos que tiene una interfaz como esta:

cmd [GLOBAL_OPTIONS] {command [COMMAND_OPTS]} [{command [COMMAND_OPTS]} ...]

He revisado la documentación de argparse . Puedo implementar GLOBAL_OPTIONScomo argumento opcional usando add_argumentin argparse. Y el {command [COMMAND_OPTS]}uso de subcomandos .

Según la documentación, parece que solo puedo tener un subcomando. Pero como puede ver, tengo que implementar uno o más subcomandos. ¿Cuál es la mejor manera de analizar el uso de argumentos de línea de comando argparse?

Vikas
fuente
1
No creo que esto sea para lo que están destinados los subcomandos. De la documentación se establece que esto es, en esencia, para controlar subprogramas distintos separados . ¿Ha mirado grupos de discusión ?
Chris
distutils ./setup.pytambién tiene este estilo de interfaz CLI, sería interesante ver su código fuente.
Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功

Respuestas:

26

Se me ocurrió la misma pregunta, y parece que tengo una mejor respuesta.

La solución es que no simplemente anidaremos subparser con otro subparser, sino que podemos agregar subparser siguiendo con un parser siguiendo a otro subparser.

El código te dice cómo:

parent_parser = argparse.ArgumentParser(add_help=False)                                                                                                  
parent_parser.add_argument('--user', '-u',                                                                                                               
                    default=getpass.getuser(),                                                                                                           
                    help='username')                                                                                                                     
parent_parser.add_argument('--debug', default=False, required=False,                                                                                     
                           action='store_true', dest="debug", help='debug flag')                                                                         
main_parser = argparse.ArgumentParser()                                                                                                                  
service_subparsers = main_parser.add_subparsers(title="service",                                                                                         
                    dest="service_command")                                                                                                              
service_parser = service_subparsers.add_parser("first", help="first",                                                                                    
                    parents=[parent_parser])                                                                                                             
action_subparser = service_parser.add_subparsers(title="action",                                                                                         
                    dest="action_command")                                                                                                               
action_parser = action_subparser.add_parser("second", help="second",                                                                                     
                    parents=[parent_parser])                                                                                                             

args = main_parser.parse_args()   
Xiongjun Liang
fuente
Sí, argparsepermite subanálisis anidados. Pero solo los he visto usados ​​en otro lugar: en un caso de prueba para un problema de Python, bugs.python.org/issue14365
hpaulj
9
Esto supone que los comandos tienen una estructura anidada. Pero la pregunta es pedir comandos "paralelos"
augurar
25

@mgilson tiene una buena respuesta a esta pregunta. Pero el problema de dividir sys.argv es que pierdo todo el mensaje de ayuda que Argparse genera para el usuario. Así que terminé haciendo esto:

import argparse

## This function takes the 'extra' attribute from global namespace and re-parses it to create separate namespaces for all other chained commands.
def parse_extra (parser, namespace):
  namespaces = []
  extra = namespace.extra
  while extra:
    n = parser.parse_args(extra)
    extra = n.extra
    namespaces.append(n)

  return namespaces

argparser=argparse.ArgumentParser()
subparsers = argparser.add_subparsers(help='sub-command help', dest='subparser_name')

parser_a = subparsers.add_parser('command_a', help = "command_a help")
## Setup options for parser_a

## Add nargs="*" for zero or more other commands
argparser.add_argument('extra', nargs = "*", help = 'Other commands')

## Do similar stuff for other sub-parsers

Ahora, después del primer análisis, todos los comandos encadenados se almacenan en formato extra. Lo analizo mientras no está vacío para obtener todos los comandos encadenados y crear espacios de nombres separados para ellos. Y obtengo una cadena de uso más agradable que genera argparse.

Vikas
fuente
2
@Flavius, después de recibir namespaceel analizador sintáctico llamando namespace = argparser.parse_args(), llamo parse_extracon parsery namespace. extra_namespaces = parse_extra( argparser, namespace )
Vikas
Creo que entiendo la lógica, pero ¿qué hay parseren el código que tienes? Solo veo que se usa para agregar el extraargumento. Luego lo mencionaste nuevamente en el comentario anterior. ¿Se supone que debe ser argparser?
jmlopez
@jmlopez, sí, debería serlo argparser. Lo editará.
Vikas
1
Tenga en cuenta que esta solución falla para argumentos opcionales específicos de subcomando. Consulte mi solución a continuación ( stackoverflow.com/a/49977713/428542 ) para obtener una solución alternativa.
MacFreek
1
A continuación, se muestra un ejemplo de cómo esto falla. Agregue las siguientes 3 líneas parser_b = subparsers.add_parser('command_b', help='command_b help'):; parser_b.add_argument('--baz', choices='XYZ', help='baz help'); options = argparser.parse_args(['--foo', 'command_a', 'command_b', '--baz', 'Z']); Esto falla con un error PROG: error: unrecognized arguments: --baz Z. La razón es que durante el análisis de command_a, los argumentos opcionales de command_bya se han analizado (y son desconocidos para el sub analizador de command_a).
MacFreek
14

parse_known_argsdevuelve un espacio de nombres y una lista de cadenas desconocidas. Esto es similar a la extrarespuesta marcada.

import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--foo')
sub = parser.add_subparsers()
for i in range(1,4):
    sp = sub.add_parser('cmd%i'%i)
    sp.add_argument('--foo%i'%i) # optionals have to be distinct

rest = '--foo 0 cmd2 --foo2 2 cmd3 --foo3 3 cmd1 --foo1 1'.split() # or sys.argv
args = argparse.Namespace()
while rest:
    args,rest =  parser.parse_known_args(rest,namespace=args)
    print args, rest

produce:

Namespace(foo='0', foo2='2') ['cmd3', '--foo3', '3', 'cmd1', '--foo1', '1']
Namespace(foo='0', foo2='2', foo3='3') ['cmd1', '--foo1', '1']
Namespace(foo='0', foo1='1', foo2='2', foo3='3') []

Un ciclo alternativo le daría a cada sub analizador su propio espacio de nombres. Esto permite la superposición de nombres posicionales.

argslist = []
while rest:
    args,rest =  parser.parse_known_args(rest)
    argslist.append(args)
hpaulj
fuente
Funciona muy bien. Sin embargo, hay una falla: si hay una opción mal escrita en algún lugar (por ejemplo rest = '--foo 0 cmd2 --foo2 2 --bar cmd3 --foo3 3 cmd1 --foo1 1'.split()), entonces argparse terminará en en error: too few argumentslugar de señalar la opción no válida. Esto se debe a que se dejará la opción incorrecta resthasta que no tengamos argumentos de comando.
Adrian W
El comentario # or sys.argvdebería ser # or sys.argv[1:].
Adrian W
5

Siempre puede dividir la línea de comandos usted mismo (dividir sys.argven los nombres de sus comandos), y luego solo pasar la parte correspondiente al comando en particular a parse_args- Incluso puede usar el mismoNamespace usando la palabra clave del espacio de nombres si lo desea.

Agrupar la línea de comandos es fácil con itertools.groupby:

import sys
import itertools
import argparse    

mycommands=['cmd1','cmd2','cmd3']

def groupargs(arg,currentarg=[None]):
    if(arg in mycommands):currentarg[0]=arg
    return currentarg[0]

commandlines=[list(args) for cmd,args in intertools.groupby(sys.argv,groupargs)]

#setup parser here...
parser=argparse.ArgumentParser()
#...

namespace=argparse.Namespace()
for cmdline in commandlines:
    parser.parse_args(cmdline,namespace=namespace)

#Now do something with namespace...

no probado

mgilson
fuente
1
Gracias mgilson. Esta es una buena solución a mi pregunta, pero terminé haciéndolo un poco diferente. Agregué otra respuesta .
Vikas
1
Buen uso de itertools.groupby()! Así es como hice lo mismo antes de darme cuenta groupby().
kzyapkov
5

Mejorando la respuesta de @mgilson, escribí un pequeño método de análisis que divide argv en partes y coloca valores de argumentos de comandos en la jerarquía de espacios de nombres:

import sys
import argparse


def parse_args(parser, commands):
    # Divide argv by commands
    split_argv = [[]]
    for c in sys.argv[1:]:
        if c in commands.choices:
            split_argv.append([c])
        else:
            split_argv[-1].append(c)
    # Initialize namespace
    args = argparse.Namespace()
    for c in commands.choices:
        setattr(args, c, None)
    # Parse each command
    parser.parse_args(split_argv[0], namespace=args)  # Without command
    for argv in split_argv[1:]:  # Commands
        n = argparse.Namespace()
        setattr(args, argv[0], n)
        parser.parse_args(argv, namespace=n)
    return args


parser = argparse.ArgumentParser()
commands = parser.add_subparsers(title='sub-commands')

cmd1_parser = commands.add_parser('cmd1')
cmd1_parser.add_argument('--foo')

cmd2_parser = commands.add_parser('cmd2')
cmd2_parser.add_argument('--foo')

cmd2_parser = commands.add_parser('cmd3')
cmd2_parser.add_argument('--foo')


args = parse_args(parser, commands)
print(args)

Se comporta correctamente, proporcionando una buena ayuda argparse:

Para ./test.py --help:

usage: test.py [-h] {cmd1,cmd2,cmd3} ...

optional arguments:
  -h, --help        show this help message and exit

sub-commands:
  {cmd1,cmd2,cmd3}

Para ./test.py cmd1 --help:

usage: test.py cmd1 [-h] [--foo FOO]

optional arguments:
  -h, --help  show this help message and exit
  --foo FOO

Y crea una jerarquía de espacios de nombres que contienen los valores de los argumentos:

./test.py cmd1 --foo 3 cmd3 --foo 4
Namespace(cmd1=Namespace(foo='3'), cmd2=None, cmd3=Namespace(foo='4'))
Andrzej Pronobis
fuente
Al revisar su código anterior, encontré un problema. En la línea 18, se refiere a lo split_argv[0]que en realidad está vacío split_argv, porque agrega [c]a split_argv(inicialmente establecido en [[]]). Si cambia la línea 7 a split_argv = [], todo funciona como se esperaba.
HEADLESS_0NE
2
Hice algunas correcciones más (nuevamente) en el código que compartiste (solucionando algunos problemas con los que me estaba encontrando) y terminé con esto: gist.github.com/anonymous/f4be805fc3ff9e132eb1e1aa0b4f7d4b
HEADLESS_0NE
Esta respuesta es bastante decente, puede determinar cuál subparserse usó agregando dest al add_subparsersmétodo stackoverflow.com/questions/8250010/…
wizebin
5

La solución proporcionada por @Vikas falla para argumentos opcionales específicos de subcomando, pero el enfoque es válido. Aquí hay una versión mejorada:

import argparse

# create the top-level parser
parser = argparse.ArgumentParser(prog='PROG')
parser.add_argument('--foo', action='store_true', help='foo help')
subparsers = parser.add_subparsers(help='sub-command help', dest='subparser_name')

# create the parser for the "command_a" command
parser_a = subparsers.add_parser('command_a', help='command_a help')
parser_a.add_argument('bar', type=int, help='bar help')

# create the parser for the "command_b" command
parser_b = subparsers.add_parser('command_b', help='command_b help')
parser_b.add_argument('--baz', choices='XYZ', help='baz help')

# parse some argument lists
argv = ['--foo', 'command_a', '12', 'command_b', '--baz', 'Z']
while argv:
    print(argv)
    options, argv = parser.parse_known_args(argv)
    print(options)
    if not options.subparser_name:
        break

Esto usa en parse_known_argslugar de parse_args.parse_argsaborta tan pronto como se encuentra un argumento desconocido para el sub analizador actual,parse_known_args devuelve como un segundo valor en la tupla devuelta. En este enfoque, los argumentos restantes se alimentan nuevamente al analizador. Entonces, para cada comando, se crea un nuevo espacio de nombres.

Tenga en cuenta que en este ejemplo básico, todas las opciones globales se agregan solo al primer espacio de nombres de opciones, no a los espacios de nombre posteriores.

Este enfoque funciona bien para la mayoría de las situaciones, pero tiene tres limitaciones importantes:

  • No es posible usar el mismo argumento opcional para diferentes subcomandos, como myprog.py command_a --foo=bar command_b --foo=bar .
  • No es posible utilizar argumentos posicionales de longitud variable con subcomandos ( nargs='?'o nargs='+'o nargs='*').
  • Se analiza cualquier argumento conocido, sin "romper" en el nuevo comando. Por ejemplo, PROG --foo command_b command_a --baz Z 12con el código anterior, --baz Zserá consumido por command_b, no por command_a.

Estas limitaciones son una limitación directa de argparse. Aquí hay un ejemplo simple que muestra las limitaciones de argparse, incluso cuando se usa un solo subcomando:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument('spam', nargs='?')
subparsers = parser.add_subparsers(help='sub-command help', dest='subparser_name')

# create the parser for the "command_a" command
parser_a = subparsers.add_parser('command_a', help='command_a help')
parser_a.add_argument('bar', type=int, help='bar help')

# create the parser for the "command_b" command
parser_b = subparsers.add_parser('command_b', help='command_b help')

options = parser.parse_args('command_a 42'.split())
print(options)

Esto aumentará el error: argument subparser_name: invalid choice: '42' (choose from 'command_a', 'command_b').

La causa es que el método interno argparse.ArgParser._parse_known_args()es demasiado codicioso y asume que ese command_aes el valor del spamargumento opcional . En particular, al 'dividir' los argumentos opcionales y posicionales, _parse_known_args()no se fija en los nombres de los argumentos (como command_ao command_b), sino simplemente en el lugar donde aparecen en la lista de argumentos. También asume que cualquier subcomando consumirá todos los argumentos restantes. Esta limitación argparsetambién impide una implementación adecuada de subanálisis de múltiples comandos. Desafortunadamente, esto significa que una implementación adecuada requiere una reescritura completa del argparse.ArgParser._parse_known_args()método, que son más de 200 líneas de código.

Dadas estas limitaciones, puede ser una opción simplemente volver a un único argumento de opción múltiple en lugar de subcomandos:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument('--bar', type=int, help='bar help')
parser.add_argument('commands', nargs='*', metavar='COMMAND',
                 choices=['command_a', 'command_b'])

options = parser.parse_args('--bar 2 command_a command_b'.split())
print(options)
#options = parser.parse_args(['--help'])

Incluso es posible enumerar los diferentes comandos en la información de uso, consulte mi respuesta https://stackoverflow.com/a/49999185/428542

MacFreek
fuente
4

Podrías probar arghandler . Esta es una extensión de argparse con soporte explícito para subcomandos.

Derek Ruths
fuente
3
arghandler proporciona una buena forma de declarar subcomandos. Sin embargo, no veo cómo esto ayuda a resolver la pregunta de OP: analizar múltiples subcomandos. El primer subcomando analizado consumirá todos los argumentos restantes, por lo que nunca se analizarán más comandos. Por favor, dé una pista sobre cómo resolver esto con arghandler. Gracias.
Adrian W
1

Otro paquete que soporta analizadores paralelos es "declarative_parser".

import argparse
from declarative_parser import Parser, Argument

supported_formats = ['png', 'jpeg', 'gif']

class InputParser(Parser):
    path = Argument(type=argparse.FileType('rb'), optional=False)
    format = Argument(default='png', choices=supported_formats)

class OutputParser(Parser):
    format = Argument(default='jpeg', choices=supported_formats)

class ImageConverter(Parser):
    description = 'This app converts images'

    verbose = Argument(action='store_true')
    input = InputParser()
    output = OutputParser()

parser = ImageConverter()

commands = '--verbose input image.jpeg --format jpeg output --format gif'.split()

namespace = parser.parse_args(commands)

y el espacio de nombres se convierte en:

Namespace(
    input=Namespace(format='jpeg', path=<_io.BufferedReader name='image.jpeg'>),
    output=Namespace(format='gif'),
    verbose=True
)

Descargo de responsabilidad: soy el autor. Requiere Python 3.6. Para instalar use:

pip3 install declarative_parser

Aquí está la documentación y aquí está el repositorio en GitHub .

Krassowski
fuente
1

Se construyó un completo ejemplo de Python 2/3 con subparsers , parse_known_argsy parse_args( que se ejecuta en Ideone ):

from __future__ import print_function

from argparse import ArgumentParser
from random import randint


def main():
    parser = get_parser()

    input_sum_cmd = ['sum_cmd', '--sum']
    input_min_cmd = ['min_cmd', '--min']

    args, rest = parser.parse_known_args(
        # `sum`
        input_sum_cmd +
        ['-a', str(randint(21, 30)),
         '-b', str(randint(51, 80))] +
        # `min`
        input_min_cmd +
        ['-y', str(float(randint(64, 79))),
         '-z', str(float(randint(91, 120)) + .5)]
    )

    print('args:\t ', args,
          '\nrest:\t ', rest, '\n', sep='')

    sum_cmd_result = args.sm((args.a, args.b))
    print(
        'a:\t\t {:02d}\n'.format(args.a),
        'b:\t\t {:02d}\n'.format(args.b),
        'sum_cmd: {:02d}\n'.format(sum_cmd_result), sep='')

    assert rest[0] == 'min_cmd'
    args = parser.parse_args(rest)
    min_cmd_result = args.mn((args.y, args.z))
    print(
        'y:\t\t {:05.2f}\n'.format(args.y),
        'z:\t\t {:05.2f}\n'.format(args.z),
        'min_cmd: {:05.2f}'.format(min_cmd_result), sep='')

def get_parser():
    # create the top-level parser
    parser = ArgumentParser(prog='PROG')
    subparsers = parser.add_subparsers(help='sub-command help')

    # create the parser for the "sum" command
    parser_a = subparsers.add_parser('sum_cmd', help='sum some integers')
    parser_a.add_argument('-a', type=int,
                          help='an integer for the accumulator')
    parser_a.add_argument('-b', type=int,
                          help='an integer for the accumulator')
    parser_a.add_argument('--sum', dest='sm', action='store_const',
                          const=sum, default=max,
                          help='sum the integers (default: find the max)')

    # create the parser for the "min" command
    parser_b = subparsers.add_parser('min_cmd', help='min some integers')
    parser_b.add_argument('-y', type=float,
                          help='an float for the accumulator')
    parser_b.add_argument('-z', type=float,
                          help='an float for the accumulator')
    parser_b.add_argument('--min', dest='mn', action='store_const',
                          const=min, default=0,
                          help='smallest integer (default: 0)')
    return parser


if __name__ == '__main__':
    main()
A
fuente
0

Tenía más o menos los mismos requisitos: poder establecer argumentos globales y poder encadenar comandos y ejecutarlos en el orden de la línea de comandos .

Terminé con el siguiente código. Usé algunas partes del código de este y otros hilos.

# argtest.py
import sys
import argparse

def init_args():

    def parse_args_into_namespaces(parser, commands):
        '''
        Split all command arguments (without prefix, like --) in
        own namespaces. Each command accepts extra options for
        configuration.
        Example: `add 2 mul 5 --repeat 3` could be used to a sequencial
                 addition of 2, then multiply with 5 repeated 3 times.
        '''
        class OrderNamespace(argparse.Namespace):
            '''
            Add `command_order` attribute - a list of command
            in order on the command line. This allows sequencial
            processing of arguments.
            '''
            globals = None
            def __init__(self, **kwargs):
                self.command_order = []
                super(OrderNamespace, self).__init__(**kwargs)

            def __setattr__(self, attr, value):
                attr = attr.replace('-', '_')
                if value and attr not in self.command_order:
                    self.command_order.append(attr)
                super(OrderNamespace, self).__setattr__(attr, value)

        # Divide argv by commands
        split_argv = [[]]
        for c in sys.argv[1:]:
            if c in commands.choices:
                split_argv.append([c])
            else:
                split_argv[-1].append(c)

        # Globals arguments without commands
        args = OrderNamespace()
        cmd, args_raw = 'globals', split_argv.pop(0)
        args_parsed = parser.parse_args(args_raw, namespace=OrderNamespace())
        setattr(args, cmd, args_parsed)

        # Split all commands to separate namespace
        pos = 0
        while len(split_argv):
            pos += 1
            cmd, *args_raw = split_argv.pop(0)
            assert cmd[0].isalpha(), 'Command must start with a letter.'
            args_parsed = commands.choices[cmd].parse_args(args_raw, namespace=OrderNamespace())
            setattr(args, f'{cmd}~{pos}', args_parsed)

        return args


    #
    # Supported commands and options
    #
    parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('--print', action='store_true')

    commands = parser.add_subparsers(title='Operation chain')

    cmd1_parser = commands.add_parser('add', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
    cmd1_parser.add_argument('add', help='Add this number.', type=float)
    cmd1_parser.add_argument('-r', '--repeat', help='Repeat this operation N times.',
                                               default=1, type=int)

    cmd2_parser = commands.add_parser('mult', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
    cmd2_parser.add_argument('mult', help='Multiply with this number.', type=float)
    cmd2_parser.add_argument('-r', '--repeat', help='Repeat this operation N times.',
                                               default=1, type=int)

    args = parse_args_into_namespaces(parser, commands)
    return args


#
# DEMO
#

args = init_args()

# print('Parsed arguments:')
# for cmd in args.command_order:
#     namespace = getattr(args, cmd)
#     for option_name in namespace.command_order:
#         option_value = getattr(namespace, option_name)
#         print((cmd, option_name, option_value))

print('Execution:')
result = 0
for cmd in args.command_order:
    namespace = getattr(args, cmd)
    cmd_name, cmd_position = cmd.split('~') if cmd.find('~') > -1 else (cmd, 0)
    if cmd_name == 'globals':
        pass
    elif cmd_name == 'add':
        for r in range(namespace.repeat):
            if args.globals.print:
                print(f'+ {namespace.add}')
            result = result + namespace.add
    elif cmd_name == 'mult':
        for r in range(namespace.repeat):
            if args.globals.print:
                print(f'* {namespace.mult}')
            result = result * namespace.mult
    else:
        raise NotImplementedError(f'Namespace `{cmd}` is not implemented.')
print(10*'-')
print(result)

A continuación un ejemplo:

$ python argstest.py --print add 1 -r 2 mult 5 add 3 mult -r 5 5

Execution:
+ 1.0
+ 1.0
* 5.0
+ 3.0
* 5.0
* 5.0
* 5.0
* 5.0
* 5.0
----------
40625.0
ddofborg
fuente
-4

puedes usar el paquete optparse

import optparse
parser = optparse.OptionParser()
parser.add_option("-f", dest="filename", help="corpus filename")
parser.add_option("--alpha", dest="alpha", type="float", help="parameter alpha", default=0.5)
(options, args) = parser.parse_args()
fname = options.filename
alpha = options.alpha
xueliang liu
fuente
1
Esto realmente no responde a la pregunta. Además, optparse está desaprobado (de los documentos de python "El módulo optparse está desaprobado y no se desarrollará más; el desarrollo continuará con el módulo argparse").
Chris
Perdón por el voto negativo, pero esto no responde a la pregunta que hice.
Vikas