Как стать автором
Обновить
87.86

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

Время на прочтение 15 мин
Количество просмотров 4.2K

Привет. Недавно мне потребовалось пересобрать 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

Теги:
Хабы:
+2
Комментарии 3
Комментарии Комментарии 3

Публикации

Информация

Сайт
bi.zone
Дата регистрации
Численность
501–1 000 человек
Местоположение
Россия