Перевод статьи From Novice to Expert: How to Write a Configuration file in Python.

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

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

Разработка качественного программного обеспечения, включает заботу о разработке своей экосистемы, например, для организации процессов тестирования, развертывания, сетевого обмена данными и т.д. Одним из наиболее важных аспектов, который необходимо при этом учитывать, является реализация гибкого механизма управления конфигурацией (настройками) программного обеспечения.

Правильная реализация управления конфигурацией по сути должна позволять запускать программное обеспечение в любой среде без внесения изменений в его исходный код. Этот подход обеспечивает эффективное управление проблемными настройками вашего приложения со стороны администраторов Ops, обеспечивает представление информации о том, что может произойти во время его функционирования, а также позволяет изменять его поведение во время выполнения.

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

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

Когда необходим файл конфигурации приложения?

Перед разработкой конфигурационного файла сначала необходимо спросить себя, нужен ли вообще какой-либо внешний файл с данными? Разве мы не можем просто поместить их в виде константных значений прямо в исходном коде? Собственно, достаточно известная концепция The Twelve-Factor App давно отвечает на этот вопрос:

Лакмусовой бумажкой для проверки правильности решения о выделении config всей конфигурационной информации приложения из кода, является рассмотрение возможности о публикации в любой момент кодовой базы вашего приложения, то есть можно ли сделать его исходный код открытым, без нарушения конфиденциальности чьих-либо учетных данных.

Обратите внимание, что это определение config не включает внутреннюю конфигурацию приложения, такую как, например, как config/routes.rb в Rails, или способ подключения модулей в Spring. Перечисленные выше примеры способов конфигурации не меняются в зависимости от среды развертывания, и поэтому это лучше всего реализовать их в коде.

Подходы рекомендованные этой концепцией предписывают, чтобы любые параметры, зависящие от среды, такие как учетные данные базы данных, находились во внешнем файле. В противном случае их реализуют просто обычными константами в коде. Другой вариант использования, который я часто вижу, – это хранение динамических переменных (данных) во внешнем файле (базе данных), например, черный blacklist или белый whitelist список пользователей. Ими могут быть числа в заданном диапазоне (например, длительность тайм-аута) или любые текстовые файлы с произвольным содержимым. Отметим, что эти динамические переменные (данные) остаются неизменными вне зависимости от особенностей исполняемой среды.

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

Какой формат файла конфигурации использовать?

С практической точки зрения, на формат файла конфигурации нет никаких технических ограничений, если код приложения может его прочитать и анализировать. Но есть и более рациональные практики для выбора формата файла с настройками. Так наиболее распространенными, стандартизованными форматами являются YAML, JSON, TOML и INI. Самый подходящий формата для файл конфигурации должен соответствовать как минимум трем критериям:

  1. Быть легко читаемым и редактируемым: файл должен иметь текстовый формат и такую структуру, чтобы его содержимое было легко понятно даже не разработчику.
  2. Разрешать использование комментариев: файл конфигурации – это то, что могут читать не только разработчики. Поэтому в процессе эксплуатации приложения чрезвычайно важно когда пользователи могут успешно пытаться понять его работу и изменить его поведение. Написание комментариев – это эффективный способ быстро пояснить ключевые особенности настройки приложения и делает конфигурационный файл более выразительным.
  3. Простота развертывания: файл конфигурации должен понятен для обработки всеми операционными системами и средами. Он также должен легко доставляться на сервер с помощью конвейера pipeline CDaaS.

Возможно вам пока не ясно какой из форматов файла лучше использовать. Но если вы подумаете об этом в контексте программирования на языке Python, то наиболее очевидным ответом будет YAML или INI. Форматы YAML и INI хорошо понятны большинству программ и пакетов Python.

INI файл, вероятно, является наиболее простым решением для сохранения настроек приложения, имеющих только один уровень иерархии (вложенности) параметров. Однако формат INI не поддерживает других типов данных, кроме строк: в нем все данные имеют строковое представление.

[APP]
ENVIRONMENT = test
DEBUG = True
# Принимает значение только True или False

[DATABASE]
USERNAME = xiaoxu
PASSWORD = xiaoxu
HOST = 127.0.0.1
PORT = 5432
DB = xiaoxu_database

Та же конфигурация настроек в YAML выглядит следующим образом.

APP:
  ENVIRONMENT: test
  DEBUG: True
  # Принимает значение только True или False

DATABASE:
  USERNAME: xiaoxu
  PASSWORD: xiaoxu
  HOST: 127.0.0.1
  PORT: 5432
  DB: xiaoxu_database

