Анализируйте файлы конфигурации, среду и аргументы командной строки, чтобы получить единый набор параметров.


111

В стандартной библиотеке Python есть модули для синтаксического анализа файла конфигурации ( configparser ), чтения переменных среды ( os.environ ) и синтаксического анализа аргументов командной строки ( argparse ). Я хочу написать программу, которая сделает все это, а также:

  • Имеет каскад значений опций :

    • значения параметров по умолчанию, замененные
    • параметры файла конфигурации, переопределенные
    • переменные среды, переопределенные
    • параметры командной строки.
  • Разрешает одно или несколько расположений файлов конфигурации, указанных в командной строке с помощью, например --config-file foo.conf, и читает их (вместо или в дополнение к обычному файлу конфигурации). Это все еще должно подчиняться вышеуказанному каскаду.

  • Позволяет определять параметры в одном месте для определения поведения синтаксического анализа файлов конфигурации и командной строки.

  • Объединяет проанализированные параметры в единую коллекцию значений параметров для доступа остальной части программы, не заботясь о том, откуда они взялись.

Все, что мне нужно, очевидно, есть в стандартной библиотеке Python, но они не работают вместе гладко.

Как я могу добиться этого с минимальным отклонением от стандартной библиотеки Python?


6
Мне очень нравится этот вопрос. Я уже давно подумываю о том, чтобы сделать что-то подобное ... Я рад, что jterraceназначил здесь награду, чтобы подтолкнуть меня к краю, достаточно, чтобы попробовать свои силы в чем-то подобном :)
mgilson

4
Отличный вопрос! Удивительно, что это не было решено популярным пакетом (или самой стандартной библиотекой) давным-давно.
Zearin

Ответы:


33

Модуль argparse делает это не сумасшедшим, если вы довольны конфигурационным файлом, который выглядит как командная строка. (Я думаю, что это преимущество, потому что пользователям нужно будет изучить только один синтаксис.) Установка fromfile_prefix_chars , например, на @, делает так, что,

my_prog --foo=bar

эквивалентно

my_prog @baz.conf

если @baz.confесть,

--foo
bar

Вы даже можете настроить foo.confавтоматический поиск кода , изменивargv

if os.path.exists('foo.conf'):
    argv = ['@foo.conf'] + argv
args = argparser.parse_args(argv)

Формат этих файлов конфигурации можно изменить, создав подкласс ArgumentParser и добавив метод convert_arg_line_to_args .


Пока кто-нибудь не предложит лучшую альтернативу, это правильный ответ. Я использовал argparse и даже не смотрел на эту функцию. Ницца!
Лемур

но здесь нет ответа на переменные среды?
jterrace 06

1
@jterrace: Этот ТАК-ответ может сработать для вас: stackoverflow.com/a/10551190/400793
Alex Szatmary

27

ОБНОВЛЕНИЕ: наконец-то я решил поставить это на pypi. Установите последнюю версию через:

   pip install configargparser

Полная справка и инструкции здесь .

Исходный пост

Вот кое-что, что я вместе взломал. Не стесняйтесь предлагать улучшения / сообщения об ошибках в комментариях:

import argparse
import ConfigParser
import os

def _identity(x):
    return x

_SENTINEL = object()


class AddConfigFile(argparse.Action):
    def __call__(self,parser,namespace,values,option_string=None):
        # I can never remember if `values` is a list all the time or if it
        # can be a scalar string; this takes care of both.
        if isinstance(values,basestring):
            parser.config_files.append(values)
        else:
            parser.config_files.extend(values)


