Привет. Недавно мне потребовалось пересобрать 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 | Иногда скрипт может парсить только часть аргументов командной строки, передавая оставшиеся аргументы другому скрипту или программе. В таких случаях может быть полезен метод |
То есть за один запуск программы невозможно получить значения всех дочерних парсеров без танцев с бубнами. Рассмотрим вышеописанный пример более скрупулезно — и все станет ясно.
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, будет:
создан магическим методом
new,инициализирован при помощи конструктора
init,вызван при помощи
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 парсеров, и нам необходимо интегрировать их в один основной. Да, задача нетривиальная, но, как видите, случается.
Как это сделать:
Создать родительский парсер.
Получить все объекты-парсеры.
Инициализировать подпарсер.
Пройтись циклом по всем (пока еще не дочерним) объектам-парсерам:
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) -> 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