Как видите, YAML изначально поддерживает использование вложенные структуры (также как и JSON) с помощью отступов. Кроме того, YAML, в отличие от формата INI файлов, поддерживает некоторые другие типы данных такие как целые и с плавающей запятой числа, логические значения, списки, словари и т.д.

Формат файлов JSON по сути очень похож на YAML и тоже чрезвычайно популярен, однако в JSON файлы нельзя добавлять комментарии. JSON, как текстовый формат содержащий структурированные данные, часто используется для хранения внутренней конфигурации внутри программы, но совершенно не предназначен для того, чтобы делиться конфигурацией приложения с другими людьми (в особенности с далекими от вопросов разработки ПО).

{
    "APP": {
        "ENVIRONMENT": "test",
        "DEBUG": true
    },
    "DATABASE": {
        "USERNAME": "xiaoxu",
        "PASSWORD": "xiaoxu",
        "HOST": "127.0.0.1",
        "PORT": 5432,
        "DB": "xiaoxu_database"
    }
}

Формат TOML, с другой стороны, похож на INI, но поддерживает гораздо больше типов данных, а также специальный синтаксис для хранения вложенных структур. Его часто используют менеджеры пакетов Python такие, например, pip или poetry. Но если в файле конфигурации присутствует слишком много вложенных структур, то YAML в этом отношении, с моей точки зрения, наилучший выбор. Следующий ниже фрагмент файла выглядит как INI, но в отличие от него каждое строковое значение имеет кавычки.

[APP]
ENVIRONMENT = "test"
DEBUG = true
# Only accept True or False

[DATABASE]
USERNAME = "xiaoxu"
PASSWORD = "xiaoxu"
HOST = "127.0.0.1"
PORT = 5432
DB = "xiaoxu_database"

Пока что мы выяснили ЧТО из себя представляют форматы файлов YAML, JSON, TOML и INI, далее мы рассмотрим КАК они могут быть использованы.

YAML/JSON — простое чтение внешнего файла

Как обычно, мы начнем с самого простого, то есть создадим внешний файл с настройками, а затем прочитаем его. Python имеет в своем составе встроенные пакеты для чтения и анализа файлов YAML и JSON. И как видно из приведенного ниже кода, они фактически возвращают один и тот же объект типа dict, поэтому доступ к его атрибутам будет одинаковым для обоих файлов.

Чтение

Из-за проблем с безопасностью рекомендуется использовать метод yaml.safe_load() вместо yaml.load(), чтобы избежать внедрения вредоносного кода при чтении файла конфигурации.

import json
import yaml

def read_json(file_path):
    with open(file_path, "r") as f:
        return json.load(f)

def read_yaml(file_path):
    with open(file_path, "r") as f:
        return yaml.safe_load(f)

assert read_json("data/sample.json") == read_yaml("data/sample.yaml")

Валидация

При использовании обоих пакетов при попытке чтения несуществующего файла будет генерироваться исключение типа FileNotFoundError. Использование пакета для чтения файлов YAML позволяет получать разные исключения для следующих случаев: пользователь указал файл не являющимся YAML файлом, а также прочитанный файл YAML является не корректным, то есть содержит синтаксические ошибки. В свою очередь пакет для чтения JSON файлов генерирует единственное исключение типа JSONDecoderError для обоих рассмотренных случаев.

import pytest

def test_validation_json():
    with pytest.raises(FileNotFoundError):
        read_json(file_path="source/data/non_existing_file.json")

    with pytest.raises(json.decoder.JSONDecodeError):
        # only show the first error
        read_json(file_path="source/data/sample_invalid.json")
        read_json(file_path="source/data/sample_invalid.yaml")

def test_validation_yaml():
    with pytest.raises(FileNotFoundError):
        read_yaml(file_path="source/data/non_existing_file.yaml")

    with pytest.raises(yaml.scanner.ScannerError):
        # only show the first error
        read_yaml(file_path="source/data/sample_invalid.yaml")

    with pytest.raises(yaml.parser.ParserError):
        # only show the first error
        read_yaml(file_path="source/data/sample_invalid.json")

Пакет Cofigureparser из состава стандартной библиотеки Python

В этом разделе рассмотрим пакеты, предназначенные непосредственно для управления конфигурацией приложения. И начнем со встроенного в стандартную библиотеку Python пакета: Configureparser.

Configureparser в большинстве случаев используется для чтения и записи INI файлов, и поддерживает чтение входных данных из файла сразу в виде словаря или итерируемого iterable файлоподобного объекта. Как известно, каждый файл INI состоит из нескольких секций, содержащих настройки в виде пар ключ-значение. Ниже приведен простой пример кода для доступа к полям настроек.