class ArgumentConfigEnvParser(argparse.ArgumentParser):
    def __init__(self,*args,**kwargs):
        """
        Added 2 new keyword arguments to the ArgumentParser constructor:

           config --> List of filenames to parse for config goodness
           default_section --> name of the default section in the config file
        """
        self.config_files = kwargs.pop('config',[])  #Must be a list
        self.default_section = kwargs.pop('default_section','MAIN')
        self._action_defaults = {}
        argparse.ArgumentParser.__init__(self,*args,**kwargs)


    def add_argument(self,*args,**kwargs):
        """
        Works like `ArgumentParser.add_argument`, except that we've added an action:

           config: add a config file to the parser

        This also adds the ability to specify which section of the config file to pull the 
        data from, via the `section` keyword.  This relies on the (undocumented) fact that
        `ArgumentParser.add_argument` actually returns the `Action` object that it creates.
        We need this to reliably get `dest` (although we could probably write a simple
        function to do this for us).
        """

        if 'action' in kwargs and kwargs['action'] == 'config':
            kwargs['action'] = AddConfigFile
            kwargs['default'] = argparse.SUPPRESS

        # argparse won't know what to do with the section, so 
        # we'll pop it out and add it back in later.
        #
        # We also have to prevent argparse from doing any type conversion,
        # which is done explicitly in parse_known_args.  
        #
        # This way, we can reliably check whether argparse has replaced the default.
        #
        section = kwargs.pop('section', self.default_section)
        type = kwargs.pop('type', _identity)
        default = kwargs.pop('default', _SENTINEL)

        if default is not argparse.SUPPRESS:
            kwargs.update(default=_SENTINEL)
        else:  
            kwargs.update(default=argparse.SUPPRESS)

        action = argparse.ArgumentParser.add_argument(self,*args,**kwargs)
        kwargs.update(section=section, type=type, default=default)
        self._action_defaults[action.dest] = (args,kwargs)
        return action

    def parse_known_args(self,args=None, namespace=None):
        # `parse_args` calls `parse_known_args`, so we should be okay with this...
        ns, argv = argparse.ArgumentParser.parse_known_args(self, args=args, namespace=namespace)
        config_parser = ConfigParser.SafeConfigParser()
        config_files = [os.path.expanduser(os.path.expandvars(x)) for x in self.config_files]
        config_parser.read(config_files)

        for dest,(args,init_dict) in self._action_defaults.items():
            type_converter = init_dict['type']
            default = init_dict['default']
            obj = default

            if getattr(ns,dest,_SENTINEL) is not _SENTINEL: # found on command line
                obj = getattr(ns,dest)
            else: # not found on commandline
                try:  # get from config file
                    obj = config_parser.get(init_dict['section'],dest)
                except (ConfigParser.NoSectionError, ConfigParser.NoOptionError): # Nope, not in config file
                    try: # get from environment
                        obj = os.environ[dest.upper()]
                    except KeyError:
                        pass

            if obj is _SENTINEL:
                setattr(ns,dest,None)
            elif obj is argparse.SUPPRESS:
                pass
            else:
                setattr(ns,dest,type_converter(obj))

        return ns, argv


if __name__ == '__main__':
    fake_config = """
[MAIN]
foo:bar
bar:1
"""
    with open('_config.file','w') as fout:
        fout.write(fake_config)

    parser = ArgumentConfigEnvParser()
    parser.add_argument('--config-file', action='config', help="location of config file")
    parser.add_argument('--foo', type=str, action='store', default="grape", help="don't know what foo does ...")
    parser.add_argument('--bar', type=int, default=7, action='store', help="This is an integer (I hope)")
    parser.add_argument('--baz', type=float, action='store', help="This is an float(I hope)")
    parser.add_argument('--qux', type=int, default='6', action='store', help="this is another int")
    ns = parser.parse_args([])

    parser_defaults = {'foo':"grape",'bar':7,'baz':None,'qux':6}
    config_defaults = {'foo':'bar','bar':1}
    env_defaults = {"baz":3.14159}

    # This should be the defaults we gave the parser
    print ns
    assert ns.__dict__ == parser_defaults

    # This should be the defaults we gave the parser + config defaults
    d = parser_defaults.copy()
    d.update(config_defaults)
    ns = parser.parse_args(['--config-file','_config.file'])
    print ns
    assert ns.__dict__ == d

    os.environ['BAZ'] = "3.14159"

    # This should be the parser defaults + config defaults + env_defaults
    d = parser_defaults.copy()
    d.update(config_defaults)
    d.update(env_defaults)
    ns = parser.parse_args(['--config-file','_config.file'])
    print ns
    assert ns.__dict__ == d

    # This should be the parser defaults + config defaults + env_defaults + commandline
    commandline = {'foo':'3','qux':4} 
    d = parser_defaults.copy()
    d.update(config_defaults)
    d.update(env_defaults)
    d.update(commandline)
    ns = parser.parse_args(['--config-file','_config.file','--foo=3','--qux=4'])
    print ns
    assert ns.__dict__ == d

    os.remove('_config.file')

