Сказание о том, как я argparse препарировал

—

от автора

Привет. Недавно мне потребовалось пересобрать N парсеров в один. В нем должен быть родитель и N детей, а также возможность использовать функции сразу всех подпарсеров.

Спойлер: это было непросто! В статье расскажу о проблемах, с которыми столкнулся, а также объясню, как устроен модуль argparse в Python 3 и что он умеет.

Статья не про то, как написать свой первый парсер аргументов, используя этот модуль. И если вы не знаете Python на достаточно высоком уровне, понять некоторые особенности, описанные тут, будет сложно.

Проблема с использованием подпарсеров

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

import argparse parser = argparse.ArgumentParser(prog='PROG') parser.add_argument('--foo', action='store_true', help='foo help')  subparsers = parser.add_subparsers(help='sub-command help')  parser_a = subparsers.add_parser('a', help='a help') parser_a.add_argument('-a', help='bar help')  parser_b = subparsers.add_parser('b', help='b help') parser_b.add_argument('-b', help='baz help')  parser.parse_known_args(['a', '-a', '12', 'b', '-b', '32', '-d', '11']) > Namespace(a=12) ['b', '-b', '32', '-d', '11'] 

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

Sometimes a script may only parse a few of the command-line arguments, passing the remaining arguments on to another script or program. In these cases, the parse_known_args() method can be useful. It works much like parse_args() except that it does not produce an error when extra arguments are present. Instead, it returns a two item tuple containing the populated namespace and the list of remaining argument strings.

Иногда скрипт может парсить только часть аргументов командной строки, передавая оставшиеся аргументы другому скрипту или программе. В таких случаях может быть полезен метод parse_known_args(). Он работает так же, как parse_args(), но не выдает ошибку при наличии дополнительных аргументов, а возвращает кортеж из двух членов, содержащий заполненное пространство имен и список строк с оставшимися аргументами.

То есть за один запуск программы невозможно получить значения всех дочерних парсеров без танцев с бубнами. Рассмотрим вышеописанный пример более скрупулезно — и все станет ясно.

import argparse parser = argparse.ArgumentParser(prog='PROG') # Создаем "родительский" парсер. parser.add_argument('--foo', action='store_true', help='foo help')  subparsers = parser.add_subparsers(help='sub-command help') # Создаем объект, отвечающий за создание дочерних парсеров.  parser_a = subparsers.add_parser('a', help='a help') # Инициализируем дочерний парсер. parser_a.add_argument('-a', help='bar help')  parser_b = subparsers.add_parser('b', help='b help') # Инициализируем еще один дочерний парсер. parser_b.add_argument('-b', help='baz help')  parser.parse_known_args(['a', '-a', '12', 'b', '-b', '32', '-d', '11']) # Вызываем метод родительского класса, отвечающий за парсинг всех объявленных аргументов. Было бы логично предположить, что на выходе мы получим кортеж, в котором будут содержаться значения подпарсеров. Что произойдет на самом деле - показано ниже ниже. > Namespace(a=12) ['b', '-b', '32', '-d', '11'] # На выходе - кортеж, состоящий из так называемых Namespace, отвечающих за спаршенные аргументы и лист, отвечающий за лишние, неспаршенные аргументы соответственно. 

И что с этим делать?

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

Способ 1

Имея целочисленное количество существующих парсеров, можно N раз вызвать метод parse_known_args, передавая в качестве аргументов массив из лишних аргументов.

Решение выглядит так:
args, not_parsed = parser.parse_known_args(['a', '-a', '12', 'b', '-b', '32', '-d', '11']) — Получаем существующие аргументы и несуществующие.

args, not_parsed = parser.parse_known_args(not_parsed) — Передаем в метод полученные несуществующие элементы.

Да, оно работает. Однозначно. Однако мне кажется, так делать неправильно. Да, круто. Но мне совершенно не нравится, что я не могу получить все аргументы при попытке это сделать. Поэтому я решил раз и навсегда разобраться с этой проблемой и не только с ней, но еще и с алгоритмом работы argparse. В конце концов я прибегнул к способу 2. 

