Python: конфигурация проекта без боли

Kate

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

В статье речь пойдет только о локальных способах хранения настроек, здесь не разбираются случаи загрузки из сети.

После создания проекта рано или поздно возникает вопрос: куда записывать номер версии, где хранить токены, пароли, настройки, каким форматом файлов конфигурации воспользоваться: .json, .yaml, .env, .cfg, .ini или просто создатьconfig.pyи записывать туда переменные?

Для каждого из перечисленных вариантов есть библиотека на python, приведу примеры самых популярных форматов.

ENV​

Переменные окружения могут передаваться программе напрямую командным интерпретатором: они записаны в .bashrc, выполнена команда export, или просто указаны в интерфейсе хостинга. Для того, чтобы их явно указать, удобно использовать .env файлы в директории проекта. Если прописать .env в .gitignore можно сложить туда все ключи и не боятся их опубликовать, в то время как осатльные настройки будут в другом месте для использования в продакшн окружении

VERSION=2.5.11
PG_USER=postgres
PG_DATABASE=my_project
Библиотека dotenv отлично справляется с задачей подгрузки таких файлов. Все переменные сразу попадают в os.environ

from dotenv import load_dotenv
import os

load_dotenv()
print(os.getenv('VERSION'))

YAML​

В последнее время стал популярен формат .yml, как один из самых приятных для чтения человеком

version: 2.5.11
logger:
level: INFO
format: '%(asctime)-15s %(clientip)s %(user)-8s %(message)s'
Открываем файл из кода

from yaml import load

with open('config.yml', 'r') as f:
data = load(f)

print(data['version'])

JSON​

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

{
"version": "2.5.11",
"postgres": {
"database": "my_project",
"host": "..."
"port": 5432
}
}
import json

with open('config.json', 'r') as f:
data = json.load(f)

print(data['version'])

PY​

В файле с расширением .py объявляем все необходимые переменные. У данного подхода есть преимощество - можно некоторые значения вычислять на ходу: инкриментировать номер сборки, менять хост в зависимости от режима: DEBUG или RELEASE и тд.

VERSION = '2.5.11'
PG_DATABASE = 'postgres'
PG_PORT = 5432
Осталось только импортировать этот файл там, где он нужен

from config import VERSION

print(VERSION)

INI​

[DEFAULT]
version = '2.5.11'

[postgres]
user = "postgres"
database = "my-project"
import configparser

config = configparser.ConfigParser()
config.read('settings.ini')
print(config["version"])

Все просто, разве нет?​

Самое интересное начинается, когда в проекте появляется несколько источников конфигов. Например статичные настройки лежат в config.yml, токены и пароли в переменных окружения и .env, некоторые записаны в файлеenv_file, используемом докером, часть значений нужно переопределить из кода, так как они заранее не известны, часть извлечь из аргументов программы.

Это ведет за собой менее красивый код, который обязан учитывать особенности хранения, приходится задумаваться, откуда мы берем очередное значения настроек, появляется несогласованность, ведь в одних местам мы обращаеся к os.environ, в других к глобальному словарю, в третьих, к сторонней библиотеке.

BestConfig​

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

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

from bestconfig import Config

config = Config()
print(config['version'])
И да, это все, что нужно сделать. Глобальный объект config уже будет содержать все необходимое. А теперь по порядку.

Что происходит при создании Config():

  1. Запускается поиск файлов, похожих на конфиги: любые комбинации имени conf, config, configuration, setting, settings с расширениями yml, yaml, json, ini, cfg, env в текущей директории и в папках выше, вплодь до корня проекта. Также в хранилище добавляются переменные окружения
  2. Исходя из формата файла производится импорт содержимого
  3. Создается класс ConfigProvider, предоставляющий универсальный доступ ко всему содержимому
Для самых дотошных
print(isinstance(Config(), ConfigProvider)) # True
Посмотрим на примерах, в чем же "универсальность" объекта config

Обращение по ключу​

print(config.get('version')) # При отсутствии возвращает None
print(config['verions']) # При отсутствии бросает KeyError
print(config.version) # Тоже может бросить KeyError

# Вложенные структуры
print(config.postgres.port)
print(config['postgres.port'])
print(config.get('postgres.port'))
# Очевидно, глубина вложенности не ограничена
Иногда мы хотим быть уверены, что значение принадлежит определенному типу

type(config.int('limit')) # int
type(config.dict('logger')) # dict
type(config.list('admins')) # list
# И тд: float, str
Преимущество такого подхода в том, что среда разработки сразу узнает тип и будет указывать на ошибки, также это дополнительная валидация самого конфига.

Иногда хочется где-нибудь в самом начале программы или скрипта убедиться, что необходимые переменные заданы, для этого предусмотренна специальная функция

config.assert_contains('key')
# Что эквивалентно assert config.get('key')
Класс ConfigProvider наследуется от dict, поэтому его можно спокойно передавать в сторонние библиотеки.

import logging.config
from config import config

logging.config.dictConfig(config['logging'])
print(config) # Выведется как словарь

Модификация​

from argparse import ArgumentParser
from config import config

if __name__ == '__main__':
# Парсим аргументы командной строки
parser = ArgumentParser()
parser.add_argument('log_level')
args = parser.parse_args()

# Обновляем общий конфиг
config.insert({'log_level', args.log_level})
Помимо словаря метод insert принимает названия файлов (с абсолютным или относильным путем) любого из поддерживаемых расширений

config.insert('logging.json')
# Кроме обычных методов dict-а есть set
config.set('key', 'value')
Выше я приводил пример, в котором конфиг переменные объявляются прямо в .py файле. Если в проекте уже используется такой вариант, необязательно всё менять, на этот случай есть метод update_from_locals

from config import config

VERSION = '2.5.11'
LOGGING_LEVEL = 'INFO'

config.update_from_locals()
В питоне есть встроенная функци locals(), она возвращает словарь локальных переменных, имеено ее и использует данный метод. Там, конечно, рассматриваются добавляемые объекты и лишние отсеиваются (подробнее в документации).

Кастомизация​

По умолчанию Config() делает довольно много всего, с целью быть универсальным, но содежит ряд аргументов, регулирующих это поведение.

# Исключить из списка по умолчанию некоторые файлы
Config(exclude=['server/setting.json'])
# Указать свои пути
Config('my-config.json', '../other-config.yml', 'app/settings.cfg')
# Импортировать только config.yml и кидать исключение при его отсутствии
# Игнорировать весь список по умолчанию
Config('config.yml', exclude_default=True, raise_on_absent=True)

Установка​

pip install bestconfig
Ссылки: pypi.com, github.com

Best practices​

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

  1. Статичные настройки, не содержащие ключей лежат в файле config.yml в корне проекта
  2. Переменные окружения и ключи задаются в .env и игнорируются системой контроля версий
  3. Файл config.py содержит создает класс Config и выполняет другие настройки (например логгирование, версия проекта, обработка параметров запуска и тд)
  4. В других местах проекта делаем from config import config и используемый нужные данные удобным образом

Резюме​

Библиотека создана чтобы максимально упростить распространенную задачу по обращению к файлам настроки, ее основные фичи и особенности:

  • Поддержка множества форматов файлов
  • Интерфейс доступа к данным "на любой вкус" (по ключу, через точку, через getи тд)
  • Маленький вес (примерно 15 кб)
  • Большое тестовое покрытие
  • Чистый, типизированный, расширяемый python код на модульной архитектуре
  • Открытость к изменениям и дополнениям (pull request-ы приветствуются)
Есть и недостатки:

  • У проекта появляются лишние зависимости. Например вы не используете yaml, однако библиотека подтягивает pyyaml
  • Захват лишнего. Вы можете написать Config() и даже не обратить внимание, сколько всего попало в config, одни переменные окружения чего стоят - их может быть очень много. И вывод print(config) становится абсолютно не читаемым
Теперь, при создании нового sandbox проекта или усложении текущего еще одним видом конфигурационных файлов, вы можете выбрать из своего арсенала еще один инструмент!

 
Сверху