ДЕЛАТЬ

Эта реализация еще не завершена. Вот неполный список TODO:

Соответствовать документированному поведению

  • (легко) Написать функцию , которая выяснит destиз argsв add_argument, вместо того , чтобы полагаться на Actionобъект
  • (тривиально) Напишите parse_argsфункцию, которая использует parse_known_args. (например, скопируйте parse_argsиз cpythonреализации, чтобы гарантировать ее вызов parse_known_args.)

Менее простые вещи…

Я еще ничего из этого не пробовал. Маловероятно - но все же возможно! - что это может сработать ...


не могли бы вы добавить это в репозиторий на github, чтобы каждый мог улучшить это?
brent.payne 09

1
@ brent.payne - github.com/mgilson/configargparser - Если я собираюсь выпустить это как настоящий код, я решил уделить немного времени сегодня вечером, чтобы немного его почистить. :-)
mgilson

3
FWIW, я , наконец , получил вокруг , чтобы положить это на PyPI - Вы должны быть в состоянии установить его с помощьюpip install configargparser
mgilson

@mgilson - Я обновил ваш пост. Этот пакет заслуживает большего использования!
ErichBSchulz

12

Есть библиотека, которая делает именно это, под названием configglue .

configglue - это библиотека, которая склеивает python optparse.OptionParser и ConfigParser.ConfigParser, чтобы вам не приходилось повторяться, когда вы хотите экспортировать одни и те же параметры в файл конфигурации и интерфейс командной строки.

Он также поддерживает переменные среды.

Есть еще одна библиотека под названием ConfigArgParse, которая

Подставная замена для argparse, которая позволяет также устанавливать параметры через файлы конфигурации и / или переменные среды.

Возможно, вас заинтересует рассказ Лукаша Ланги о настройке PyCon - пусть они настроят !


Я спросил , есть ли планы по поддержке модуля argparse.
Петр Доброгост

10

Хотя я не пробовал это самостоятельно, есть библиотека ConfigArgParse, в которой говорится, что она делает большинство вещей, которые вы хотите:

Подставная замена для argparse, которая позволяет также устанавливать параметры через файлы конфигурации и / или переменные среды.


1
Я попробовал, ConfigArgParse очень удобен и действительно является незаменимой заменой.
maxschlepzig

7

Кажется , что стандартная библиотека не решает это, в результате чего каждый программиста булыжник configparserи argparseи os.environвсе вместе в неуклюжих способах.


5

Насколько мне известно, стандартная библиотека Python этого не обеспечивает. Я решил это для себя, написав код для использования optparseи ConfigParserанализа командной строки и файлов конфигурации, а также предоставив поверх них слой абстракции. Однако вам понадобится это как отдельная зависимость, которая из вашего предыдущего комментария кажется неприятной.

Если вы хотите взглянуть на написанный мною код, он находится на http://liw.fi/cliapp/ . Он интегрирован в мою библиотеку «каркаса приложений командной строки», так как это большая часть того, что нужно делать каркасу.


4

Я недавно пробовал что-то подобное, используя optparse.

Я установил его как подкласс OptonParser с помощью команд --Store и --Check.

Приведенный ниже код в значительной степени должен вас охватить. Вам просто нужно определить свои собственные методы «загрузки» и «хранения», которые принимают / возвращают словари, и вы очень сильно настроены.


class SmartParse(optparse.OptionParser):
    def __init__(self,defaults,*args,**kwargs):
        self.smartDefaults=defaults
        optparse.OptionParser.__init__(self,*args,**kwargs)
        fileGroup = optparse.OptionGroup(self,'handle stored defaults')
        fileGroup.add_option(
            '-S','--Store',
            dest='Action',
            action='store_const',const='Store',
            help='store command line settings'
        )
        fileGroup.add_option(
            '-C','--Check',
            dest='Action',
            action='store_const',const='Check',
            help ='check stored settings'
        )
        self.add_option_group(fileGroup)
    def parse_args(self,*args,**kwargs):
        (options,arguments) = optparse.OptionParser.parse_args(self,*args,**kwargs)
        action = options.__dict__.pop('Action')
        if action == 'Check':
            assert all(
                value is None 
                for (key,value) in options.__dict__.iteritems() 
            )
            print 'defaults:',self.smartDefaults
            print 'config:',self.load()
            sys.exit()
        elif action == 'Store':
            self.store(options.__dict__)
            sys.exit()
        else:
            config=self.load()
            commandline=dict(
                [key,val] 
                for (key,val) in options.__dict__.iteritems() 
                if val is not None
            )
            result = {}
            result.update(self.defaults)
            result.update(config)
            result.update(commandline)
            return result,arguments
    def load(self):
        return {}
    def store(self,optionDict):
        print 'Storing:',optionDict