Итак, способ 2

Способ 2 заключается в том, чтоб сделать форк, изменив логику алгоритма argparse. Разбираем мотор на запчасти. Внутри protected-метода _parse_known_args создается цикл, определяющий, что из всего переданного нами является командами, а что — аргументами для каждой полученной команды:

for i, arg_string in enumerate(arg_strings_iter):    # all args after -- are non-options   if arg_string == '--':     arg_string_pattern_parts.append('-')     for arg_string in arg_strings_iter:       arg_string_pattern_parts.append('A')    # otherwise, add the arg to the arg strings   # and note the index if it was an option   else:     option_tuple = self._parse_optional(arg_string)     if option_tuple is None:       pattern = 'A'       else:         option_string_indices[i] = option_tuple         pattern = 'O'         arg_string_pattern_parts.append(pattern) 

Тут гвоздь программы — protected-метод _parse_optional, отвечающий непосредственно за полную проверку, что из переданного — команды, а что — аргументы. Если нашли команду во время парсинга, получаем на выходе O, во всех остальных случаях — A.

Далее происходит много сложных вычислений и запутанных циклов. Все они должны помочь программе определить нужность переданных аргументов, чтобы получить те самые known_args (известные аргументы). Но мы не будем останавливаться на этом цикле, поскольку проблема заключается не в нем. Перейдем к интересному замыканию, которое отвечает за инициализацию и вызов Actions: 

def take_action(action, argument_strings, option_string=None):   seen_actions.add(action)   argument_values = self._get_values(action, argument_strings)    # error if this argument is not allowed with other previously   # seen arguments, assuming that actions that use the default   # value don't really count as "present"   if argument_values is not action.default:     seen_non_default_actions.add(action)     for conflict_action in action_conflicts.get(action, []):       if conflict_action in seen_non_default_actions:         msg = _('not allowed with argument %s')         action_name = _get_action_name(conflict_action)         raise ArgumentError(action, msg % action_name)    # take the action if we didn't receive a SUPPRESS value   # (e.g. from a default)   if argument_values is not SUPPRESS:     action(self, namespace, argument_values, option_string) 

Из этого кода нас больше всего интересует эта строка: action(self, namespace, argument_values, option_string).

Для полного понимания происходящего напомню, что все в Python является объектом. А поскольку все является объектом, то мы можем использовать это в своих целях. Например, чтобы передавать функции в качестве аргументов. Как так происходит? Все просто: функции — это объекты первого класса, то есть их можно передавать в качестве аргументов ровно так же, как и любые другие объекты.

Отличный пример такого объекта — действие action, которое передается в замыкание take_action. Причем это не функция, а полноценный класс, наследуемый от другого класса. А тот, в свою очередь, создан с помощью метакласса. Этот список можно продолжать долго, поверьте… Но вернемся к замыканию. В самом начале значение action будет равно _SubParsersAction. Именно этот объект, указывающий на _SubParsersAction, будет:

  1. создан магическим методом new,

  2. инициализирован при помощи конструктора init,

  3. вызван при помощи call.

Все было бы просто, если бы в Python нельзя было переопределять и задавать поведение объектов при их создании. Я, наверное, тогда и не писал бы на «Хабр». Но раз статья увидела свет, значит, производить подобные махинации можно и даже нужно!

Рассмотрим хороший, на мой взгляд, пример использования магического метода, который позволил мне изменить поведение другого метода, о котором речь шла ранее, — parse_known_args:

    def __call__(self, parser, namespace, values, option_string=None): parser_name = values[0] arg_strings = values[1:]     # set the parser name if requested     if self.dest is not SUPPRESS:         setattr(namespace, self.dest, parser_name)      # select the parser     try:         parser = self._name_parser_map[parser_name]     except KeyError:         args = {'parser_name': parser_name,                 'choices': ', '.join(self._name_parser_map)}         msg = _('unknown parser %(parser_name)r (choices: %(choices)s)') % args         raise ArgumentError(self, msg)      subnamespace, arg_strings = parser.parse_known_args(arg_strings, None)     for key, value in vars(subnamespace).items():         setattr(namespace, key, value)      if arg_strings:         vars(namespace).setdefault(_UNRECOGNIZED_ARGS_ATTR, [])         getattr(namespace, _UNRECOGNIZED_ARGS_ATTR).extend(arg_strings)  