Чтение

import configparser

def read_ini(file_path, config_json):
    config = configparser.ConfigParser()
    config.read(file_path)
    for section in config.sections():
        for key in config[section]:
            print((key, config[section][key]))
 
read_ini("source/data/sample.ini", config_json)
# ('environment', 'test')
# ('debug', 'True')
# ('username', 'xiaoxu')
# ('password', 'xiaoxu')
# ('host', '127.0.0.1')
# ('port', '5432')
# ('db', 'xiaoxu_database')

Как видно из примера Configureparser не может «угадать» типы данных, содержащихся в файле конфигурации, так как значение каждой настройки сохраняется в виде строки. Тем не менее, он предоставляет несколько полезных методов для преобразования строк (значений настроек) в нужный тип данных. Наиболее интересным из них является метод преобразующий значения в логический тип, то есть он может распознавать некоторые логические значения, например, yes/no, on/off, true/false и 1/0.

Как уже нами упоминалось ранее, Configureparser может читать данные настроек в следующих видах на выходе: словаря с помощью метода read_dict(), простой строки с использованием read_string() и итерируемого файлоподобного объекта, возвращаемого методом read_file().

import configparser

def read_ini_extra(file_path, dict_obj=None):
    config = configparser.ConfigParser()
    if dict_obj:
        config.read_dict(dict_obj)
    else:
        config.read(file_path)
    debug = config["APP"].getboolean("DEBUG")
    print(type(debug))
    # <class 'bool'>
    name = config.get('APP', 'NAME', fallback='NAME is not defined')
    print(name)
    return debug

# читаем ini файл
read_ini_extra(file_path="source/data/sample.ini")

# читаем данные в словарь
config_json = read_json(file_path="source/data/sample.json")
read_ini_extra(dict_obj=config_json)

Валидация

Валидация данных с Configureparser не так проста, как для пакетов, работающих с форматами YAML и JSON. Во-первых, он не возбуждает исключения FileNotFoundError если файла настроек не существует, а вместо этого вызывает исключение типа KeyError, как при попытке доступа к отсутствующему ключу.

Кроме того, этот пакет «игнорирует» некоторые ошибки форматирования, например, неправильное использование отступа. Так в приведенном ниже примере, в случае если в файле присутствует дополнительная табуляция или пробел перед настройкой DEBUG, то вы получите неправильные значения для обеих настроек ENVIRONMENT и DEBUG.

ENVIRONMENT=test
	DEBUG=true
USERNAME=xiaoxu
PASSWORD=xiaoxu
HOST=127.0.0.1
PORT=5432

Тем не менее, Configureparser может возбуждать исключение ParserError при наличии нескольких ошибок (см. пример кода с тестами ниже). И в большинстве случаев этого достаточно для определения проблемных мест в самом файле настроек.

import pytest

def test_validation_configureparser():
    # не возбуждается FileNotFoundError, возбуждается KeyError
    # как и для случая отсутствия ключа (параметра настройки)
    with pytest.raises(KeyError):
        read_ini_extra(file_path="source/data/non_existing_file.ini")

    # [APP]
    # ENVIRONMENT = test
    #     DEBUG = True
    # не возбуждается исключение из-за неверной расстановки отступов
    debug = read_ini_extra(
        file_path="source/data/sample_wrong_indentation.ini"
    )
    print(debug)
    # None
    # Однако, config["APP"]["ENVIRONMENT"] вернет 'test\nDEBUG = True'

    # [APP]
    # ENVIRONMENT = test
    # DEBUG  True

    # [DATABASE]
    # USERNAME = xiaoxu
    # PASSWORD xiaoxu
    with pytest.raises(configparser.ParsingError):
        debug = read_ini_extra(
            file_path="source/data/sample_wrong_key_value.ini"
        )
    # выведет все ошибки
    # configparser.ParsingError: Source contains parsing errors: 'source/data/sample_wrong_key_value.ini'
    #         [line  3]: 'DEBUG  True\n'
    #         [line  8]: 'PASSWORD xiaoxu\n'

Python-dotenv — считываем конфигурацию приложения из переменных среды

Теперь перейдем к сторонним библиотекам, использующимся для управления конфигурацией приложений Python. До сих пор я намеренно пропустил еще один тип файлов конфигурации, а именно .env. Так значения настроек, находящихся в файле .env при запуске терминала (скрипта приложения) будут загружены как переменные среды, и поэтому с помощью библиотеки python-dotenv, а точнее ее метода os.getenv() можно получить доступ к ним из кода приложения.