но все же полезно, если вы хотите оставаться совместимым со старыми версиями Python
MarioVilas

3

Чтобы выполнить все эти требования, я бы порекомендовал написать вашу собственную библиотеку, которая использует как синтаксический анализ [opt | arg], так и configparser для базовой функциональности.

Учитывая первые два и последнее требование, я бы сказал, что вам нужно:

Шаг первый: выполните проход парсера командной строки, который ищет только параметр --config-file.

Шаг второй: проанализируйте файл конфигурации.

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

Третье требование, вероятно, означает, что вы должны разработать свою собственную систему определения опций, чтобы раскрыть все функции optparse и configparser, которые вам нужны, и написать некоторую сантехнику для выполнения преобразований между ними.


Это гораздо дальше от «минимального отклонения от стандартной библиотеки Python», чем я ожидал.
bignose

2

Вот модуль, который я собрал вместе, который считывает аргументы командной строки, параметры среды, файлы ini и значения связки ключей. Это также доступно в чистом виде .

"""
Configuration Parser

Configurable parser that will parse config files, environment variables,
keyring, and command-line arguments.



Example test.ini file:

    [defaults]
    gini=10

    [app]
    xini = 50

Example test.arg file:

    --xfarg=30

Example test.py file:

    import os
    import sys

    import config


    def main(argv):
        '''Test.'''
        options = [
            config.Option("xpos",
                          help="positional argument",
                          nargs='?',
                          default="all",
                          env="APP_XPOS"),
            config.Option("--xarg",
                          help="optional argument",
                          default=1,
                          type=int,
                          env="APP_XARG"),
            config.Option("--xenv",
                          help="environment argument",
                          default=1,
                          type=int,
                          env="APP_XENV"),
            config.Option("--xfarg",
                          help="@file argument",
                          default=1,
                          type=int,
                          env="APP_XFARG"),
            config.Option("--xini",
                          help="ini argument",
                          default=1,
                          type=int,
                          ini_section="app",
                          env="APP_XINI"),
            config.Option("--gini",
                          help="global ini argument",
                          default=1,
                          type=int,
                          env="APP_GINI"),
            config.Option("--karg",
                          help="secret keyring arg",
                          default=-1,
                          type=int),
        ]
        ini_file_paths = [
            '/etc/default/app.ini',
            os.path.join(os.path.dirname(os.path.abspath(__file__)),
                         'test.ini')
        ]

        # default usage
        conf = config.Config(prog='app', options=options,
                             ini_paths=ini_file_paths)
        conf.parse()
        print conf

        # advanced usage
        cli_args = conf.parse_cli(argv=argv)
        env = conf.parse_env()
        secrets = conf.parse_keyring(namespace="app")
        ini = conf.parse_ini(ini_file_paths)
        sources = {}
        if ini:
            for key, value in ini.iteritems():
                conf[key] = value
                sources[key] = "ini-file"
        if secrets:
            for key, value in secrets.iteritems():
                conf[key] = value
                sources[key] = "keyring"
        if env:
            for key, value in env.iteritems():
                conf[key] = value
                sources[key] = "environment"
        if cli_args:
            for key, value in cli_args.iteritems():
                conf[key] = value
                sources[key] = "command-line"
        print '\n'.join(['%s:\t%s' % (k, v) for k, v in sources.items()])


    if __name__ == "__main__":
        if config.keyring:
            config.keyring.set_password("app", "karg", "13")
        main(sys.argv)

Example results:

    $APP_XENV=10 python test.py api --xarg=2 @test.arg
    <Config xpos=api, gini=1, xenv=10, xini=50, karg=13, xarg=2, xfarg=30>
    xpos:   command-line
    xenv:   environment
    xini:   ini-file
    karg:   keyring
    xarg:   command-line
    xfarg:  command-line


"""
import argparse
import ConfigParser
import copy
import os
import sys

