Расскажу о проделанном пути, чтобы найти идеальный, для моих целей, инструмент конфигурирования проекта и о создании легковесной библиотеки bestconfig, впитавшей в себя преимущества изложенных подходов.
В статье речь пойдет только о локальных способах хранения настроек, здесь не разбираются случаи загрузки из сети.
После создания проекта рано или поздно возникает вопрос: куда записывать номер версии, где хранить токены, пароли, настройки, каким форматом файлов конфигурации воспользоваться: .json, .yaml, .env, .cfg, .ini или просто создатьconfig.pyи записывать туда переменные?
Для каждого из перечисленных вариантов есть библиотека на python, приведу примеры самых популярных форматов.
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'))
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'])
{
"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'])
VERSION = '2.5.11'
PG_DATABASE = 'postgres'
PG_PORT = 5432
Осталось только импортировать этот файл там, где он нужен
from config import VERSION
print(VERSION)
version = '2.5.11'
[postgres]
user = "postgres"
database = "my-project"
import configparser
config = configparser.ConfigParser()
config.read('settings.ini')
print(config["version"])
Это ведет за собой менее красивый код, который обязан учитывать особенности хранения, приходится задумаваться, откуда мы берем очередное значения настроек, появляется несогласованность, ведь в одних местам мы обращаеся к os.environ, в других к глобальному словарю, в третьих, к сторонней библиотеке.
Поставил целью уменьшить количество кода чтения конфигов почти до нуля, но в тоже время оставить гибкость и возможность подкрутки под более специфичные задачи. Итак, давайе посмотрим, как импортировать например config.yml
from bestconfig import Config
config = Config()
print(config['version'])
И да, это все, что нужно сделать. Глобальный объект config уже будет содержать все необходимое. А теперь по порядку.
Что происходит при создании Config():
print(isinstance(Config(), ConfigProvider)) # True
Посмотрим на примерах, в чем же "универсальность" объекта config
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 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(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)
Ссылки: pypi.com, github.com
В статье речь пойдет только о локальных способах хранения настроек, здесь не разбираются случаи загрузки из сети.
После создания проекта рано или поздно возникает вопрос: куда записывать номер версии, где хранить токены, пароли, настройки, каким форматом файлов конфигурации воспользоваться: .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():
- Запускается поиск файлов, похожих на конфиги: любые комбинации имени conf, config, configuration, setting, settings с расширениями yml, yaml, json, ini, cfg, env в текущей директории и в папках выше, вплодь до корня проекта. Также в хранилище добавляются переменные окружения
- Исходя из формата файла производится импорт содержимого
- Создается класс ConfigProvider, предоставляющий универсальный доступ ко всему содержимому
print(isinstance(Config(), ConfigProvider)) # True
Посмотрим на примерах, в чем же "универсальность" объекта config
Обращение по ключу
print(config.get('version')) # При отсутствии возвращает Noneprint(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 ArgumentParserfrom 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.- Статичные настройки, не содержащие ключей лежат в файле config.yml в корне проекта
- Переменные окружения и ключи задаются в .env и игнорируются системой контроля версий
- Файл config.py содержит создает класс Config и выполняет другие настройки (например логгирование, версия проекта, обработка параметров запуска и тд)
- В других местах проекта делаем from config import config и используемый нужные данные удобным образом
Резюме
Библиотека создана чтобы максимально упростить распространенную задачу по обращению к файлам настроки, ее основные фичи и особенности:- Поддержка множества форматов файлов
- Интерфейс доступа к данным "на любой вкус" (по ключу, через точку, через getи тд)
- Маленький вес (примерно 15 кб)
- Большое тестовое покрытие
- Чистый, типизированный, расширяемый python код на модульной архитектуре
- Открытость к изменениям и дополнениям (pull request-ы приветствуются)
- У проекта появляются лишние зависимости. Например вы не используете yaml, однако библиотека подтягивает pyyaml
- Захват лишнего. Вы можете написать Config() и даже не обратить внимание, сколько всего попало в config, одни переменные окружения чего стоят - их может быть очень много. И вывод print(config) становится абсолютно не читаемым
Python: конфигурация проекта без боли
Расскажу о проделанном пути, чтобы найти идеальный, для моих целей, инструмент конфигурирования проекта и о создании легковесной библиотеки bestconfig , впитавшей в себя преимущества изложенных...
habr.com