Python. Подкоманды и argparse

Поговорим об улучшении использования argparse и подкоманд в повседневной жизни.

В моей практике почти в каждом проекте есть интерфейс для командной строки, это может быть manage.py в веб проекте, просто скрипт бекапа или даже приложение GTK. В python 2.7 и 3.2 появился очень мощный модуль argparse для обработки параметров командной строки, и в нем есть “из коробки” поддержка подкоманд и это очень круто. Но есть в этом модуле маленький недостаток - интерфейс его использования немного избыточен.

Для начала нужно глянуть что уж такого плохого в интерфейсе, рассмотрим простой пример:

#!/usr/bin/env python
import argparse


def run_test(module, settings=None):
    pass


def run_server(host, port, no_reload=False, settings=None):
    pass


def parse_args(args=None):
    parser = argparse.ArgumentParser(prog='app')
    cmds = parser.add_subparsers(help='commands')

    cmd_run = cmds.add_parser('run', help='start dev server')
    cmd_run.add_argument('-s', '--settings', help='application settings')
    cmd_run.add_argument(
        '-P', '--port', type=int, default=8000, help='server port'
    )
    cmd_run.add_argument(
        '-H', '--host', default='localhost', help='server host'
    )
    cmd_run.add_argument(
        '--no-reload', action='store_true', help='without reloading'
    )
    cmd_run.set_defaults(func=lambda a: (
        run_server(a.host, a.port, a.no_reload, settings=a.settings)
    ))

    cmd_test = cmds.add_parser('test', aliases=['t', 'te'], help='run tests')
    cmd_test.add_argument('-s', '--settings', help='application settings')
    cmd_test.add_argument('target', default='.', help='python module or file')
    cmd_test.set_defaults(
        func=lambda a: run_test(a.module, settings=a.settings)
    )

    args = parser.parse_args(args)
    if not hasattr(args, 'func'):
        parser.print_usage()
    else:
        args.func(args)


if __name__ == '__main__':
    parse_args()

Вроде не так уж все и плохо, обычный интерфейс. Есть дублирование параметра --settings, но чтоб он был привязан к каждой подкоманде его нельзя вешать на базовый парсер. Также нам пришлось переносить строки для соблюдения PEP 8, при том что не помещались считанные символы. Можно укоротить переменные cmd_run, cmd_test до run, test или даже до r, t, но суть не в этом. Эти переменные, в принципе, не нужны, если добавить цепочки вызовов:

cmds.add_parser('run').add_argument('port').set_defaults(func=run_server)

На чистом argparse цепочек вызовов не получится, хотя может в каких-то случаях использования они и не нужны. В моей практике чаще хочется цепочек.

В самом начале примера объявлена пара функций и есть проекты, которые превращают эти функции в подкоманды, типа: opster, argh, komandr. Последние два основаны на argparse, а opster использует getopt.

В некоторых случаях использование подобных улучшаторов выглядит очень клево, например, использование argh:

#!/usr/bin/env python
import argh


@argh.aliases('t', 'te')
def test(module, settings=None):
    '''run tests'''
    pass


def run(host='localhost', port=8000, no_reload=False, settings=None):
    '''run dev server'''
    pass

if __name__ == '__main__':
    argh.dispatch_commands([run, test])

Вывод главного help такой же как из первого примера:

usage: example-argh.py [-h] {run,test,t,te} ...

positional arguments:
  {run,test,t,te}
    run            run dev server
    test (t, te)   run tests

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

А вот вывод help для определенной подкоманды отличается отсутствием описаний и различием коротких аналогов для параметров:

===pure argparse===
usage: app run [-h] [-s SETTINGS] [-P PORT] [-H HOST] [--no-reload]

optional arguments:
  -h, --help            show this help message and exit
  -s SETTINGS, --settings SETTINGS
                        application settings
  -P PORT, --port PORT  server port
  -H HOST, --host HOST  server host
  --no-reload           without reloading


===argh===
usage: example-argh.py run [-h] [--host HOST] [-p PORT] [-n] [-s SETTINGS]

run dev server

optional arguments:
  -h, --help            show this help message and exit
  --host HOST
  -p PORT, --port PORT
  -n, --no-reload
  -s SETTINGS, --settings SETTINGS

В принципе, можно добиться полного соответствия help, но от этого уже будет страдать предельная лаконичность второго примера. Вообще-то, если названия параметров не требуют пояснений, то использовать argh очень заманчиво, тем более он, в принципе, позволяет добраться до обычного argparse, если где-то сталкиваешься с ограничениями.

Вся прелесть argparse, что с python 2.7 и 3.2 он входит в стандартную библиотеку и реально крут по сравнению с тем же getopt и optparse. А перечисленные выше улучшаторы - это отдельные пакеты и таскать их зависимостями в каждый проект не прикольно, особенно если проект минималистичный или небольшой скрипт с подкомандами. Еще в улучшаторах часто присутствует немного магии, argparse же прямой как двери.

Хорошо бы использовать argparse, но как-то покрасивее, чем в первом примере.

Следующий пример - мой любимый способ:

#!/usr/bin/env python
import argparse


def run_test(module, settings=None):
    pass


def run_server(host, port, no_reload=False, settings=None):
    pass


def parse_args(args=None):
    parser = argparse.ArgumentParser(prog='app')
    cmds = parser.add_subparsers(help='commands')

    def cmd(name, **kw):
        p = cmds.add_parser(name, **kw)
        p.set_defaults(cmd=name)
        p.arg = lambda *a, **kw: p.add_argument(*a, **kw) and p
        p.exe = lambda f: p.set_defaults(exe=f) and p

        # global options
        p.arg('-s', '--settings', help='application settings')
        return p

    cmd('run', help='start dev server')\
        .arg('-P', '--port', type=int, default=8000, help='server port')\
        .arg('-H', '--host', default='localhost', help='server host')\
        .arg('--no-reload', action='store_true', help='without reloading')\
        .exe(lambda a: (
            run_server(a.host, a.port, a.no_reload, settings=a.settings)
        ))

    cmd('test', aliases=['t', 'te'], help='run tests')\
        .arg('target', default='.', nargs='?', help='python module or file')\
        .exe(lambda a: run_test(a.target, settings=a.settings))

    args = parser.parse_args(args)
    if not hasattr(args, 'exe'):
        parser.print_usage()
    else:
        args.exe(args)


if __name__ == '__main__':
    parse_args()
По-моему, выходит очень читабельно:
  • это чистый argparse и если его интерфейс чаще использовать, то даже этот сложный интерфейс запомнится;
  • глобальные опции, типа --settings, мы можем определять в одном месте без дублирования;
  • чем больше подкоманд и параметров, тем оправданнее добавление вложенной функции cmd;
  • обратный слеш в цепочках вызовов мне больше нравится, хотя перенос строк больше люблю делать внутри скобок.

После использования в нескольких местах такого подхода мне все больше нравится отделение интерфейса функции от вызовов командной строки, все таки это немного разные вещи. Хотя раньше мне очень нравилось превращение функций в подкоманды.

Вывод довольно банальный: у python очень крутая стандартная библиотека, argparse - очень мощный инструмент для работы с параметрами командной строки. И даже если есть какие-то библиотеки с красивыми плюшками (argh, opster или docopt) у них скорее всего тоже найдутся свои недостатки, поэтому мой выбор - подточить использование argparse и забыть про дополнительные зависимости.