try:
    import keyring
except ImportError:
    keyring = None


class Option(object):
    """Holds a configuration option and the names and locations for it.

    Instantiate options using the same arguments as you would for an
    add_arguments call in argparse. However, you have two additional kwargs
    available:

        env: the name of the environment variable to use for this option
        ini_section: the ini file section to look this value up from
    """

    def __init__(self, *args, **kwargs):
        self.args = args or []
        self.kwargs = kwargs or {}

    def add_argument(self, parser, **override_kwargs):
        """Add an option to a an argparse parser."""
        kwargs = {}
        if self.kwargs:
            kwargs = copy.copy(self.kwargs)
            try:
                del kwargs['env']
            except KeyError:
                pass
            try:
                del kwargs['ini_section']
            except KeyError:
                pass
        kwargs.update(override_kwargs)
        parser.add_argument(*self.args, **kwargs)

    @property
    def type(self):
        """The type of the option.

        Should be a callable to parse options.
        """
        return self.kwargs.get("type", str)

    @property
    def name(self):
        """The name of the option as determined from the args."""
        for arg in self.args:
            if arg.startswith("--"):
                return arg[2:].replace("-", "_")
            elif arg.startswith("-"):
                continue
            else:
                return arg.replace("-", "_")

    @property
    def default(self):
        """The default for the option."""
        return self.kwargs.get("default")


class Config(object):
    """Parses configuration sources."""

    def __init__(self, options=None, ini_paths=None, **parser_kwargs):
        """Initialize with list of options.

        :param ini_paths: optional paths to ini files to look up values from
        :param parser_kwargs: kwargs used to init argparse parsers.
        """
        self._parser_kwargs = parser_kwargs or {}
        self._ini_paths = ini_paths or []
        self._options = copy.copy(options) or []
        self._values = {option.name: option.default
                        for option in self._options}
        self._parser = argparse.ArgumentParser(**parser_kwargs)
        self.pass_thru_args = []

    @property
    def prog(self):
        """Program name."""
        return self._parser.prog

    def __getitem__(self, key):
        return self._values[key]

    def __setitem__(self, key, value):
        self._values[key] = value

    def __delitem__(self, key):
        del self._values[key]

    def __contains__(self, key):
        return key in self._values

    def __iter__(self):
        return iter(self._values)

    def __len__(self):
        return len(self._values)

    def get(self, key, *args):
        """
        Return the value for key if it exists otherwise the default.
        """
        return self._values.get(key, *args)

    def __getattr__(self, attr):
        if attr in self._values:
            return self._values[attr]
        else:
            raise AttributeError("'config' object has no attribute '%s'"
                                 % attr)

    def build_parser(self, options, **override_kwargs):
        """."""
        kwargs = copy.copy(self._parser_kwargs)
        kwargs.update(override_kwargs)
        if 'fromfile_prefix_chars' not in kwargs:
            kwargs['fromfile_prefix_chars'] = '@'
        parser = argparse.ArgumentParser(**kwargs)
        if options:
            for option in options:
                option.add_argument(parser)
        return parser

    def parse_cli(self, argv=None):
        """Parse command-line arguments into values."""
        if not argv:
            argv = sys.argv
        options = []
        for option in self._options:
            temp = Option(*option.args, **option.kwargs)
            temp.kwargs['default'] = argparse.SUPPRESS
            options.append(temp)
        parser = self.build_parser(options=options)
        parsed, extras = parser.parse_known_args(argv[1:])
        if extras:
            valid, pass_thru = self.parse_passthru_args(argv[1:])
            parsed, extras = parser.parse_known_args(valid)
            if extras:
                raise AttributeError("Unrecognized arguments: %s" %
                                     ' ,'.join(extras))
            self.pass_thru_args = pass_thru + extras
        return vars(parsed)

    def parse_env(self):
        results = {}
        for option in self._options:
            env_var = option.kwargs.get('env')
            if env_var and env_var in os.environ:
                value = os.environ[env_var]
                results[option.name] = option.type(value)
        return results

    def get_defaults(self):
        """Use argparse to determine and return dict of defaults."""
        parser = self.build_parser(options=self._options)
        parsed, _ = parser.parse_known_args([])
        return vars(parsed)

    def parse_ini(self, paths=None):
        """Parse config files and return configuration options.

        Expects array of files that are in ini format.
        :param paths: list of paths to files to parse (uses ConfigParse logic).
                      If not supplied, uses the ini_paths value supplied on
                      initialization.
        """
        results = {}
        config = ConfigParser.SafeConfigParser()
        config.read(paths or self._ini_paths)
        for option in self._options:
            ini_section = option.kwargs.get('ini_section')
            if ini_section:
                try:
                    value = config.get(ini_section, option.name)
                    results[option.name] = option.type(value)
                except ConfigParser.NoSectionError:
                    pass
        return results

    def parse_keyring(self, namespace=None):
        """."""
        results = {}
        if not keyring:
            return results
        if not namespace:
            namespace = self.prog
        for option in self._options:
            secret = keyring.get_password(namespace, option.name)
            if secret:
                results[option.name] = option.type(secret)
        return results

    def parse(self, argv=None):
        """."""
        defaults = self.get_defaults()
        args = self.parse_cli(argv=argv)
        env = self.parse_env()
        secrets = self.parse_keyring()
        ini = self.parse_ini()

        results = defaults
        results.update(ini)
        results.update(secrets)
        results.update(env)
        results.update(args)

        self._values = results
        return self

    @staticmethod
    def parse_passthru_args(argv):
        """Handles arguments to be passed thru to a subprocess using '--'.

        :returns: tuple of two lists; args and pass-thru-args
        """
        if '--' in argv:
            dashdash = argv.index("--")
            if dashdash == 0:
                return argv[1:], []
            elif dashdash > 0:
                return argv[0:dashdash], argv[dashdash + 1:]
        return argv, []

    def __repr__(self):
        return "<Config %s>" % ', '.join([
            '%s=%s' % (k, v) for k, v in self._values.iteritems()])


def comma_separated_strings(value):
    """Handles comma-separated arguments passed in command-line."""
    return map(str, value.split(","))


def comma_separated_pairs(value):
    """Handles comma-separated key/values passed in command-line."""
    pairs = value.split(",")
    results = {}
    for pair in pairs:
        key, pair_value = pair.split('=')
        results[key] = pair_value
    return results


-1

Созданная мною библиотека как раз отвечает большинству ваших потребностей.

  • Он может загружать файл конфигурации несколько раз через заданные пути к файлам или имя модуля.
  • Он загружает конфигурации из переменных среды с заданным префиксом.
  • Он может прикреплять параметры командной строки к некоторым командам щелчка

    (извините, это не argparse, но щелчок лучше и намного более продвинутый. confectможет поддерживать argparse в будущей версии).

  • Самое главное, confectзагружает файлы конфигурации Python, а не JSON / YMAL / TOML / INI. Как и файл профиля IPython или файл настроек DJANGO, файл конфигурации Python является гибким и простым в обслуживании.

Для получения дополнительной информации проверьте файл README.rst в репозитории проекта . Имейте в виду, что он поддерживает только Python3.6 и выше.

Примеры

Присоединение параметров командной строки

import click
from proj_X.core import conf

@click.command()
@conf.click_options
def cli():
    click.echo(f'cache_expire = {conf.api.cache_expire}')

if __name__ == '__main__':
    cli()

Он автоматически создает подробное справочное сообщение со всеми объявленными свойствами и значениями по умолчанию.

$ python -m proj_X.cli --help
Usage: cli.py [OPTIONS]

Options:
  --api-cache_expire INTEGER  [default: 86400]
  --api-cache_prefix TEXT     [default: proj_X_cache]
  --api-url_base_path TEXT    [default: api/v2/]
  --db-db_name TEXT           [default: proj_x]
  --db-username TEXT          [default: proj_x_admin]
  --db-password TEXT          [default: your_password]
  --db-host TEXT              [default: 127.0.0.1]
  --help                      Show this message and exit.

Загрузка переменных среды

Для загрузки переменных среды требуется всего одна строка

conf.load_envvars('proj_X')

> извините, это не argparse, но щелчок лучше и намного более продвинутый […] Несмотря на достоинства сторонней библиотеки, это не является ответом на вопрос.
bignose
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.