Проаналізуйте конфігураційні файли, середовище та аргументи командного рядка, щоб отримати єдину колекцію параметрів


110

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

  • Має каскад значень опцій :

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

  • Дозволяє визначенням опцій в одному місці для визначення поведінки розбору файлів конфігурації та командного рядка.

  • Уніфікує проаналізовані параметри в єдину колекцію значень опцій для решти програми для доступу, не піклуючись, звідки вони прийшли.

Все, що мені потрібно, мабуть, у стандартній бібліотеці Python, але вони не працюють разом.

Як я можу досягти цього з мінімальним відхиленням від стандартної бібліотеки Python?


6
Мені дуже подобається це питання. Я довго jterrace
роздумував

4
Відмінне запитання! Дивно, що це давно не вирішено популярним пакетом (або стандартною бібліотекою).
Зерін

Відповіді:


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

1
@jterrace: Цей SO відповідь може працювати для вас: stackoverflow.com/a/10551190/400793
Alex Szatmary

27

ОНОВЛЕННЯ: Нарешті я взявся за те, щоб поставити це на піпі. Встановити останню версію за допомогою:

   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

1
@ brent.payne - github.com/mgilson/configargparser - Якщо я випущу це як справжній код, я вирішив сьогодні витратити трохи часу, щоб трохи його почистити. :-)
mgilson

3
FWIW, я нарешті зібрався поставити це на pypi - Ви повинні мати змогу встановити це черезpip install configargparser
mgilson

@mgilson - я оновив вашу публікацію. Цей пакет заслуговує на більше використання!
ErichBSchulz

12

Існує бібліотека, яка робить саме це під назвою configglue .

configglue - це бібліотека, яка склеює optparse.OptionParser і ConfigParser.ConfigParser python, так що вам не доведеться повторювати себе, коли потрібно експортувати ті самі параметри у файл конфігурації та інтерфейс командного рядка.

Він також підтримує змінні середовища.

Також є ще одна бібліотека під назвою ConfigArgParse, яка є

Заміна заміни на аргпарсе, яка дозволяє також встановлювати параметри через конфігураційні файли та / або змінні середовища.

Можливо, вас зацікавить розмова PyCon про конфігурацію Юкаша Ланга - Нехай конфігурується!


Я запитав, чи є якісь плани щодо підтримки модуля argparse.
Пьотр Доброгост

10

Хоча я ще не пробував це самостійно, є бібліотека ConfigArgParse, яка стверджує, що вона робить більшість бажаних речей:

Заміна заміни на аргпарсе, яка дозволяє також встановлювати параметри через конфігураційні файли та / або змінні середовища.


1
Я спробував це, ConfigArgParse - це дуже зручно і дійсно є заміною.
maxschlepzig

7

Здається , що стандартна бібліотека не вирішує це, в результаті чого кожен програміста кругляк configparserі argparseі os.environвсе разом в незграбних способах.


5

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

Якщо ви хочете подивитися код, який я написав, це на http://liw.fi/cliapp/ . Він інтегрований у мою бібліотеку "рамковий додаток для командного рядка", оскільки це значна частина того, що потрібно робити.


4

Мене нещодавно пробували щось подібне, використовуючи "optparse".

Я встановив його як підклас OptonParser, за допомогою команд '--Store' та '--Check'.

Нижче наведений код повинен значною мірою охопити вас. Вам просто потрібно визначити свій власний метод "load" і "store", який приймає / повертає словники, і ви готові до цього.


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.

  • Найголовніше, що confectзавантажуються файли конфігурації Python не JSON / YMAL / TOML / INI. Так само, як файл профілю IPython або файл налаштувань DJANGO, файл конфігурації Python є гнучким і простішим в обслуговуванні.

Для отримання додаткової інформації перегляньте README.rst у сховищі проектів . Будьте в курсі, що він підтримує лише Python3.6 up.

Приклади

Приєднання параметрів командного рядка

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')

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