Опять непонятный исходный код библиотеки без комментариев!

Я бы хотел дать вам какое-то время на размышления относительно того, что в этом методе не так. Подумайте и продолжайте читать.*

Теперь опишу происходящее тут.

После вызова класса мы получаем парсер, который создается путем вытаскивания первого значения из массива values и его подстановки в качестве ключа в словарь, содержащий в себе информацию о созданных подпарсерах: self._name_parser_map[parser_name]. На выходе — объект, хранящий в себе action для парсинга первого (нулевого) аргумента. И это, пожалуй, одна из самых важных частей описанного метода, так как при создании нового parser в нем создается _StoreAction, который призван хранить необходимые аргументы и отвечает за последующий парсинг.

Затем происходит повторный вызов parser.parse_known_args(arg_strings, None), но уже для вновь созданного объекта parser. Внутри него определяются необходимые значения из массива values, а остальные заносятся в arg_strings. Они считаются ненужными и больше нигде не используются — в этом и проблема. 

Проще говоря, _SubParsersAction хранит в себе кучу actions, которые мы создавали путем инициализации подпарсеров, но в виде выбора — собственно, само поле так и называется.

По итогу берется первый Action из этой выборки, полностью и целиком парсится согласно всем правилам, которые составил разработчик. Почему все устроено так, что берется только первый Action из выборки? Расскажу об этом в конце.

Хеппи-энд, или Еще немного о способе 2 

В общем, сделал форк и готов поделиться им с вами:

    def __call__(self, parser, namespace, values, option_string=None, arg_strings_pattern:list =None):         o_amount = arg_strings_pattern.count("O")         if not o_amount:             raise ValueError("No Os found")         o_start, o_stop, indexes = arg_strings_pattern.index('O'), len(arg_strings_pattern), []         print(parser)         try:             while arg_strings_pattern.index('O', o_start, o_stop):                 indexes.append(arg_strings_pattern.index('O', o_start, o_stop))                 o_start = arg_strings_pattern.index('O', o_start + 1, o_stop)         except ValueError:             pass          used_indexes = []         known_args = {}         for i, index in enumerate(indexes):             parser_name = values[index - 1]             if not known_args.get(parser_name):                 known_args[parser_name] = []                 known_args[parser_name] += values[index: indexes[i + 1] - 1] if i + 1 < len(indexes) else values[index:]             if index not in used_indexes:                 for s, subindex in enumerate(indexes[1:]):                     subparser_name = values[subindex - 1]                     if parser_name == subparser_name:                         used_indexes.append(index)                         used_indexes.append(subindex)                         subparser_args = values[subindex: indexes[s + 2] - 1] if s + 2 < len(indexes) else values[subindex:]                         known_args[parser_name] += subparser_args          for parser_name, args in known_args.items():             self._create_parser(namespace, parser_name, args)      def _create_parser(self, namespace, parser_name, arg_strings):          if self.dest is not SUPPRESS:             setattr(namespace, self.dest, parser_name)          try:             parser = self._name_parser_map[parser_name]         except KeyError:             args = {'parser_name': parser_name,                     'choices': ', '.join(self._name_parser_map)}             msg = _('unknown parser %(parser_name)r (choices: %(choices)s)') % args             raise ArgumentError(self, msg)          subnamespace, arg_strings = parser.parse_known_args(arg_strings, None)         for key, value in vars(subnamespace).items():             setattr(namespace, key, value)          if arg_strings:             vars(namespace).setdefault(_UNRECOGNIZED_ARGS_ATTR, [])             getattr(namespace, _UNRECOGNIZED_ARGS_ATTR).extend(arg_strings)  

Выше я говорил, что мы в какой-то момент можем получить строку вида AAAAOAAAOAA. Так вот, эта строка меня сильно выручила. Я почти сдался, пока разгадывал, как лучше изменить алгоритм, ничего не сломав. Выходит, что изначально уже есть информация, где именно находится команда, где находятся ее аргументы и так далее.

