Сказание о том, как я argparse препарировал
- пятница, 29 апреля 2022 г. в 00:42:53
Привет. Недавно мне потребовалось пересобрать 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']
Последняя строка в коде — результат выполнения программы. Проблема в том, что официальная документация гласит следующее:
|
---|
То есть за один запуск программы невозможно получить значения всех дочерних парсеров без танцев с бубнами. Рассмотрим вышеописанный пример более скрупулезно — и все станет ясно.
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, отвечающих за спаршенные аргументы и лист, отвечающий за лишние, неспаршенные аргументы соответственно.
Проблему получения всех аргументов за один запуск программы можно решить несколькими способами. Мы рассмотрим два из них.
Имея целочисленное количество существующих парсеров, можно 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 заключается в том, чтоб сделать форк, изменив логику алгоритма 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
из выборки? Расскажу об этом в конце.
В общем, сделал форк и готов поделиться им с вами:
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) -> 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