Як проаналізувати кілька вкладених підкоманд за допомогою python argparse?


82

Я реалізую програму командного рядка, яка має такий інтерфейс:

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

Я переглянув документацію argparse . Я можу реалізувати GLOBAL_OPTIONSяк необов'язковий аргумент, використовуючи add_argumentв argparse. І {command [COMMAND_OPTS]}використання підкоманд .

З документації здається, що я можу мати лише одну підкоманду. Але, як ви бачите, мені доводиться реалізовувати одну або кілька підкоманд. Який найкращий спосіб розбору таких аргументів командного рядка з використанням argparse?


2
Я не думаю, що для цього призначені підкоманди. З документації зазначено, що це, по суті, управління окремими окремими підпрограмами . Ви розглядали групи аргументів ?
Кріс,

distutils ./setup.pyтакож має цей стиль інтерфейсу CLI, було б цікаво вивчити їх вихідний код.
Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功

Відповіді:


27

Я придумав те саме питання, і, схоже, у мене є краща відповідь.

Рішення полягає в тому, що ми не просто вкладемо підпарсер з іншим підпарсером, але ми можемо додати субпарсер, наступний за парсером, який слідує за іншим підпарсером.

Код розповість вам, як:

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()   

Так, argparseдозволяє вкладені підпарсери. Але я бачив, як їх використовували лише в іншому місці - у тестовому випадку для випуску Python, bugs.python.org/issue14365
hpaulj

9
Це передбачає, що команди мають вкладену структуру. Але питання полягає у проханні "паралельних" команд
augurar

25

@mgilson має гарну відповідь на це питання. Але проблема з розщепленням самого sys.argv полягає в тому, що я втрачаю все приємне довідкове повідомлення, яке створює Argparse для користувача. Тож я закінчив робити це:

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

Тепер після першого розбору всі ланцюгові команди зберігаються в extra. Я переробляю його, поки він не порожній, щоб отримати всі ланцюгові команди та створити окремі простори імен для них. І я отримую приємніший рядок використання, який генерує argparse.


2
@Flavius, після того, як я зайшов за namespaceдопомогою синтаксичного аналізатора namespace = argparser.parse_args(), зателефоную за parse_extraдопомогою parserта namespace. extra_namespaces = parse_extra( argparser, namespace )
Вікас

Здається, я розумію логіку, але що parserв коді, який у вас є. Я бачу лише його використання для додавання extraаргументу. Потім ви ще раз згадали про це у наведеному коментарі. Це повинно бути argparser?
jmlopez

@jmlopez так, це повинно бути argparser. Буде редагувати.
Вікас

1
Зауважте, що це рішення не вдається для необов’язкових аргументів, специфічних для підкоманди. Дивіться моє рішення нижче ( stackoverflow.com/a/49977713/428542 ) для альтернативного рішення.
MacFreek

1
Ось приклад того, як це не вдається. Додайте наступні 3 рядки 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']); Це не вдається з помилкою PROG: error: unrecognized arguments: --baz Z. Причина полягає в тому, що під час синтаксичного аналізу command_a, необов’язкові аргументи command_bвже проаналізовані (і невідомі для підпарсатора command_a).
MacFreek

14

parse_known_argsповертає простір імен і список невідомих рядків. Це схоже на те, що вказано extraу відповіді.

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

виробляє:

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') []

Альтернативний цикл дасть кожному підпарсеру власний простір імен. Це дозволяє перекривати імена позицій.

argslist = []
while rest:
    args,rest =  parser.parse_known_args(rest)
    argslist.append(args)

Працює гарно. Однак є недолік: якщо десь є неправильно написаний варіант (наприклад rest = '--foo 0 cmd2 --foo2 2 --bar cmd3 --foo3 3 cmd1 --foo1 1'.split()), тоді argparse закінчиться error: too few argumentsзамість того, щоб вказувати на недійсний варіант. Це пов’язано з тим, що поганий варіант залишатиметься, restдоки у нас не залишиться аргументів команди.
Адріан В

Коментар # or sys.argvповинен бути # or sys.argv[1:].
Адріан В

5

Ви завжди можете самостійно розділити командний рядок (розділити sys.argvна імена ваших команд), а потім передати лише ту частину, яка відповідає певній команді parse_args- Ви навіть можете використовувати ту самуNamespace використовуючи ключове слово простору імен, якщо хочете.

Групувати командний рядок легко за допомогою 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...

неперевірений


1
Дякую Мгільсон. Це гарне рішення мого запитання, але в підсумку я зробив це трохи інакше. Я додав ще одну відповідь .
Вікас

1
Приємного використання itertools.groupby()! Це те саме, що я робив те саме до того, як дізнався groupby().
kzyapkov

5

Покращуючи відповідь @mgilson, я написав невеликий метод синтаксичного аналізу, який розбиває argv на частини та поміщає значення аргументів команд в ієрархію просторів імен:

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)

Він поводиться належним чином, надаючи приємну допомогу argparse:

Для ./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}

Для ./test.py cmd1 --help:

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

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

І створює ієрархію просторів імен, що містять значення аргументів:

./test.py cmd1 --foo 3 cmd3 --foo 4
Namespace(cmd1=Namespace(foo='3'), cmd2=None, cmd3=Namespace(foo='4'))

Переглядаючи ваш код вище, я зіткнувся з однією проблемою. У рядку 18, ви посилаєтеся , split_argv[0]який насправді порожній в split_argv, тому що ви додаєте [c]до split_argv(intially встановлений [[]]). Якщо змінити рядок 7 на split_argv = [], все працює належним чином.
HEADLESS_0NE

2
Я зробив ще кілька виправлень коду, яким ви поділилися (виправивши деякі проблеми, з якими я стикався
HEADLESS_0NE

Відповідь на це питання досить пристойний, ви можете визначити , який subparserбув використаний при додаванні Dest до add_subparsersметоду stackoverflow.com/questions/8250010 / ...
wizebin

5

Рішення, яке надає @Vikas, не вдається для необов’язкових аргументів, специфічних для підкоманди, але підхід є дійсним. Ось покращена версія:

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

Це використовує parse_known_argsзамість parse_args. parse_argsпереривається, як тільки зустрічається аргумент, невідомий поточному підпарсеру,parse_known_args повертає їх як друге значення у повернутому кортежі. При такому підході решта аргументів знову подається до синтаксичного аналізатора. Тож для кожної команди створюється новий простір імен.

Зверніть увагу, що в цьому базовому прикладі всі глобальні параметри додаються лише до першого простору імен, а не до наступних просторів імен.

Цей підхід чудово працює у більшості ситуацій, але має три важливі обмеження:

  • Неможливо використовувати один і той же необов'язковий аргумент для різних підкоманд, наприклад myprog.py command_a --foo=bar command_b --foo=bar.
  • Неможливо використовувати позиційні аргументи змінної довжини з підкомандами ( nargs='?'або nargs='+'або nargs='*').
  • Будь-який відомий аргумент аналізується без `` злому '' за новою командою. Наприклад, PROG --foo command_b command_a --baz Z 12з наведеним вище кодом, --baz Zбуде споживатися command_b, а не command_a.

Ці обмеження є прямим обмеженням аргпарсу. Ось простий приклад, який показує обмеження argparse - навіть при використанні однієї підкоманди -:

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)

Це підніме error: argument subparser_name: invalid choice: '42' (choose from 'command_a', 'command_b').

Причиною є те, що внутрішній метод argparse.ArgParser._parse_known_args()занадто жадібний і припускає, що command_aце значення необов’язкового spamаргументу. Зокрема, під час «розбиття» необов’язкових та позиційних аргументів _parse_known_args()не розглядаються назви груп (як command_aабо command_b), а лише те, де вони трапляються у списку аргументів. Він також передбачає, що будь-яка підкоманда буде використовувати всі інші аргументи. Це обмеження argparseтакож заважає належній реалізації багатокомандних підпарсерів. На жаль, це означає, що для належної реалізації потрібен повний перепис argparse.ArgParser._parse_known_args()методу, що складає понад 200 рядків коду.

Враховуючи ці обмеження, можливо, просто повернутися до одного аргументу з множинним вибором замість підкоманд:

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'])

Можна навіть перерахувати різні команди в інформації про використання, див. Мою відповідь https://stackoverflow.com/a/49999185/428542


4

Ви можете спробувати arghandler . Це розширення argparse з явною підтримкою підкоманд.


3
arghandler забезпечує приємний спосіб оголошення підкоманд. Однак я не бачу, як це допомагає вирішити питання OP: аналіз декількох підкоманд. Перша підкоманда, проаналізована, з’їсть усі аргументи, що залишились, тому подальші команди ніколи не будуть проаналізовані. Будь ласка, дайте підказку про те, як це вирішити за допомогою arghandler. Дякую.
Адріан В

1

Іншим пакетом, який підтримує паралельні синтаксичні аналізатори, є "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)

і простір імен стає:

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

Застереження: Я автор. Потрібна Python 3.6. Для встановлення використовуйте:

pip3 install declarative_parser

Ось документація і ось репо на GitHub .


1

Вбудований повний Python 2/3 приклад з subparsers , parse_known_argsі parse_args( працює на 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()

0

У мене були більш-менш однакові вимоги: Вміння встановлювати глобальні аргументи та вміння ланцюгавати команди та виконувати їх у порядку командного рядка .

У підсумку я отримав такий код. Я використовував деякі частини коду з цього та інших потоків.

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

Нижче наведено приклад:

$ 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

-4

Ви можете використовувати пакет 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

1
Це насправді не відповідає на питання. Крім того, optparse застарілий (з python docs "Модуль optparse застарів і не буде розвиватися надалі; розробка продовжиться з модулем argparse").
Кріс,

Вибачте за "проти", але це не стосується питання, яке я задав.
Вікас
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.