В этом форке я создаю по объекту-парсеру, выясняя из изначально полученных значений, что именно является показателем вновь созданного аргумента, а что, соответственно, нет. Затем определяю границы и по ним получаю необходимые аргументы, которые в дальнейшем будут переданы в функцию по созданию объекта-парсера:

o_amount = arg_strings_pattern.count("O") if not o_amount: raise ValueError("No Os found") o_start, o_stop, indexes = arg_strings_pattern.index('O'), len(arg_strings_pattern), [] try: while arg_strings_pattern.index('O', o_start, o_stop): indexes.append(arg_strings_pattern.index('O', o_start, o_stop)) o_start = arg_strings_pattern.index('O', o_start + 1, o_stop) except ValueError: pass 

Таким образом, у меня есть индексы всех команд и возможность вычислить количество аргументов — нужно взять медиану между индексами команд, при этом не выходя за границы массива values:

used_indexes = [] known_args = {} for i, index in enumerate(indexes): parser_name = values[index - 1] if not known_args.get(parser_name): known_args[parser_name] = [] known_args[parser_name] += values[index: indexes[i + 1] - 1] if i + 1 < len(indexes) else values[index:] if index not in used_indexes: for s, subindex in enumerate(indexes[1:]): subparser_name = values[subindex - 1] if parser_name == subparser_name: used_indexes.append(index) used_indexes.append(subindex) subparser_args = values[subindex: indexes[s + 2] - 1] if s + 2 < len(indexes) else values[subindex:] known_args[parser_name] += subparser_args  for parser_name, args in known_args.items(): self._create_parser(namespace, parser_name, args) 

Дальше я разделил методы для более красивого вызова. Просто вызвал оставшуюся, старую часть.

Выше я говорил, что пытался переработать алгоритм и ничего не сломать. Что же, алгоритм я переработал и почти ничего не сломал. Так как теперь мы разбираем сразу все аргументы, то на выходе не получаем ничего лишнего, ведь лишнего формально не остается.

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

Подобные проблемы были описаны и ранее. Никто не понимал, почему это не работает нормально и почему значения подпарсеров не парсятся сразу.

Проблема решена — время двигаться дальше, к теме пересборки парсеров.

Проблема пересборки: как это вообще работает

У каждого объекта-парсера есть actions, которые говорят парсеру, что делать с переданными в него данными, например: 

  • _HelpActions, отвечающие за вывод help;

  • _StoreActions, отвечающие за создание функции, позволяющей нам хранить переданные пользователем данные.

Нам интересны _StoreActions. Давайте еще раз рассмотрим базовый пример создания подпарсера:

parser = argparse.ArgumentParser(prog='PROG') parser.add_argument('--foo', action='store_true', help='foo help')  subparsers = parser.add_subparsers(help='sub-command help')  parser_a = subparsers.add_parser('a', help='a help') parser_a.add_argument('-a', help='bar help')  parser_b = subparsers.add_parser('b', help='b help') parser_b.add_argument('-b', help='baz help') 

Из важного надо выделить следующие строки:

parser = argparse.ArgumentParser(prog='PROG') # Создаем родительский парсер subparsers = parser.add_subparsers(help='sub-command help') # Создаем объект, отвечающий за добавление дочерних парсеров parser_a = subparsers.add_parser('a', help='a help') # инициализируем новый дочерний парсер ...

Теперь представим ситуацию: кто-то написал 20 парсеров, и нам необходимо интегрировать их в один основной. Да, задача нетривиальная, но, как видите, случается.

Как это сделать:

  1. Создать родительский парсер.

  2. Получить все объекты-парсеры.

  3. Инициализировать подпарсер.

  4. Пройтись циклом по всем (пока еще не дочерним) объектам-парсерам:
    4.1. Инициализировать новый подпарсер.
    4.2. Взять _actions  из уже созданного парсера, который был передан нам.
    4.3. Интегрировать эти _actions в дочерний парсер.