Файл .env обычно выглядит следующим образом. По умолчанию его местонахождение – корневая папка вашего проекта.

ENVIRONMENT=test
DEBUG=true
USERNAME=xiaoxu
PASSWORD=xiaoxu
HOST=127.0.0.1
PORT=5432

Чтение

Этот тип файла конфигурации очень легко использовать. Так если вы решите переопределить существующую (или создать новую) переменную среды, то можете использовать вызов метод load_dotenv(), например, зададим значение параметра override.

import os
from dotenv import load_dotenv

load_dotenv()
print(os.getenv('DEBUG'))
# true

load_dotenv(override=True)
# переопределяем переменную среды

Валидация

Тем не менее пакет python-dotenv не проверяет корректность .env файла. Допустим у вас есть некоторый .env файл (его содержимое представлено ниже), и вы хотите получить доступ к значению переменной (параметра настройки) DEBUG, то будет возвращено значение None без возбуждения исключения соответствующего типа.

# .env
ENVIRONMENT=test
DEBUG
# load.py
load_dotenv()
print('DEBUG' in os.environ.keys())
# False

Dynaconf — мощный конфигуратор настроек для приложений Python

Dynaconf — это очень мощная система для конфигурации настроек в Python, которая поддерживает следующие форматы файлов: yaml, json, ini, toml и python. Она также позволяет автоматически загружать .env файл и поддерживает настраиваемые правила для валидации данных настроек. Проще говоря, он охватывает практически весь функционал трех предыдущих рассмотренных вариантов и даже выходит за рамки этого. Например, вы можете сохранить зашифрованный пароль и используя специальный загрузчик для его расшифровки. Он прекрасно интегрирован с Flask, Django и Pytest. Я не буду упоминать все его возможности в этой статье, и поэтому для более подробной информации обратитесь к его документации.

Чтение

Dynaconf использует .env файл для поиска других конфигурационных файлов и последующего заполнения полей settings объекта с настройками. Так если в двух файлах настроек есть одна и та же переменная, то ее значение будет перезаписано значением из последнего файла настроек.

# .env
# ROOT_PATH_FOR_DYNACONF="config/"
# SETTINGS_FILE_FOR_DYNACONF="['settings.ini', 'settings2.ini']"

from dynaconf import settings

print(settings["DB"])
# xiaoxu_database

Валидация

Одна из наиболее интересных, в частности для меня, возможностей dynaconf – это его настраиваемый валидатор. Как упоминалось ранее, Configureparser недостаточно строго проверяет корректность INI файлов настроек, но это можно легко реализовать в dynaconf. В следующем примере мы проверяем, существуют ли определенные ключи в файле с настройками и имеется ли в нем конкретный ключ с корректным значением. Если вы читаете настройки из файла YAML или TOML, которые как мы говорили ранее, поддерживают несколько типов данных, то вы даже можете проверить, находится ли значение настройки, например, число в заданном диапазоне.

# settings.ini
# [default]
# ENVIRONMENT = test
# DEBUG = True
# USERNAME = xiaoxu
# PASSWORD = xiaoxu
# HOST = 127.0.0.1
# PORT = 5432
# DB = xiaoxu_database

# [production]
# DEBUG = False

from dynaconf import settings, Validator

settings.validators.register(
    Validator('ENVIRONMENT', 'DEBUG', 'USERNAME', must_exist=True),
    Validator('PASSWORD', must_exist=False),
    Validator('DEBUG', eq='False', env='production')
)

# запускаем валидатор
settings.validators.validate()

# dynaconf.validator.ValidationError: PASSWORD cannot exists in env test

Интеграция с Pytest

Еще одна интересная особенность dynaconf – это возможность его интеграции с pytest. Так настройки для модульного тестирования unit testing обычно существенно отличаются в различных средах. Для этого вы можете использовать параметр FORCE_ENV_FOR_DYNACONF, чтобы ваше приложение могло прочитать значения настроек из внешнего файла, или использовать фикстуру monkeypatch для замены определенных пар ключ и значение в файле настроек.

import pytest
from dynaconf import settings

@pytest.fixture(scope="session", autouse=True)
def set_test_settings():
    settings.configure(FORCE_ENV_FOR_DYNACONF="testing")

def test_dynaconf(monkeypatch):
    monkeypatch.setattr(settings, 'HOST', 'localhost')

Обновляем конфигурацию приложения во время его выполнения

Dynaconf в своем составе содержит метод reload(), который очищает значения настроек и перезапускает все загрузчики вашего приложения. Это полезно, если вы хотите, чтобы приложение перезагружало файл настроек во время выполнения и соответственно в последствие изменяло свое поведение. Например, приложение должно автоматически перезагрузить настройки, если файл конфигурации был открыт и изменен (откорректирован).

Hydra — упрощаем разработку, динамически создавая иерархическую структуру конфигурации приложения

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

Hydra – это платформа, разработанная Facebook для гибкой и элегантной настройки самых сложных приложений. Которая помимо чтения, записи и валидации корректности файлов конфигурации, реализовывает свою достаточно рациональную стратегию упрощения управления несколькими конфигурационными файлами, переопределения (перезаписи) их с использованием интерфейса командной строки, создания snapshot снимка состояния приложения перед каждым его запуском (между перезапусками) и т.д.

Чтение

Рассмотрим основы использования hydra. Так в следующем примере команда +APP.NAME, добавленная в командную строку при запуске скрипта, позволяет добавить новое поле (настройку) в конфигурацию приложения, а также осуществить перезапись значения существующего поля (значения настройки) APP.NAME=hydra1.1.

import hydra
from omegaconf import DictConfig, OmegaConf

@hydra.main(config_name="config")
def my_app(cfg: DictConfig) -> None:
    print(OmegaConf.to_yaml(cfg))

if __name__ == "__main__":
    my_app()

# запускаем в командной строке скрипт с командами
# python3 source/hydra_basic.py +APP.NAME=hydra
#
# результат его выполнения
# APP:
#   ENVIRONMENT: test
#   DEBUG: true
#   NAME: hydra1.1

Валидация

Hydra прекрасно интегрируется с декоратором @dataclass для выполнения основных проверок корректности, таких как проверка типов или значения полей. Однако у нее нет поддержки __post_init__ метода расширенной проверки значений, как это описано в моей предыдущей статье.

from dataclasses import dataclass
from omegaconf import MISSING, OmegaConf
import hydra
from hydra.core.config_store import ConfigStore

@dataclass
# @dataclass(frozen=True) способ определения полей только для чтения 
class MySQLConfig:
    driver: str = "mysql"
    host: str = "localhost"
    port: int = 3306
    user: str = MISSING
    password: str = MISSING

@dataclass
class Config:
    db: DBConfig = MISSING

cs = ConfigStore.instance()
cs.store(name="config", node=Config)
cs.store(group="db", name="mysql", node=MySQLConfig)

@hydra.main(config_path="conf", config_name="config")
def my_app(cfg: Config) -> None:
    print(OmegaConf.to_yaml(cfg))

if __name__ == "__main__":
    my_app()

Группа конфигураций

Hydra вводит концепцию под названием config group . Идея которой состоит в том, чтобы сгруппировать файлы конфигурации одного типа (или для выполнения одних задач) и затем выбирать один из них во время выполнения приложения. Например, у вас имеется группа настроек «Базы данных» с одной конфигурацией для Postgres, а другой для MySQL.

Когда конфигурация приложения станет более сложной, то в вашей программе она может иметь следующую структуру (пример из документации Hydra).

├── conf
│   ├── config.yaml
│   ├── db
│   │   ├── mysql.yaml
│   │   └── postgresql.yaml
│   ├── schema
│       ├── school.yaml
│       ├── support.yaml
│       └── warehouse.yaml     
└── my_app.py

Например, вы хотите протестировать свое приложение с различными комбинациями опций db, schema и ui, это можно сделать следующим образом:

python my_app.py db=postgresql schema=school.yaml

Далее…

Hydra поддерживает использование нескольких наборов параметров конфигурации с опцией --multirun, при этом запускаются параллельно несколько задач с различными файлами конфигурации. Например, для предыдущего примера мы можем запустить скрипт следующим образом:

python my_app.py schema=warehouse,support,school db=mysql,postgresql -m

В этом случае в основном потоке запускаются 6 задач одновременно:

[2019-10-01 14:44:16,254] - Launching 6 jobs locally
[2019-10-01 14:44:16,254] - Sweep output dir : multirun/2019-10-01/14-44-16
[2019-10-01 14:44:16,254] -     #0 : schema=warehouse db=mysql
[2019-10-01 14:44:16,321] -     #1 : schema=warehouse db=postgresql
[2019-10-01 14:44:16,390] -     #2 : schema=support db=mysql
[2019-10-01 14:44:16,458] -     #3 : schema=support db=postgresql
[2019-10-01 14:44:16,527] -     #4 : schema=school db=mysql
[2019-10-01 14:44:16,602] -     #5 : schema=school db=postgresql

Вывод

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

Надеюсь, вам понравится эта статья, не стесняйтесь оставлять свои комментарии ниже.

1 комментарий

Добавить комментарий для время намаза центорой X