Расскажу о проделанном пути, чтобы найти идеальный, для моих целей, инструмент конфигурирования проекта и о создании легковесной библиотеки 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 проекта или усложении текущего еще одним видом конфигурационных файлов, вы можете выбрать из своего арсенала еще один инструмент!

Комментарии (11)


  1. extiander
    28.08.2021 16:11
    +2

    неплохой костыль, а то свои надоели уже :)


    1. snp
      28.08.2021 17:01
      +2

      https://pypi.org/project/dynaconf/ рассматривали? Уже более 6 лет проекту, функционал — тот же, что и в статье, а также ещё немало полезного. Например, может из Hashicorp Vault брать конфиг.


      1. fiobond Автор
        28.08.2021 17:34
        +1

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


      1. extiander
        28.08.2021 17:37
        +1

        Вот за это отедльное спасибо. самый кастилинг начинался как раз с валтом;


      1. mkone112
        28.08.2021 19:20
        +1

        dynaconf офигенен. Хотя там полгода назад была пара неприятных багов. И не хватало пары возможностей. Тут я попробовал добавить указание путей с помощью Path https://github.com/mk-dv/dynaconf-pathlib.Path/commit/83d9c9d8f6ca574474d3846a7687a0c9fa412fb5

        Но к сожалению времени на mr не оставалось - искал свою первую работу джуном=)

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


    1. ertaquo
      28.08.2021 17:26
      +1

      Если используете pydantic, то у него есть свой класс для настроек: https://pydantic-docs.helpmanual.io/usage/settings/ . Довольно удобно, если пользуетесь Docker/Kubernetes - настройки автоматом подтягиваются из environment и раскладываются по нужным типам с валидацией значений.


  1. Tanner
    28.08.2021 17:42

    Для проектов на компилируемых языках конфигурация -- это код, выполнение которого отсрочено от времени компиляции до времени выполнения. В динамических языках компиляция не отделяется от выполнения, поэтому и нет нужды хранить конфигурацию в каком-то особом формате. Разве что за исключением совсем сложных случаев, вроде того же Vault. А всякие JSON, XML, YAML, ini, env и т. д. попадают в проекты на Python по недоразумению


    1. funca
      28.08.2021 18:23
      +5

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


      1. Tanner
        28.08.2021 19:54
        +1

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

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

        Если же конфиг по ходу дела становится сложным, появляется шаблонизация, переменные, макросы и т. д., то "формат данных" превращается в доморощенный язык программирования. Скорее всего даже Тьюринг-полный, зато непопулярный, плохо документированный и глючный. Всё ещё не видите проблемы?

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

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

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

        Разница между кодом и данными может казаться призрачной (Lisp!). Мне проще всего объяснить, почему конфигурация -- это код, а не данные, с помощью правила "ноль-один-бесконечность". Нам на практике интересен только ограниченнный набор "корректных" конфигураций, но при этом мы заинтересованы корректно обрабатывать бесконечное разнообразие данных.

        Почему формат данных плохо подходит для кода, я, надеюсь, уже объяснил.


  1. katletmedown
    30.08.2021 10:43
    +1

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


  1. katletmedown
    30.08.2021 13:56
    +2

    Действительно, любопытный пример "чистого, типизированного, расширяемого python кода"image