Сейчас все это хранится у меня на GitHub и выглядит примерно так:

import argparse from PluginHandler import AnotherPlugin   class ParserMetaclass(type):     def __new__(mcs, name, bases, dct):         if name != "ArgumentParser":             if not dct.get("_PluginLink"):                 raise LookupError(f"Link in {mcs} for parent class not found. Terminating")             if not dct.get("__plugin_name__"):                 dct["__plugin_name__"] = name                 print(name)         parser_object = type.__new__(mcs, name, bases, dct)         return parser_object   class ArgumentParser(metaclass=ParserMetaclass):     """     # Every plugin class should have:     # _PluginLink = Plugin, where     # _PluginLink is basic link to object     # __plugin_name__ -> name, that could     # allow you to use your own parser     # from parent one.      # Every class should have     # __parser__name__     # otherwise plugin subparser     # call name will be generated     # from className      # The only problem is in conflict     # The only way (as i think) is to use     # conflict_handler='resolve' handler     # that will by default resolve all     # conflicts in the program.      # If you don't want to auto editing, <- TODO     # just inherit from this class and change     # parser options like this:     # https://docs.python.org/3/library/argparse.html#other-utilities     """      test = "test"      def __init__(self):         self.parent_parser = argparse.ArgumentParser(conflict_handler='resolve')      def __recreate_parser(self, created_parsers: dict):         """         Example parser ._actions:         _HelpAction(option_strings=['-h', '--help'], dest='help', nargs=0, const=None, default='==SUPPRESS==', type=None,         choices=None, help='show this help message and exit', metavar=None)         _StoreAction(option_strings=['-t', '--test'], dest='test', nargs=None, const=None, default=None, type=None,         choices=None, help=None, metavar=None)         _StoreAction(option_strings=['-p', '--plugin'], dest='plugin', nargs=None, const=None, default=None, type=None,         choices=None, help=None, metavar=None)          So this method is created for recreation of parser to insert it into the "parent"         :param created_parser:         :return:         """         subparsers = self.parent_parser.add_subparsers()          for parser, name in created_parsers.items():             if isinstance(parser, argparse.ArgumentParser):                 """                 We could use parser._option_string_actions                 But afterwards there is pretty hard way to resolve                 all argparse objects. So that's is optimal way                  """                 parser_actions = parser._actions                 if parser_actions:                     plugin_subparser = subparsers.add_parser(name=name)                     for action in parser_actions:                         action_kwargs = dict(action._get_kwargs())                         if isinstance(action, argparse._HelpAction):                             """                             Passing this _HelpAction because of every                              _*StoreAction is linked to it's own _HelpAction                             """                             continue                         """                         From lib source:                                      # if no positional args are supplied or only one is supplied and                                 # it doesn't look like an option string, parse a positional                                 # argument                                                  >> return dict(kwargs, dest=dest, option_strings=[])                                                  So option_strings will be empty.                         """                         options = action_kwargs['option_strings']                         action_kwargs.pop('option_strings')                         plugin_subparser.add_argument(*options, **action_kwargs) 

Это решение позволяет динамически создавать N подпарсеров в зависимости от ваших нужд. А вместе с форком это даст возможность использовать плагины и для каждого из них задавать свои аргументы.

Выглядит решение в общей сумме так:

import argfork as argparse  from PluginsArgumentParser import ArgumentParser  pars = ArgumentParser()  test1 = argparse.ArgumentParser() test1.add_argument("-t")  test2 = argparse.ArgumentParser() test2.add_argument("-r")  test3 = argparse.ArgumentParser() test3.add_argument("-b")  res_dict = {     test1: "Plugin1",     test2: "Plugin2",     test3: "Plugin3" }  pars.setup(res_dict) args, unk_args = (pars.parent_parser.parse_known_args(     ["Plugin1", '-t', '1', "Plugin2", '-r', '2', "Plugin3", '-b', '3'] )) 

Сравнение работы парсера до и после переработки

Посмотрим, как с поставленной задачей справляется argparse без исправлений:

import argparse  parser = argparse.ArgumentParser(prog='PROG') subparsers = parser.add_subparsers(help='sub-command help')  # create the parser for the "a" command parser_a = subparsers.add_parser('a', help='a help') parser_a.add_argument('-a', help='bar help')  # create the parser for the "b" command parser_b = subparsers.add_parser('b', help='b help') parser_b.add_argument('-b', help='baz help')  # parse some argument lists args, unk_args = (parser.parse_known_args(['a', '-a', '12', 'b', '-b', '32',])) # args, unk_args = (parser.parse_known_args(unk_args)) print(args, unk_args) 

На выходе: Namespace(a='12', foo=False) ['b', '-b', '32'] — то есть спарсилось только одно значение.

Теперь, используя форк import argfork as argparse: Namespace(a='12', b='32') [], мы получили все инициализированные значения подпарсеров.

Самые наблюдательные наверняка заметили, что из вывода в форке пропало значение foo=False. Это решение создано исключительно для работы с N дочерних парсеров и не работает в случае, когда необходимо получить значения родительского парсера вкупе со значениями дочерних. Можно, конечно, передать в качестве первого аргумента значение родительского, но тогда дочерние рассматриваться не будут.

Простые юнит-тесты, которые демонстрируют работоспособность:

import unittest import argfork as argparse from argfork import Namespace  class argparseTest(unittest.TestCase):   def setUp(self) -&gt; None:       self.parser = argparse.ArgumentParser(prog='PROG')       subparsers = self.parser.add_subparsers(help='sub-command help')        # create the parser for the "a" command       parser_a = subparsers.add_parser('a', help='a help')       parser_a.add_argument('-a', help='bar help')        # create the parser for the "b" command       parser_b = subparsers.add_parser('b', help='b help')       parser_b.add_argument('-b', help='baz help')       parser_b.add_argument('-q', help='baz help')        # create the parser for the "c" command       parser_b = subparsers.add_parser('c', help='b help')       parser_b.add_argument('-c', help='baz help')       parser_b.add_argument('-k', help='baz help')        # create the parser for the "c" command       parser_b = subparsers.add_parser('d', help='b help')       parser_b.add_argument('-d', help='baz help')       parser_b.add_argument('-D', help='baz help')       parser_b.add_argument('-R', help='baz help')    def testSimple(self):       case = ['a', '-a', 'test']       res_obj = Namespace(a='test').__dict__       rest_obj = self.parser.parse_known_args(case)[0].__dict__        res_k, res_v = res_obj.keys(), list(res_obj.values())       test_k, test_v = rest_obj.keys(), list(rest_obj.values())        self.assertEqual(res_v, test_v)       self.assertEqual(res_k, test_k)    def testMany(self):       case = ['d', '-d', '1234', 'd', '-D', '12345', 'd', '-R', '1', 'c', '-c', '123', 'c', '-k', '555', 'b', '-q', 'test']       res_obj = Namespace(d='1234', D='12345', R='1', c='123', k='555', b=None, q='test').__dict__       rest_obj = self.parser.parse_known_args(case)[0].__dict__        res_k, res_v = res_obj.keys(), list(res_obj.values())       test_k, test_v = rest_obj.keys(), list(rest_obj.values())        self.assertEqual(res_v, test_v)       self.assertEqual(res_k, test_k)    def testZero(self):       case = []       res_obj = Namespace().__dict__       rest_obj = self.parser.parse_known_args(case)[0].__dict__        res_k, res_v = res_obj.keys(), list(res_obj.values())       test_k, test_v = rest_obj.keys(), list(rest_obj.values())        self.assertEqual(res_v, test_v)       self.assertEqual(res_k, test_k)  if name == 'main': unittest.main() 

На выходе:Ran 3 tests in 0.007s OK

Получается, что я больше не хочу играть со стандартным argparse.

Связаться со мной:

Телеграм канал: https://t.me/r1v3ns_life
Сорцы: https://github.com/rive-n/Gitlab-exploitation-toolkit
Я: https://t.me/rive_n


ссылка на оригинал статьи https://habr.com/ru/company/bizone/blog/663540/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *